Compare commits

..

6 Commits

Author SHA1 Message Date
kingchenc 6e0464930e release: bump 0.5.6 -> 0.5.7 (#182)
Version bump 0.5.6 → 0.5.7 for the **B3 — Trend & Directional** batch (#181):
seven new indicators (`Qstick`, `TtmTrend`, `TrendStrengthIndex`,
`PolarizedFractalEfficiency`, `WavePm`, `GatorOscillator`,
`KasePermissionStochastic`), catalog 413 → 420.

Bumps: workspace `Cargo.toml` + `Cargo.lock`, `bindings/python/pyproject.toml`,
`bindings/node/package.json` + the six `npm/*/package.json` platform manifests,
both `package-lock.json` files, and the `CHANGELOG.md` `[Unreleased]` → `[0.5.7]`
roll with compare URLs.
2026-06-04 18:06:57 +02:00
kingchenc 13bc801f89 feat(indicators): B3 Trend & Directional batch (413 -> 420) (#181)
Adds the **B3 — Trend & Directional** batch: seven new indicators, taking the
catalog from 413 to 420 (Trend & Directional family).

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `Qstick` | candle → f64 | Chande's SMA of the candle body (close − open) |
| `TtmTrend` | candle → f64 (±1) | John Carter close-vs-median-SMA trend filter |
| `TrendStrengthIndex` | f64 → f64 | signed r² of an OLS regression of price vs time |
| `PolarizedFractalEfficiency` | f64 → f64 | Hannula directional trend efficiency |
| `WavePm` | f64 → f64 | Kase variance-normalised peak-momentum statistic (reconstruction) |
| `GatorOscillator` | candle → struct | Bill Williams Alligator convergence/divergence histogram |
| `KasePermissionStochastic` | candle → struct | double-smoothed stochastic permission filter |

Note: the roadmap's "Directional Indicator +DI/−DI" item is already covered by
the existing standalone `PlusDi` / `MinusDi` / `Dx`, so it is intentionally not
re-added.

All touchpoints wired: core (every-branch unit tests), Python/Node/WASM
bindings, fuzz drivers, Python test registries + reference tests, Node
factories, README/CHANGELOG counters.

Local verify: `cargo test -p wickra-core` (lib 3389 + doc 378), `cargo clippy
--workspace --all-targets --all-features -- -D warnings`, node build + 495
tests, maturin + 815 pytest, counter 420 == 420.
2026-06-04 17:57:24 +02:00
kingchenc ac8f6acf08 release: bump 0.5.5 -> 0.5.6 (#180)
Release bump `0.5.5 → 0.5.6` for the Momentum Oscillators family deepening
(#179): ten new indicators (DisparityIndex, FisherRsi, Rmi, DerivativeOscillator,
Rsx, DynamicMomentumIndex, IntradayMomentumIndex, StochasticCci, ElderRay, Qqe),
counter now 413.

Version strings only across all manifests + lockfiles; CHANGELOG `[Unreleased]`
rolled to `[0.5.6] - 2026-06-04` with the new compare links.
2026-06-04 15:35:41 +02:00
kingchenc 4f81222aed Deepen Momentum Oscillators family with ten additions (#179)
Deepens the **Momentum Oscillators** family with ten widely-used oscillators
(403 → 413 indicators), the second batch of Part B (family deepening).

| Indicator | Binding | Input → Output |
|-----------|---------|----------------|
| `DisparityIndex` | `DisparityIndex` | scalar → scalar |
| `FisherRsi` | `FisherRSI` | scalar → scalar |
| `Rmi` | `RMI` | scalar (period, momentum) → scalar |
| `DerivativeOscillator` | `DerivativeOscillator` | scalar (4 periods) → scalar |
| `Rsx` | `RSX` | scalar → scalar |
| `DynamicMomentumIndex` | `DynamicMomentumIndex` | scalar → scalar |
| `IntradayMomentumIndex` | `IMI` | candle (open+close) → scalar |
| `StochasticCci` | `StochasticCCI` | candle → scalar |
| `ElderRay` | `ElderRay` | candle → struct (bull/bear) |
| `Qqe` | `QQE` | scalar → struct (rsi_ma/trailing) |

LSMA was dropped from the planned set: it already ships as `LinearRegression`.

The single-period scalars use generated macro bindings; `Rmi` /
`DerivativeOscillator` use hand node/python bindings with the typed wasm macro;
`ElderRay`/`Qqe` use custom struct bindings; `IntradayMomentumIndex` uses custom
candle bindings carrying the open. Full coverage: core modules with per-branch
unit tests, mod/lib catalogue, FAMILIES + assert, README + docs counters,
CHANGELOG, all three bindings (regenerated `index.d.ts`/`index.js`), fuzz
drivers, and the python/node test registries.

Local verification: `cargo test -p wickra-core` (lib 3335 + doc 371),
`cargo clippy --workspace --all-targets --all-features -D warnings` clean,
node `npm run build && npm test` (488), python `pytest` (802).
2026-06-04 15:26:17 +02:00
kingchenc 0d2acad28d release: bump 0.5.4 -> 0.5.5 (#178)
Release bump `0.5.4 → 0.5.5` for the Moving Averages family deepening
(#177): seven new indicators (`SineWeightedMa`, `GeometricMa`, `Ehma`,
`MedianMa`, `AdaptiveLaguerreFilter`, `GeneralizedDema`, `HoltWinters`),
counter now 403.

Version strings only across all manifests + lockfiles; CHANGELOG `[Unreleased]`
rolled to `[0.5.5] - 2026-06-04` with the new compare links.
2026-06-04 13:55:26 +02:00
kingchenc b228a70d7d Deepen Moving Averages family with seven additions (#177)
Deepens the **Moving Averages** family with seven widely-used variants
(396 → 403 indicators), the first batch of Part B (family deepening).

All are scalar `f64 → f64`:

| Indicator | Binding | Notes |
|-----------|---------|-------|
| `SineWeightedMa` | `SWMA` | symmetric half-cycle sine-weighted window |
| `GeometricMa` | `GMA` | rolling geometric mean (log-space average) |
| `Ehma` | `EHMA` | exponential Hull MA (Hull construction over EMAs) |
| `MedianMa` | `MedianMA` | rolling median, robust to single outliers |
| `AdaptiveLaguerreFilter` | `AdaptiveLaguerre` | Ehlers' adaptive Laguerre filter (median-of-normalised-error γ) |
| `GeneralizedDema` | `GD` | Tillson's volume-factor double EMA; `v=1` is DEMA, `v=0` is EMA |
| `HoltWinters` | `HoltWinters` | Holt's linear double exponential smoothing (level + trend) |

LSMA was dropped from the planned set: it already ships as `LinearRegression`
(TA-Lib `LINEARREG`, the rolling least-squares endpoint).

The five single-period filters use the generated scalar macro bindings;
`GeneralizedDema` (period, v) and `HoltWinters` (alpha, beta) use hand-written
node/python bindings with the typed wasm macro (precedent `T3` / `Alma`).

Full coverage: core modules with per-branch unit tests (100% intent), mod/lib
catalogue, FAMILIES group + assert, README + docs counters, CHANGELOG, all three
bindings (regenerated `index.d.ts` / `index.js`), fuzz drivers, and the
python/node test registries.

Local verification: `cargo test -p wickra-core` (lib 3255 + doc 361),
`cargo clippy --workspace --all-targets --all-features -D warnings` clean,
node `npm run build && npm test` (478), python `pytest` (791).
2026-06-04 13:44:51 +02:00
51 changed files with 9022 additions and 120 deletions
+34 -1
View File
@@ -7,6 +7,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.7] - 2026-06-04
- **Qstick** — Qstick (Chande), the SMA of the candle body (close open) as a net buying/selling pressure gauge (`QSTICK`).
- **TTM Trend** — TTM Trend (John Carter), +1/1 by whether the close sits above the SMA of recent median prices (`TTM_TREND`).
- **Trend Strength Index** — trend strength index, the signed r² of a linear regression of price against time (`TREND_STRENGTH_INDEX`).
- **Polarized Fractal Efficiency** — polarized fractal efficiency (Hannula), directional trend efficiency over a fractal lookback (`POLARIZED_FRACTAL_EFFICIENCY`).
- **Wave PM** — Wave PM (Kase), a variance-normalised peak-momentum statistic (`WAVE_PM`).
- **Gator Oscillator** — Gator Oscillator (Bill Williams), the Alligator convergence/divergence histogram (`GATOR_OSCILLATOR`).
- **Kase Permission Stochastic** — Kase Permission Stochastic, a double-smoothed stochastic used as a trade-permission filter (`KASE_PERMISSION_STOCHASTIC`).
## [0.5.6] - 2026-06-04
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
## [0.5.5] - 2026-06-04
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
- **Holt-Winters** — Holt's linear (double exponential) smoothing with level and trend components (`HoltWinters`).
- **Adaptive Laguerre** — Ehlers adaptive Laguerre filter with median-error-adaptive gamma (`AdaptiveLaguerre`).
- **Median MA** — median moving average, the rolling median of prices (`MedianMA`).
- **EHMA** — exponential Hull moving average (EHMA), the Hull construction built from EMAs (`EHMA`).
- **SWMA** — sine-weighted moving average (SWMA), a symmetric half-cycle sine window (`SWMA`).
## [0.5.4] - 2026-06-04
- **Roll Measure** — effective spread implied by the negative serial covariance of trade-price changes (Roll 1984) (`RollMeasure`).
- **Amihud Illiquidity** — average absolute log return per unit of traded value (price-impact liquidity proxy, Amihud 2002) (`AmihudIlliquidity`).
@@ -1238,7 +1268,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.4...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.7...HEAD
[0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/wickra-lib/wickra/compare/v0.5.1...v0.5.2
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.5.4"
version = "0.5.7"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.5.4"
version = "0.5.7"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.4"
version = "0.5.7"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.4"
version = "0.5.7"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.4"
version = "0.5.7"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.4"
version = "0.5.7"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -12,7 +12,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.5.4"
version = "0.5.7"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.5.4" }
wickra-core = { path = "crates/wickra-core", version = "0.5.7" }
thiserror = "2"
rayon = "1.10"
+7 -7
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=396" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=420" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 396 indicators; start at the
every one of the 420 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -136,16 +136,16 @@ python -m benchmarks.compare_libraries
## Indicators
396 streaming-first indicators across twenty-four families. Every one passes the
420 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Family | Indicators |
|--------|-----------|
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100) |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
| Moving Averages | SMA, EMA, WMA, DEMA, TEMA, HMA, KAMA, SMMA, TRIMA, ZLEMA, T3, VWMA, ALMA, McGinley Dynamic, FRAMA, VIDYA, JMA, Alligator, EVWMA, SWMA, GMA, EHMA, Median MA, Adaptive Laguerre, GD, Holt-Winters |
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
@@ -245,7 +245,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 396 indicators
│ ├── wickra-core/ core engine + all 420 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -28,6 +28,22 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
WAVE_PM: () => new wickra.WAVE_PM(32, 3),
POLARIZED_FRACTAL_EFFICIENCY: () => new wickra.POLARIZED_FRACTAL_EFFICIENCY(10, 5),
TREND_STRENGTH_INDEX: () => new wickra.TREND_STRENGTH_INDEX(20),
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
RMI: () => new wickra.RMI(14, 5),
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
RSX: () => new wickra.RSX(14),
FisherRSI: () => new wickra.FisherRSI(14),
DisparityIndex: () => new wickra.DisparityIndex(14),
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
GD: () => new wickra.GD(5, 0.7),
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
MedianMA: () => new wickra.MedianMA(14),
EHMA: () => new wickra.EHMA(9),
GMA: () => new wickra.GMA(14),
SWMA: () => new wickra.SWMA(14),
Expectancy: () => new wickra.Expectancy(20),
WinRate: () => new wickra.WinRate(20),
RegimeLabel: () => new wickra.RegimeLabel(5, 20),
@@ -327,6 +343,10 @@ const candleScalar = {
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -409,6 +429,10 @@ const multi = {
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
};
for (const [name, d] of Object.entries(multi)) {
+232
View File
@@ -69,6 +69,22 @@ export interface HtPhasorValue {
inphase: number
quadrature: number
}
export interface QqeValue {
rsiMa: number
trailingLine: number
}
export interface ElderRayValue {
bullPower: number
bearPower: number
}
export interface GatorOscillatorValue {
upper: number
lower: number
}
export interface KasePermissionStochasticValue {
fast: number
slow: number
}
export interface StochValue {
k: number
d: number
@@ -872,6 +888,96 @@ export declare class Expectancy {
isReady(): boolean
warmupPeriod(): number
}
export type SineWeightedMaNode = SWMA
export declare class SWMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GeometricMaNode = GMA
export declare class GMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type EhmaNode = EHMA
export declare class EHMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MedianMaNode = MedianMA
export declare class MedianMA {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AdaptiveLaguerreFilterNode = AdaptiveLaguerre
export declare class AdaptiveLaguerre {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DisparityIndexNode = DisparityIndex
export declare class DisparityIndex {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type FisherRsiNode = FisherRSI
export declare class FisherRSI {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RsxNode = RSX
export declare class RSX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DynamicMomentumIndexNode = DynamicMomentumIndex
export declare class DynamicMomentumIndex {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TrendStrengthIndexNode = TREND_STRENGTH_INDEX
export declare class TREND_STRENGTH_INDEX {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type JumpIndicatorNode = JumpIndicator
export declare class JumpIndicator {
constructor(period: number, threshold: number)
@@ -1369,6 +1475,96 @@ export declare class HighLowRange {
isReady(): boolean
warmupPeriod(): number
}
export type StochasticCciNode = StochasticCCI
export declare class StochasticCCI {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ImiNode = IMI
export declare class IMI {
constructor(period: number)
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type QqeNode = QQE
export declare class QQE {
constructor(rsiPeriod: number, smoothing: number, factor: number)
update(value: number): QqeValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ElderRayNode = ElderRay
export declare class ElderRay {
constructor(period: number)
update(high: number, low: number, close: number): ElderRayValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TtmTrendNode = TTM_TREND
export declare class TTM_TREND {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type QstickNode = Qstick
export declare class Qstick {
constructor(period: number)
update(open: number, close: number): number | null
batch(open: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type PolarizedFractalEfficiencyNode = POLARIZED_FRACTAL_EFFICIENCY
export declare class POLARIZED_FRACTAL_EFFICIENCY {
constructor(period: number, smoothing: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WavePmNode = WAVE_PM
export declare class WAVE_PM {
constructor(length: number, smoothing: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type GatorOscillatorNode = GatorOscillator
export declare class GatorOscillator {
constructor(jawPeriod: number, teethPeriod: number, lipsPeriod: number)
update(high: number, low: number, close: number): GatorOscillatorValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type KasePermissionStochasticNode = KasePermissionStochastic
export declare class KasePermissionStochastic {
constructor(length: number, smooth: number)
update(high: number, low: number, close: number): KasePermissionStochasticValue | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -1677,6 +1873,42 @@ export declare class T3 {
isReady(): boolean
warmupPeriod(): number
}
export type GeneralizedDemaNode = GD
export declare class GD {
constructor(period: number, v: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HoltWintersNode = HoltWinters
export declare class HoltWinters {
constructor(alpha: number, beta: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RmiNode = RMI
export declare class RMI {
constructor(period: number, momentum: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DerivativeOscillatorNode = DerivativeOscillator
export declare class DerivativeOscillator {
constructor(rsiPeriod: number, smooth1: number, smooth2: number, signalPeriod: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TsiNode = TSI
export declare class TSI {
constructor(long: number, short: number)
+25 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.5.4",
"version": "0.5.7",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.5.4",
"version": "0.5.7",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.5.4",
"version": "0.5.7",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.5.4",
"version": "0.5.7",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.5.4",
"version": "0.5.7",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.5.4",
"version": "0.5.7",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.5.4",
"version": "0.5.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.4",
"version": "0.5.7",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.4",
"wickra-darwin-x64": "0.5.4",
"wickra-linux-arm64-gnu": "0.5.4",
"wickra-linux-x64-gnu": "0.5.4",
"wickra-win32-arm64-msvc": "0.5.4",
"wickra-win32-x64-msvc": "0.5.4"
"wickra-darwin-arm64": "0.5.7",
"wickra-darwin-x64": "0.5.7",
"wickra-linux-arm64-gnu": "0.5.7",
"wickra-linux-x64-gnu": "0.5.7",
"wickra-win32-arm64-msvc": "0.5.7",
"wickra-win32-x64-msvc": "0.5.7"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.4.tgz",
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.7.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.4.tgz",
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.7.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.4.tgz",
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.7.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.4.tgz",
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.7.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.4.tgz",
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.7.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.4.tgz",
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.7.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.5.4",
"version": "0.5.7",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.4",
"wickra-linux-arm64-gnu": "0.5.4",
"wickra-darwin-x64": "0.5.4",
"wickra-darwin-arm64": "0.5.4",
"wickra-win32-x64-msvc": "0.5.4",
"wickra-win32-arm64-msvc": "0.5.4"
"wickra-linux-x64-gnu": "0.5.7",
"wickra-linux-arm64-gnu": "0.5.7",
"wickra-darwin-x64": "0.5.7",
"wickra-darwin-arm64": "0.5.7",
"wickra-win32-x64-msvc": "0.5.7",
"wickra-win32-arm64-msvc": "0.5.7"
},
"scripts": {
"build": "napi build --platform --release",
+739
View File
@@ -197,6 +197,28 @@ node_scalar_indicator!(
node_scalar_indicator!(TrendLabelNode, "TrendLabel", wc::TrendLabel);
node_scalar_indicator!(WinRateNode, "WinRate", wc::WinRate);
node_scalar_indicator!(ExpectancyNode, "Expectancy", wc::Expectancy);
node_scalar_indicator!(SineWeightedMaNode, "SWMA", wc::SineWeightedMa);
node_scalar_indicator!(GeometricMaNode, "GMA", wc::GeometricMa);
node_scalar_indicator!(EhmaNode, "EHMA", wc::Ehma);
node_scalar_indicator!(MedianMaNode, "MedianMA", wc::MedianMa);
node_scalar_indicator!(
AdaptiveLaguerreFilterNode,
"AdaptiveLaguerre",
wc::AdaptiveLaguerreFilter
);
node_scalar_indicator!(DisparityIndexNode, "DisparityIndex", wc::DisparityIndex);
node_scalar_indicator!(FisherRsiNode, "FisherRSI", wc::FisherRsi);
node_scalar_indicator!(RsxNode, "RSX", wc::Rsx);
node_scalar_indicator!(
DynamicMomentumIndexNode,
"DynamicMomentumIndex",
wc::DynamicMomentumIndex
);
node_scalar_indicator!(
TrendStrengthIndexNode,
"TREND_STRENGTH_INDEX",
wc::TrendStrengthIndex
);
#[napi(js_name = "JumpIndicator")]
pub struct JumpIndicatorNode {
inner: wc::JumpIndicator,
@@ -2042,6 +2064,564 @@ impl HighLowRangeNode {
}
}
#[napi(js_name = "StochasticCCI")]
pub struct StochasticCciNode {
inner: wc::StochasticCci,
}
#[napi]
impl StochasticCciNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::StochasticCci::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "IMI")]
pub struct ImiNode {
inner: wc::IntradayMomentumIndex,
}
#[napi]
impl ImiNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::IntradayMomentumIndex::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd4(open, high, low, close)?))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"open, high, low, close must be equal length".to_string(),
));
}
let n = open.len();
let mut out = Vec::with_capacity(n);
for i in 0..n {
out.push(
self.inner
.update(cnd4(open[i], high[i], low[i], close[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct QqeValue {
pub rsi_ma: f64,
pub trailing_line: f64,
}
#[napi(js_name = "QQE")]
pub struct QqeNode {
inner: wc::Qqe,
}
#[napi]
impl QqeNode {
#[napi(constructor)]
pub fn new(rsi_period: u32, smoothing: u32, factor: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Qqe::new(rsi_period as usize, smoothing as usize, factor)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<QqeValue> {
self.inner.update(value).map(|o| QqeValue {
rsi_ma: o.rsi_ma,
trailing_line: o.trailing_line,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 2];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.rsi_ma;
out[i * 2 + 1] = o.trailing_line;
}
}
out
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct ElderRayValue {
pub bull_power: f64,
pub bear_power: f64,
}
#[napi(js_name = "ElderRay")]
pub struct ElderRayNode {
inner: wc::ElderRay,
}
#[napi]
impl ElderRayNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ElderRay::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<ElderRayValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| ElderRayValue {
bull_power: o.bull_power,
bear_power: o.bear_power,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.bull_power;
out[i * 2 + 1] = o.bear_power;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "TTM_TREND")]
pub struct TtmTrendNode {
inner: wc::TtmTrend,
}
#[napi]
impl TtmTrendNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TtmTrend::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "Qstick")]
pub struct QstickNode {
inner: wc::Qstick,
}
#[napi]
impl QstickNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Qstick::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, open: f64, close: f64) -> napi::Result<Option<f64>> {
let hi = open.max(close);
let lo = open.min(close);
Ok(self.inner.update(cnd4(open, hi, lo, close)?))
}
#[napi]
pub fn batch(&mut self, open: Vec<f64>, close: Vec<f64>) -> napi::Result<Vec<f64>> {
if open.len() != close.len() {
return Err(NapiError::from_reason(
"open, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(open.len());
for i in 0..open.len() {
let hi = open[i].max(close[i]);
let lo = open[i].min(close[i]);
out.push(
self.inner
.update(cnd4(open[i], hi, lo, close[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "POLARIZED_FRACTAL_EFFICIENCY")]
pub struct PolarizedFractalEfficiencyNode {
inner: wc::PolarizedFractalEfficiency,
}
#[napi]
impl PolarizedFractalEfficiencyNode {
#[napi(constructor)]
pub fn new(period: u32, smoothing: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PolarizedFractalEfficiency::new(period as usize, smoothing as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "WAVE_PM")]
pub struct WavePmNode {
inner: wc::WavePm,
}
#[napi]
impl WavePmNode {
#[napi(constructor)]
pub fn new(length: u32, smoothing: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::WavePm::new(length as usize, smoothing as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct GatorOscillatorValue {
pub upper: f64,
pub lower: f64,
}
#[napi(js_name = "GatorOscillator")]
pub struct GatorOscillatorNode {
inner: wc::GatorOscillator,
}
#[napi]
impl GatorOscillatorNode {
#[napi(constructor)]
pub fn new(jaw_period: u32, teeth_period: u32, lips_period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::GatorOscillator::new(
jaw_period as usize,
teeth_period as usize,
lips_period as usize,
)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<GatorOscillatorValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| GatorOscillatorValue {
upper: o.upper,
lower: o.lower,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.upper;
out[i * 2 + 1] = o.lower;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct KasePermissionStochasticValue {
pub fast: f64,
pub slow: f64,
}
#[napi(js_name = "KasePermissionStochastic")]
pub struct KasePermissionStochasticNode {
inner: wc::KasePermissionStochastic,
}
#[napi]
impl KasePermissionStochasticNode {
#[napi(constructor)]
pub fn new(length: u32, smooth: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::KasePermissionStochastic::new(length as usize, smooth as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<KasePermissionStochasticValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| KasePermissionStochasticValue {
fast: o.fast,
slow: o.slow,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
out[i * 2] = o.fast;
out[i * 2 + 1] = o.slow;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct StochValue {
pub k: f64,
@@ -3794,6 +4374,165 @@ impl T3Node {
}
}
// ============================== GD ==============================
#[napi(js_name = "GD")]
pub struct GeneralizedDemaNode {
inner: wc::GeneralizedDema,
}
#[napi]
impl GeneralizedDemaNode {
#[napi(constructor)]
pub fn new(period: u32, v: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::GeneralizedDema::new(period as usize, v).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== HoltWinters ==============================
#[napi(js_name = "HoltWinters")]
pub struct HoltWintersNode {
inner: wc::HoltWinters,
}
#[napi]
impl HoltWintersNode {
#[napi(constructor)]
pub fn new(alpha: f64, beta: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::HoltWinters::new(alpha, beta).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== RMI ==============================
#[napi(js_name = "RMI")]
pub struct RmiNode {
inner: wc::Rmi,
}
#[napi]
impl RmiNode {
#[napi(constructor)]
pub fn new(period: u32, momentum: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Rmi::new(period as usize, momentum as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== DerivativeOscillator ==============================
#[napi(js_name = "DerivativeOscillator")]
pub struct DerivativeOscillatorNode {
inner: wc::DerivativeOscillator,
}
#[napi]
impl DerivativeOscillatorNode {
#[napi(constructor)]
pub fn new(
rsi_period: u32,
smooth1: u32,
smooth2: u32,
signal_period: u32,
) -> napi::Result<Self> {
Ok(Self {
inner: wc::DerivativeOscillator::new(
rsi_period as usize,
smooth1 as usize,
smooth2 as usize,
signal_period as usize,
)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ============================== TSI ==============================
#[napi(js_name = "TSI")]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.5.4"
version = "0.5.7"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+48
View File
@@ -25,6 +25,30 @@ from __future__ import annotations
from ._wickra import (
__version__,
Qstick,
GatorOscillator,
KasePermissionStochastic,
WAVE_PM,
POLARIZED_FRACTAL_EFFICIENCY,
TREND_STRENGTH_INDEX,
TTM_TREND,
QQE,
IMI,
ElderRay,
DerivativeOscillator,
RMI,
StochasticCCI,
DynamicMomentumIndex,
RSX,
FisherRSI,
DisparityIndex,
HoltWinters,
GD,
AdaptiveLaguerre,
MedianMA,
EHMA,
GMA,
SWMA,
Expectancy,
WinRate,
RegimeLabel,
@@ -449,6 +473,30 @@ from ._wickra import (
)
__all__ = [
"Qstick",
"GatorOscillator",
"KasePermissionStochastic",
"WAVE_PM",
"POLARIZED_FRACTAL_EFFICIENCY",
"TREND_STRENGTH_INDEX",
"TTM_TREND",
"QQE",
"IMI",
"ElderRay",
"DerivativeOscillator",
"RMI",
"StochasticCCI",
"DynamicMomentumIndex",
"RSX",
"FisherRSI",
"DisparityIndex",
"HoltWinters",
"GD",
"AdaptiveLaguerre",
"MedianMA",
"EHMA",
"GMA",
"SWMA",
"Expectancy",
"WinRate",
"RegimeLabel",
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,22 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.WAVE_PM, (32, 3)),
(ta.POLARIZED_FRACTAL_EFFICIENCY, (10, 5)),
(ta.TREND_STRENGTH_INDEX, (20,)),
(ta.DerivativeOscillator, (14, 5, 3, 9)),
(ta.RMI, (14, 5)),
(ta.DynamicMomentumIndex, (14,)),
(ta.RSX, (14,)),
(ta.FisherRSI, (14,)),
(ta.DisparityIndex, (14,)),
(ta.HoltWinters, (0.2, 0.1)),
(ta.GD, (5, 0.7)),
(ta.AdaptiveLaguerre, (13,)),
(ta.MedianMA, (14,)),
(ta.EHMA, (9,)),
(ta.GMA, (14,)),
(ta.SWMA, (14,)),
(ta.Expectancy, (20,)),
(ta.WinRate, (20,)),
(ta.RegimeLabel, (5, 20)),
@@ -150,6 +166,7 @@ SCALAR = [
# Family 05 band/channel indicators with scalar input and multi-output.
# `cols` is the expected number of band columns from `batch`.
SCALAR_MULTI = {
"Qqe": (lambda: ta.QQE(14, 5, 4.236), 2),
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
@@ -341,6 +358,8 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
# Per-bar OHLC transforms (open matters). The streaming harness feeds
# open == close, so batch passes the close column in for open to match.
"HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
@@ -877,6 +896,21 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"KasePermissionStochastic": (
lambda: ta.KasePermissionStochastic(9, 3),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"GatorOscillator": (
lambda: ta.GatorOscillator(13, 8, 5),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ElderRay": (
lambda: ta.ElderRay(13),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"FibFan": (
lambda: ta.FibFan(),
lambda ind, h, l, c, v: ind.batch(h, l),
@@ -2734,6 +2768,100 @@ def test_spread_ar1_coefficient_reference():
out = ta.SpreadAr1Coefficient(20).batch(a, b)
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
def test_elder_ray_reference():
er = ta.ElderRay(3)
high = np.array([11.0, 13.0, 16.0])
low = np.array([9.0, 11.0, 13.0])
close = np.array([10.0, 12.0, 14.0])
out = er.batch(high, low, close)
# EMA(3) seeds at the third bar with mean close 12; bar high 16 -> bull 4,
# low 13 -> bear 1.
assert out[2][0] == pytest.approx(4.0)
assert out[2][1] == pytest.approx(1.0)
def test_imi_reference():
imi = ta.IMI(3)
open_ = np.array([10.0, 11.0, 10.0])
high = np.array([12.0, 12.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([11.0, 10.0, 12.0])
out = imi.batch(open_, high, low, close)
# bodies +1, -1, +2 -> gain 3, loss 1 -> 100 * 3 / 4 = 75.
assert math.isnan(out[0])
assert math.isnan(out[1])
assert out[2] == pytest.approx(75.0)
def test_qstick_reference():
q = ta.Qstick(3)
open_ = np.array([10.0, 10.0, 10.0])
close = np.array([11.0, 11.0, 11.0])
out = q.batch(open_, close)
# Each body is close - open = 1; SMA(3) of [1, 1, 1] = 1.
assert math.isnan(out[0])
assert math.isnan(out[1])
assert out[2] == pytest.approx(1.0)
def test_ttm_trend_reference():
t = ta.TTM_TREND(3)
high = np.array([13.0, 13.0, 13.0])
low = np.array([9.0, 9.0, 9.0])
close = np.array([12.0, 12.0, 12.0])
out = t.batch(high, low, close)
# Median (13 + 9) / 2 = 11; close 12 is above the SMA(3) reference -> +1.
assert math.isnan(out[0])
assert out[2] == pytest.approx(1.0)
def test_trend_strength_index_reference():
tsi = ta.TREND_STRENGTH_INDEX(10)
closes = np.arange(10, dtype=float)
out = tsi.batch(closes)
# A clean ramp is a perfect uptrend -> signed r^2 = +1.
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
def test_polarized_fractal_efficiency_reference():
pfe = ta.POLARIZED_FRACTAL_EFFICIENCY(5, 3)
closes = np.arange(20, dtype=float)
out = pfe.batch(closes)
# On a straight ramp the path equals the diagonal -> efficiency 1 -> +100.
assert math.isclose(out[-1], 100.0, abs_tol=1e-9)
def test_wave_pm_reference():
wpm = ta.WAVE_PM(10, 3)
closes = np.arange(60, dtype=float) * 5.0
out = wpm.batch(closes)
# Constant-slope ramp: momentum equals its energy -> 100 * (1 - e^-0.5).
baseline = 100.0 * (1.0 - math.exp(-0.5))
assert math.isclose(out[-1], baseline, abs_tol=1e-9)
def test_gator_oscillator_reference():
g = ta.GatorOscillator(13, 8, 5)
n = 40
high = np.full(n, 11.0)
low = np.full(n, 9.0)
close = np.full(n, 10.0)
out = g.batch(high, low, close)
# Constant median collapses all three Alligator lines -> both bars zero.
assert out[-1][0] == pytest.approx(0.0)
assert out[-1][1] == pytest.approx(0.0)
def test_kase_permission_stochastic_reference():
k = ta.KasePermissionStochastic(4, 2)
n = 20
flat = np.full(n, 10.0)
out = k.batch(flat, flat, flat)
# HH == LL -> raw %K defaults to the neutral 50 -> both lines at 50.
assert out[-1][0] == pytest.approx(50.0)
assert out[-1][1] == pytest.approx(50.0)
# --- Lifecycle ------------------------------------------------------------
+440
View File
@@ -80,6 +80,14 @@ wasm_scalar_indicator!(WasmTrima, "TRIMA", wc::Trima, period: usize);
wasm_scalar_indicator!(WasmZlema, "ZLEMA", wc::Zlema, period: usize);
wasm_scalar_indicator!(WasmT3, "T3", wc::T3, period: usize, v: f64);
wasm_scalar_indicator!(WasmAlma, "ALMA", wc::Alma, period: usize, offset: f64, sigma: f64);
wasm_scalar_indicator!(
WasmPolarizedFractalEfficiency,
"POLARIZED_FRACTAL_EFFICIENCY",
wc::PolarizedFractalEfficiency,
period: usize,
smoothing: usize
);
wasm_scalar_indicator!(WasmWavePm, "WAVE_PM", wc::WavePm, length: usize, smoothing: usize);
wasm_scalar_indicator!(WasmMcGinleyDynamic, "McGinleyDynamic", wc::McGinleyDynamic, period: usize);
wasm_scalar_indicator!(WasmFrama, "FRAMA", wc::Frama, period: usize);
wasm_scalar_indicator!(WasmVidya, "VIDYA", wc::Vidya, period: usize, cmo_period: usize);
@@ -2050,6 +2058,424 @@ impl WasmHighLowRange {
}
}
#[wasm_bindgen(js_name = StochasticCCI)]
pub struct WasmStochasticCci {
inner: wc::StochasticCci,
}
#[wasm_bindgen(js_class = StochasticCCI)]
impl WasmStochasticCci {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmStochasticCci, JsError> {
Ok(Self {
inner: wc::StochasticCci::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = IMI)]
pub struct WasmImi {
inner: wc::IntradayMomentumIndex,
}
#[wasm_bindgen(js_class = IMI)]
impl WasmImi {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmImi, JsError> {
Ok(Self {
inner: wc::IntradayMomentumIndex::new(period).map_err(map_err)?,
})
}
/// Batch over open/high/low/close arrays; `NaN` during warmup.
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = open.len();
if high.len() != n || low.len() != n || close.len() != n {
return Err(JsError::new("open, high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n];
for i in 0..n {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
if let Some(v) = self.inner.update(c) {
out[i] = v;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update over one candle's open/high/low/close.
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = QQE)]
pub struct WasmQqe {
inner: wc::Qqe,
}
#[wasm_bindgen(js_class = QQE)]
impl WasmQqe {
#[wasm_bindgen(constructor)]
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<WasmQqe, JsError> {
Ok(Self {
inner: wc::Qqe::new(rsi_period, smoothing, factor).map_err(map_err)?,
})
}
/// Returns `[rsiMa0, trailing0, rsiMa1, trailing1, ...]`, length `2 * n`.
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let mut out = vec![f64::NAN; prices.len() * 2];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.rsi_ma;
out[i * 2 + 1] = o.trailing_line;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ rsiMa, trailingLine }` once warm, else `null`.
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"rsiMa".into(), &o.rsi_ma.into()).ok();
Reflect::set(&obj, &"trailingLine".into(), &o.trailing_line.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = ElderRay)]
pub struct WasmElderRay {
inner: wc::ElderRay,
}
#[wasm_bindgen(js_class = ElderRay)]
impl WasmElderRay {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmElderRay, JsError> {
Ok(Self {
inner: wc::ElderRay::new(period).map_err(map_err)?,
})
}
/// Returns `[bull0, bear0, bull1, bear1, ...]`, length `2 * n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.bull_power;
out[i * 2 + 1] = o.bear_power;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ bullPower, bearPower }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"bullPower".into(), &o.bull_power.into()).ok();
Reflect::set(&obj, &"bearPower".into(), &o.bear_power.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = TTM_TREND)]
pub struct WasmTtmTrend {
inner: wc::TtmTrend,
}
#[wasm_bindgen(js_class = TTM_TREND)]
impl WasmTtmTrend {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmTtmTrend, JsError> {
Ok(Self {
inner: wc::TtmTrend::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = Qstick)]
pub struct WasmQstick {
inner: wc::Qstick,
}
#[wasm_bindgen(js_class = Qstick)]
impl WasmQstick {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmQstick, JsError> {
Ok(Self {
inner: wc::Qstick::new(period).map_err(map_err)?,
})
}
/// Batch over open/close arrays; `NaN` during warmup.
pub fn batch(&mut self, open: &[f64], close: &[f64]) -> Result<Float64Array, JsError> {
let n = open.len();
if close.len() != n {
return Err(JsError::new("open, close must be equal length"));
}
let mut out = vec![f64::NAN; n];
for i in 0..n {
let hi = open[i].max(close[i]);
let lo = open[i].min(close[i]);
let c = make_candle_ohlc(open[i], hi, lo, close[i])?;
if let Some(v) = self.inner.update(c) {
out[i] = v;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update over one candle's open and close.
pub fn update(&mut self, open: f64, close: f64) -> Result<Option<f64>, JsError> {
let hi = open.max(close);
let lo = open.min(close);
let c = make_candle_ohlc(open, hi, lo, close)?;
Ok(self.inner.update(c))
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = GatorOscillator)]
pub struct WasmGatorOscillator {
inner: wc::GatorOscillator,
}
#[wasm_bindgen(js_class = GatorOscillator)]
impl WasmGatorOscillator {
#[wasm_bindgen(constructor)]
pub fn new(
jaw_period: usize,
teeth_period: usize,
lips_period: usize,
) -> Result<WasmGatorOscillator, JsError> {
Ok(Self {
inner: wc::GatorOscillator::new(jaw_period, teeth_period, lips_period)
.map_err(map_err)?,
})
}
/// Returns `[upper0, lower0, upper1, lower1, ...]`, length `2 * n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.upper;
out[i * 2 + 1] = o.lower;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ upper, lower }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = KasePermissionStochastic)]
pub struct WasmKasePermissionStochastic {
inner: wc::KasePermissionStochastic,
}
#[wasm_bindgen(js_class = KasePermissionStochastic)]
impl WasmKasePermissionStochastic {
#[wasm_bindgen(constructor)]
pub fn new(length: usize, smooth: usize) -> Result<WasmKasePermissionStochastic, JsError> {
Ok(Self {
inner: wc::KasePermissionStochastic::new(length, smooth).map_err(map_err)?,
})
}
/// Returns `[fast0, slow0, fast1, slow1, ...]`, length `2 * n`.
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
let n = high.len();
if low.len() != n || close.len() != n {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.fast;
out[i * 2 + 1] = o.slow;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
/// Streaming update. Returns `{ fast, slow }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"fast".into(), &o.fast.into()).ok();
Reflect::set(&obj, &"slow".into(), &o.slow.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -10213,6 +10639,20 @@ wasm_scalar_indicator!(WasmJumpIndicator, "JumpIndicator", wc::JumpIndicator, pe
wasm_scalar_indicator!(WasmRegimeLabel, "RegimeLabel", wc::RegimeLabel, vol_period: usize, lookback: usize);
wasm_scalar_indicator!(WasmWinRate, "WinRate", wc::WinRate, period: usize);
wasm_scalar_indicator!(WasmExpectancy, "Expectancy", wc::Expectancy, period: usize);
wasm_scalar_indicator!(WasmSineWeightedMa, "SWMA", wc::SineWeightedMa, period: usize);
wasm_scalar_indicator!(WasmGeometricMa, "GMA", wc::GeometricMa, period: usize);
wasm_scalar_indicator!(WasmEhma, "EHMA", wc::Ehma, period: usize);
wasm_scalar_indicator!(WasmMedianMa, "MedianMA", wc::MedianMa, period: usize);
wasm_scalar_indicator!(WasmAdaptiveLaguerreFilter, "AdaptiveLaguerre", wc::AdaptiveLaguerreFilter, period: usize);
wasm_scalar_indicator!(WasmGeneralizedDema, "GD", wc::GeneralizedDema, period: usize, v: f64);
wasm_scalar_indicator!(WasmHoltWinters, "HoltWinters", wc::HoltWinters, alpha: f64, beta: f64);
wasm_scalar_indicator!(WasmDisparityIndex, "DisparityIndex", wc::DisparityIndex, period: usize);
wasm_scalar_indicator!(WasmFisherRsi, "FisherRSI", wc::FisherRsi, period: usize);
wasm_scalar_indicator!(WasmRsx, "RSX", wc::Rsx, period: usize);
wasm_scalar_indicator!(WasmDynamicMomentumIndex, "DynamicMomentumIndex", wc::DynamicMomentumIndex, period: usize);
wasm_scalar_indicator!(WasmRmi, "RMI", wc::Rmi, period: usize, momentum: usize);
wasm_scalar_indicator!(WasmDerivativeOscillator, "DerivativeOscillator", wc::DerivativeOscillator, rsi_period: usize, smooth1: usize, smooth2: usize, signal_period: usize);
wasm_scalar_indicator!(WasmTrendStrengthIndex, "TREND_STRENGTH_INDEX", wc::TrendStrengthIndex, period: usize);
// --- DrawdownDuration: u32 output, no constructor args ---
@@ -0,0 +1,344 @@
//! Ehlers' Adaptive Laguerre Filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// John Ehlers' Adaptive Laguerre Filter — a four-stage Laguerre polynomial
/// smoother whose damping factor `gamma` is recomputed every bar from how well
/// the filter is currently tracking price.
///
/// The Laguerre cascade is the same one used by [`LaguerreRsi`](crate::LaguerreRsi),
/// but instead of a fixed `gamma` the filter adapts: it measures the recent
/// absolute error `|price filter|`, normalises those errors across a window of
/// `period` bars to `[0, 1]`, and takes their **median** as `gamma`. When price
/// is tracking smoothly the errors are small and uniform (low `gamma`, fast
/// response); when price jumps, the spread of errors widens and `gamma` rises,
/// slowing the filter to reject the noise.
///
/// ```text
/// diff_t = |price_t filter_{t-1}|
/// over the last `period` diffs:
/// HH = max(diff), LL = min(diff)
/// norm_i = (diff_i LL) / (HH LL) (0 if HH == LL)
/// gamma = median(norm)
/// alpha = 1 gamma
/// L0_t = alpha·price_t + gamma·L0_{t-1}
/// L1_t = gamma·L0_t + L0_{t-1} + gamma·L1_{t-1}
/// L2_t = gamma·L1_t + L1_{t-1} + gamma·L2_{t-1}
/// L3_t = gamma·L2_t + L2_{t-1} + gamma·L3_{t-1}
/// filter_t = (L0_t + 2·L1_t + 2·L2_t + L3_t) / 6
/// ```
///
/// The output is a smoothed price on the same scale as the input. The first
/// emission lands once the error window holds `period` values.
///
/// Reference: John F. Ehlers, *"Adaptive Laguerre Filter"*, Technical Analysis
/// of Stocks & Commodities, 2007.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, AdaptiveLaguerreFilter};
///
/// let mut indicator = AdaptiveLaguerreFilter::new(13).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AdaptiveLaguerreFilter {
period: usize,
l0: f64,
l1: f64,
l2: f64,
l3: f64,
/// Previous filter output, or `None` before the first bar.
filter: Option<f64>,
/// The last `period` absolute errors `|price filter|`.
diffs: VecDeque<f64>,
}
impl AdaptiveLaguerreFilter {
/// Construct a new adaptive Laguerre filter with the given error-window
/// length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
l0: 0.0,
l1: 0.0,
l2: 0.0,
l3: 0.0,
filter: None,
diffs: VecDeque::with_capacity(period),
})
}
/// Configured error-window length.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the error window is full.
pub fn value(&self) -> Option<f64> {
if self.diffs.len() == self.period {
self.filter
} else {
None
}
}
/// Median of the normalised errors currently in the window. Returns `0.0`
/// when every error is equal (e.g. during a constant warmup), which makes
/// the filter maximally fast.
fn adaptive_gamma(&self) -> f64 {
let mut hh = f64::MIN;
let mut ll = f64::MAX;
for &d in &self.diffs {
if d > hh {
hh = d;
}
if d < ll {
ll = d;
}
}
let range = hh - ll;
if range <= 0.0 {
return 0.0;
}
let mut norm: Vec<f64> = self.diffs.iter().map(|&d| (d - ll) / range).collect();
// `total_cmp` never panics — under pathological (e.g. overflowing) fuzz
// inputs a normalised error can be non-finite; a total order keeps the
// sort sound where `partial_cmp` would return `None`.
norm.sort_by(f64::total_cmp);
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
}
}
impl Indicator for AdaptiveLaguerreFilter {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.value();
}
// Absolute tracking error against the previous filter (0 on the first
// bar, where there is no prior filter value).
let diff = self.filter.map_or(0.0, |f| (price - f).abs());
if self.diffs.len() == self.period {
self.diffs.pop_front();
}
self.diffs.push_back(diff);
let gamma = self.adaptive_gamma();
let alpha = 1.0 - gamma;
let l0 = alpha * price + gamma * self.l0;
let l1 = -gamma * l0 + self.l0 + gamma * self.l1;
let l2 = -gamma * l1 + self.l1 + gamma * self.l2;
let l3 = -gamma * l2 + self.l2 + gamma * self.l3;
self.l0 = l0;
self.l1 = l1;
self.l2 = l2;
self.l3 = l3;
let filter = (l0 + 2.0 * l1 + 2.0 * l2 + l3) / 6.0;
self.filter = Some(filter);
self.value()
}
fn reset(&mut self) {
self.l0 = 0.0;
self.l1 = 0.0;
self.l2 = 0.0;
self.l3 = 0.0;
self.filter = None;
self.diffs.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.diffs.len() == self.period
}
fn name(&self) -> &'static str {
"AdaptiveLaguerre"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference: replays the exact recurrence from scratch.
fn naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let (mut l0, mut l1, mut l2, mut l3) = (0.0_f64, 0.0_f64, 0.0_f64, 0.0_f64);
let mut filter: Option<f64> = None;
let mut diffs: Vec<f64> = Vec::new();
let mut out = Vec::with_capacity(prices.len());
for &price in prices {
let diff = filter.map_or(0.0, |f: f64| (price - f).abs());
diffs.push(diff);
if diffs.len() > period {
diffs.remove(0);
}
let hh = diffs.iter().copied().fold(f64::MIN, f64::max);
let ll = diffs.iter().copied().fold(f64::MAX, f64::min);
let range = hh - ll;
let gamma = if range <= 0.0 {
0.0
} else {
let mut norm: Vec<f64> = diffs.iter().map(|&d| (d - ll) / range).collect();
norm.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = norm.len() / 2;
if norm.len() % 2 == 1 {
norm[mid]
} else {
f64::midpoint(norm[mid - 1], norm[mid])
}
};
let alpha = 1.0 - gamma;
let n0 = alpha * price + gamma * l0;
let n1 = -gamma * n0 + l0 + gamma * l1;
let n2 = -gamma * n1 + l1 + gamma * l2;
let n3 = -gamma * n2 + l2 + gamma * l3;
l0 = n0;
l1 = n1;
l2 = n2;
l3 = n3;
let f = (n0 + 2.0 * n1 + 2.0 * n2 + n3) / 6.0;
filter = Some(f);
out.push(if diffs.len() == period { Some(f) } else { None });
}
out
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(
AdaptiveLaguerreFilter::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let alf = AdaptiveLaguerreFilter::new(13).unwrap();
assert_eq!(alf.period(), 13);
assert_eq!(alf.warmup_period(), 13);
assert_eq!(alf.name(), "AdaptiveLaguerre");
}
#[test]
fn warmup_returns_none_until_window_full() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
assert_eq!(alf.update(10.0), None);
assert_eq!(alf.update(11.0), None);
assert!(alf.update(12.0).is_some());
}
#[test]
fn constant_series_converges_to_constant() {
// Errors are all zero -> gamma 0 -> the 4-stage delay line fills with
// the constant and the filter settles on it.
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
let out = alf.batch(&[42.0_f64; 40]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 42.0, epsilon = 1e-9);
}
#[test]
fn converged_output_stays_within_price_range() {
// Once the Laguerre cascade has filled (it cold-starts from zero, so the
// first few post-warmup values ramp up toward price), the filter is a
// convex blend of recent prices and must stay inside the data range.
let prices: Vec<f64> = (0..120)
.map(|i| 50.0 + (f64::from(i) * 0.4).sin() * 10.0)
.collect();
let lo = prices.iter().copied().fold(f64::MAX, f64::min);
let hi = prices.iter().copied().fold(f64::MIN, f64::max);
let period = 8;
let mut alf = AdaptiveLaguerreFilter::new(period).unwrap();
for (i, v) in alf.batch(&prices).into_iter().enumerate() {
// Skip the cold-start transient (a few multiples of the window).
if i < 4 * period {
continue;
}
let v = v.expect("filter is ready well past warmup");
assert!(
v >= lo - 1e-6 && v <= hi + 1e-6,
"filter out of range at {i}"
);
}
}
#[test]
fn matches_naive_recurrence() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.5).sin() * 8.0 + f64::from(i) * 0.1)
.collect();
let mut alf = AdaptiveLaguerreFilter::new(10).unwrap();
let got = alf.batch(&prices);
let want = naive(&prices, 10);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut alf = AdaptiveLaguerreFilter::new(5).unwrap();
alf.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(alf.is_ready());
alf.reset();
assert!(!alf.is_ready());
assert_eq!(alf.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=50).map(|i| f64::from(i) * 0.7).collect();
let mut a = AdaptiveLaguerreFilter::new(7).unwrap();
let mut b = AdaptiveLaguerreFilter::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut alf = AdaptiveLaguerreFilter::new(3).unwrap();
alf.update(10.0);
alf.update(11.0);
let ready = alf.update(12.0).expect("ready after three inputs");
assert_eq!(alf.update(f64::NAN), Some(ready));
assert_eq!(alf.update(f64::INFINITY), Some(ready));
}
}
@@ -0,0 +1,218 @@
//! Derivative Oscillator (Constance Brown).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::rsi::Rsi;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
///
/// The RSI is smoothed twice with EMAs, then a simple moving average of that
/// double-smoothed line is subtracted as a signal, leaving a zero-centered
/// histogram:
///
/// ```text
/// rsi = RSI(price, rsi_period)
/// s1 = EMA(rsi, smooth1)
/// s2 = EMA(s1, smooth2) // double-smoothed RSI
/// signal = SMA(s2, signal_period)
/// DerivativeOscillator = s2 - signal
/// ```
///
/// The double EMA smoothing strips the RSI's high-frequency noise, and
/// subtracting the SMA signal removes the residual level, so the result
/// oscillates around zero: positive (and rising) bars mark accelerating bullish
/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
///
/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period 2`
/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
///
/// # Example
///
/// ```
/// use wickra_core::{DerivativeOscillator, Indicator};
///
/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DerivativeOscillator {
rsi: Rsi,
ema1: Ema,
ema2: Ema,
signal: Sma,
warmup: usize,
}
impl DerivativeOscillator {
/// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
/// SMA signal periods.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if any period is `0`.
pub fn new(
rsi_period: usize,
smooth1: usize,
smooth2: usize,
signal_period: usize,
) -> Result<Self> {
if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
rsi: Rsi::new(rsi_period)?,
ema1: Ema::new(smooth1)?,
ema2: Ema::new(smooth2)?,
signal: Sma::new(signal_period)?,
// RSI seeds at rsi_period + 1, then each stage adds (len - 1).
warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
})
}
/// Total warmup length (also returned by `warmup_period`).
pub const fn warmup(&self) -> usize {
self.warmup
}
}
impl Indicator for DerivativeOscillator {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let s1 = self.ema1.update(rsi)?;
let s2 = self.ema2.update(s1)?;
let signal = self.signal.update(s2)?;
Some(s2 - signal)
}
fn reset(&mut self) {
self.rsi.reset();
self.ema1.reset();
self.ema2.reset();
self.signal.reset();
}
fn warmup_period(&self) -> usize {
self.warmup
}
fn is_ready(&self) -> bool {
self.signal.is_ready()
}
fn name(&self) -> &'static str {
"DerivativeOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_periods() {
assert!(matches!(
DerivativeOscillator::new(0, 5, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 0, 3, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 0, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
DerivativeOscillator::new(14, 5, 3, 0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
// 14 + 5 + 3 + 9 - 2 = 29.
assert_eq!(d.warmup(), 29);
assert_eq!(d.warmup_period(), 29);
assert_eq!(d.name(), "DerivativeOscillator");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let out = d.batch(&prices);
let warmup = d.warmup_period();
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first value must land at warmup_period - 1"
);
}
#[test]
fn matches_manual_chain() {
// Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut rsi = Rsi::new(14).unwrap();
let mut e1 = Ema::new(5).unwrap();
let mut e2 = Ema::new(3).unwrap();
let mut sig = Sma::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = d.update(p);
let want = rsi
.update(p)
.and_then(|r| e1.update(r))
.and_then(|x| e2.update(x))
.and_then(|s2| sig.update(s2).map(|s| s2 - s));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(d.is_ready());
d.reset();
assert!(!d.is_ready());
assert_eq!(d.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,169 @@
//! Disparity Index.
use crate::error::Result;
use crate::indicators::sma::Sma;
use crate::traits::Indicator;
/// Disparity Index — the percentage gap between price and its moving average.
///
/// ```text
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
/// ```
///
/// Originating in Japanese technical analysis (*kairi*), the disparity index
/// expresses how far price has stretched from its `period`-bar simple moving
/// average, as a percentage of that average. Positive readings mean price is
/// above the mean (potentially overbought / strong), negative readings mean it
/// is below (potentially oversold / weak); the magnitude measures how
/// over-extended the move is.
///
/// The first output lands once the inner SMA is ready (input `period`). If the
/// moving average is exactly zero the gap percentage is undefined and the index
/// returns `0.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{DisparityIndex, Indicator};
///
/// let mut indicator = DisparityIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DisparityIndex {
period: usize,
sma: Sma,
}
impl DisparityIndex {
/// Construct a disparity index over `period` inputs.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
sma: Sma::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for DisparityIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let mean = self.sma.update(input)?;
if mean == 0.0 {
return Some(0.0);
}
Some(100.0 * (input - mean) / mean)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"DisparityIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(DisparityIndex::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let di = DisparityIndex::new(14).unwrap();
assert_eq!(di.period(), 14);
assert_eq!(di.warmup_period(), 14);
assert_eq!(di.name(), "DisparityIndex");
}
#[test]
fn warmup_then_known_value() {
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
let mut di = DisparityIndex::new(3).unwrap();
assert_eq!(di.update(2.0), None);
assert_eq!(di.update(4.0), None);
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn constant_series_is_zero() {
// Price equals its own mean -> zero disparity.
let mut di = DisparityIndex::new(5).unwrap();
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn negative_when_below_mean() {
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
let mut di = DisparityIndex::new(3).unwrap();
let v = di.batch(&[10.0, 8.0, 6.0]);
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
}
#[test]
fn zero_mean_returns_zero() {
// A window summing to zero (mean 0) makes the percentage undefined; the
// index returns 0.0 rather than a non-finite value.
let mut di = DisparityIndex::new(2).unwrap();
assert_eq!(di.update(-3.0), None);
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut di = DisparityIndex::new(5).unwrap();
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30)
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
.collect();
let mut a = DisparityIndex::new(7).unwrap();
let mut b = DisparityIndex::new(7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,301 @@
//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::sma::Sma;
use crate::indicators::std_dev::StdDev;
use crate::traits::Indicator;
// Chande's definitional constants.
const STD_PERIOD: usize = 5; // volatility window
const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
const MIN_PERIOD: usize = 5; // fastest RSI lookback
const MAX_PERIOD: usize = 30; // slowest RSI lookback
/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
/// volatile markets and lengthens in calm ones.
///
/// A standard RSI uses a fixed period; the DMI varies it from the recent
/// volatility so the oscillator stays responsive when the market is fast and
/// smooth when it is quiet:
///
/// ```text
/// vol = StdDev(close, 5)
/// vol_avg = SMA(vol, 10)
/// Vi = vol / vol_avg (volatility index)
/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback)
/// avg_gain, avg_loss = simple means of the last `td` price changes
/// DMI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
/// lengthens it toward `30` (slower). The averages of gains and losses are
/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
/// returns the neutral `50`.
///
/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change
/// buffer always holds enough history for any dynamic lookback up to `30`.
///
/// # Example
///
/// ```
/// use wickra_core::{DynamicMomentumIndex, Indicator};
///
/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct DynamicMomentumIndex {
period: usize,
vol: StdDev,
vol_avg: Sma,
prev_close: Option<f64>,
/// The last `MAX_PERIOD` price changes, oldest at the front.
changes: VecDeque<f64>,
last_vol_avg: Option<f64>,
last_value: Option<f64>,
}
impl DynamicMomentumIndex {
/// Construct a DMI with the given base RSI period (Chande uses 14).
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
vol: StdDev::new(STD_PERIOD)?,
vol_avg: Sma::new(STD_AVG_PERIOD)?,
prev_close: None,
changes: VecDeque::with_capacity(MAX_PERIOD),
last_vol_avg: None,
last_value: None,
})
}
/// Configured base period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
if vol_avg <= 0.0 || vol <= 0.0 {
// No measurable volatility -> slowest (calmest) lookback.
return MAX_PERIOD;
}
let vi = vol / vol_avg;
let td = (self.period as f64 / vi).round();
// td is finite and positive here; clamp into the valid band.
(td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
}
}
impl Indicator for DynamicMomentumIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
// Track the smoothed volatility on every close.
if let Some(v) = self.vol.update(input) {
self.last_vol_avg = self.vol_avg.update(v);
}
// Record the price change.
if let Some(prev) = self.prev_close {
let change = input - prev;
if self.changes.len() == MAX_PERIOD {
self.changes.pop_front();
}
self.changes.push_back(change);
}
self.prev_close = Some(input);
let vol = self.vol.value()?;
let vol_avg = self.last_vol_avg?;
if self.changes.len() < MAX_PERIOD {
return None;
}
let td = self.dynamic_period(vol, vol_avg);
// Average gains and losses over the last `td` changes.
let mut sum_gain = 0.0;
let mut sum_loss = 0.0;
for &c in self.changes.iter().skip(MAX_PERIOD - td) {
if c > 0.0 {
sum_gain += c;
} else if c < 0.0 {
sum_loss -= c;
}
}
let denom = sum_gain + sum_loss;
let v = if denom == 0.0 {
50.0
} else {
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
100.0 * (sum_gain / denom)
};
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.vol.reset();
self.vol_avg.reset();
self.prev_close = None;
self.changes.clear();
self.last_vol_avg = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
// binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
MAX_PERIOD + 1
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"DynamicMomentumIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
DynamicMomentumIndex::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessors `period` + `value` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(dmi.period(), 14);
assert_eq!(dmi.value(), None);
assert_eq!(dmi.warmup_period(), 31);
assert_eq!(dmi.name(), "DynamicMomentumIndex");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (0..50)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let out = dmi.batch(&prices);
for (i, v) in out.iter().enumerate().take(30) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
}
#[test]
fn pure_uptrend_is_one_hundred() {
// Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
// Constant prices: no volatility (dynamic period -> max) and no changes
// -> neutral 50.
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_stays_in_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
.collect();
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
for v in dmi.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
}
}
#[test]
fn high_volatility_shortens_period() {
let dmi = DynamicMomentumIndex::new(14).unwrap();
// Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
// Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
// Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
// Zero volatility -> slowest lookback.
assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
}
#[test]
fn ignores_non_finite_input() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
let ready = dmi
.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(dmi.update(f64::NAN), Some(ready));
assert_eq!(dmi.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
assert!(dmi.is_ready());
dmi.reset();
assert!(!dmi.is_ready());
assert_eq!(dmi.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..80)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = DynamicMomentumIndex::new(14).unwrap();
let mut b = DynamicMomentumIndex::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Exponential Hull Moving Average (EHMA).
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Exponential Hull Moving Average: the Hull construction built from EMAs
/// instead of WMAs.
///
/// ```text
/// EHMA = EMA( 2 · EMA(price, period/2) EMA(price, period), round(sqrt(period)) )
/// ```
///
/// Alan Hull's [`Hma`](crate::Hma) uses weighted moving averages; replacing them
/// with exponential moving averages keeps the same lag-reduction trick — a fast
/// half-length average minus a full-length one, smoothed over `sqrt(period)` —
/// while inheriting the EMA's strictly recursive O(1) update and infinite
/// (exponentially decaying) memory. The result is marginally smoother than the
/// WMA-based Hull at the cost of a little more lag.
///
/// The half period is `(period / 2).max(1)` and the smoothing period is
/// `round(sqrt(period)).max(1)`, matching the rounding used by [`Hma`].
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Ehma};
///
/// let mut indicator = Ehma::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Ehma {
period: usize,
half_ema: Ema,
full_ema: Ema,
smooth_ema: Ema,
}
impl Ehma {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let half = (period / 2).max(1);
let smooth = (period as f64).sqrt().round() as usize;
let smooth = smooth.max(1);
Ok(Self {
period,
half_ema: Ema::new(half)?,
full_ema: Ema::new(period)?,
smooth_ema: Ema::new(smooth)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Ehma {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
// Feed both component EMAs on every input so they warm up in parallel;
// gating the longer one behind the shorter would delay the first
// emission past `warmup_period()`.
let h = self.half_ema.update(input);
let f = self.full_ema.update(input);
let (h, f) = (h?, f?);
let diff = 2.0 * h - f;
self.smooth_ema.update(diff)
}
fn reset(&mut self) {
self.half_ema.reset();
self.full_ema.reset();
self.smooth_ema.reset();
}
fn warmup_period(&self) -> usize {
// full_ema seeds at `period`, then smooth_ema needs another
// (round(sqrt(period)) - 1) values to seed.
let sm = (self.period as f64).sqrt().round() as usize;
self.period + sm.max(1) - 1
}
fn is_ready(&self) -> bool {
self.smooth_ema.is_ready()
}
fn name(&self) -> &'static str {
"EHMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn constant_series_yields_constant_ehma() {
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&[10.0_f64; 80]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 10.0, epsilon = 1e-9);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=100).map(|i| f64::from(i) * 0.7).collect();
let mut a = Ehma::new(9).unwrap();
let mut b = Ehma::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut ehma = Ehma::new(9).unwrap();
ehma.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
assert!(ehma.is_ready());
ehma.reset();
assert!(!ehma.is_ready());
}
#[test]
fn rejects_zero_period() {
assert!(Ehma::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `name`.
/// `warmup_period` is covered by `first_emission_matches_warmup_period`.
#[test]
fn accessors_and_metadata() {
let ehma = Ehma::new(9).unwrap();
assert_eq!(ehma.period(), 9);
assert_eq!(ehma.name(), "EHMA");
}
#[test]
fn first_emission_matches_warmup_period() {
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut ehma = Ehma::new(9).unwrap();
let out = ehma.batch(&prices);
let warmup = ehma.warmup_period();
// full EMA seeds at 9, smooth EMA round(sqrt(9))=3 needs 2 more -> 11.
assert_eq!(warmup, 11);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first EHMA value must land at warmup_period - 1"
);
}
#[test]
fn matches_independent_emas() {
// The two component EMAs run as independent siblings on the price
// stream; EHMA must equal feeding three standalone EMAs and combining.
let prices: Vec<f64> = (1..=50)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut ehma = Ehma::new(9).unwrap();
let mut half = Ema::new(4).unwrap(); // (9 / 2).max(1)
let mut full = Ema::new(9).unwrap();
let mut smooth = Ema::new(3).unwrap(); // round(sqrt(9))
for (i, &p) in prices.iter().enumerate() {
let got = ehma.update(p);
let want = match (half.update(p), full.update(p)) {
(Some(h), Some(f)) => smooth.update(2.0 * h - f),
_ => None,
};
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn period_one_collapses_to_pass_through() {
// period 1: half=1, full=1, smooth=round(sqrt(1))=1; every EMA seeds on
// the first input, so EHMA(1) passes the price straight through.
let mut ehma = Ehma::new(1).unwrap();
assert_relative_eq!(ehma.update(5.0).unwrap(), 5.0, epsilon = 1e-12);
assert_relative_eq!(ehma.update(8.0).unwrap(), 8.0, epsilon = 1e-12);
}
}
@@ -0,0 +1,192 @@
//! Elder Ray — Bull Power and Bear Power.
use crate::error::Result;
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// One Elder Ray reading: the bull and bear power for a bar.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElderRayOutput {
/// `high EMA(close)`: how far buyers pushed price above the trend mean.
pub bull_power: f64,
/// `low EMA(close)`: how far sellers pushed price below the trend mean
/// (negative in a normal market).
pub bear_power: f64,
}
/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator.
///
/// An EMA of the close marks the market's consensus of value; the bar's high and
/// low relative to it measure how far the bulls and bears could push price away
/// from that consensus:
///
/// ```text
/// ema = EMA(close, period)
/// BullPower = high - ema
/// BearPower = low - ema
/// ```
///
/// Bull Power is normally positive (the high prints above the mean) and Bear
/// Power normally negative (the low prints below it). Their behaviour relative
/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend
/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup.
///
/// The first reading lands once the inner EMA is seeded, at bar `period`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, ElderRay, Indicator};
///
/// let mut er = ElderRay::new(13).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)).unwrap();
/// last = er.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderRay {
period: usize,
ema: Ema,
}
impl ElderRay {
/// Construct an Elder Ray with the given EMA period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
ema: Ema::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for ElderRay {
type Input = Candle;
type Output = ElderRayOutput;
fn update(&mut self, candle: Candle) -> Option<ElderRayOutput> {
let ema = self.ema.update(candle.close)?;
Some(ElderRayOutput {
bull_power: candle.high - ema,
bear_power: candle.low - ema,
})
}
fn reset(&mut self) {
self.ema.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"ElderRay"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new(close, high, low, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(ElderRay::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let er = ElderRay::new(13).unwrap();
assert_eq!(er.period(), 13);
assert_eq!(er.warmup_period(), 13);
assert_eq!(er.name(), "ElderRay");
}
#[test]
fn warmup_then_known_value() {
// EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes).
// bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1.
let mut er = ElderRay::new(3).unwrap();
assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None);
assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None);
let v = er.update(candle(16.0, 13.0, 14.0)).unwrap();
assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12);
assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12);
}
#[test]
fn matches_manual_ema() {
let bars: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
candle(base + 2.0, base - 2.0, base)
})
.collect();
let mut er = ElderRay::new(13).unwrap();
let mut ema = Ema::new(13).unwrap();
for (i, c) in bars.iter().enumerate() {
let got = er.update(*c);
let want = ema.update(c.close).map(|e| (c.high - e, c.low - e));
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(g), Some((b, be))) = (got, want) {
assert_relative_eq!(g.bull_power, b, epsilon = 1e-9);
assert_relative_eq!(g.bear_power, be, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut er = ElderRay::new(5).unwrap();
er.batch(
&(0..20)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect::<Vec<_>>(),
);
assert!(er.is_ready());
er.reset();
assert!(!er.is_ready());
assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..30)
.map(|i| {
let base = 50.0 + f64::from(i);
candle(base + 1.5, base - 1.5, base)
})
.collect();
let mut a = ElderRay::new(7).unwrap();
let mut b = ElderRay::new(7).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,186 @@
//! Fisher-transformed RSI.
use crate::error::Result;
use crate::indicators::rsi::Rsi;
use crate::traits::Indicator;
/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
///
/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
/// middle, which blurs turning points. The Fisher transform reshapes a bounded
/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
/// peaks:
///
/// ```text
/// rsi = RSI(price, period) in [0, 100]
/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1)
/// Fisher = 0.5 * ln((1 + x) / (1 - x))
/// ```
///
/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
/// sharp excursions marking momentum extremes. The first value lands with the
/// inner RSI, after `period + 1` inputs.
///
/// # Example
///
/// ```
/// use wickra_core::{FisherRsi, Indicator};
///
/// let mut indicator = FisherRsi::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct FisherRsi {
period: usize,
rsi: Rsi,
}
impl FisherRsi {
/// Construct a Fisher RSI with the given RSI period.
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
rsi: Rsi::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for FisherRsi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let rsi = self.rsi.update(input)?;
let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
}
fn reset(&mut self) {
self.rsi.reset();
}
fn warmup_period(&self) -> usize {
self.rsi.warmup_period()
}
fn is_ready(&self) -> bool {
self.rsi.is_ready()
}
fn name(&self) -> &'static str {
"FisherRSI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(FisherRsi::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let f = FisherRsi::new(9).unwrap();
assert_eq!(f.period(), 9);
// RSI warmup is period + 1.
assert_eq!(f.warmup_period(), 10);
assert_eq!(f.name(), "FisherRSI");
}
#[test]
fn warmup_matches_rsi() {
let mut f = FisherRsi::new(3).unwrap();
// RSI(3) needs 4 inputs; the first three return None.
assert_eq!(f.update(1.0), None);
assert_eq!(f.update(2.0), None);
assert_eq!(f.update(3.0), None);
assert!(f.update(4.0).is_some());
}
#[test]
fn matches_fisher_of_rsi() {
// Fisher RSI must equal the Fisher transform of the standalone RSI.
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut fr = FisherRsi::new(9).unwrap();
let mut rsi = Rsi::new(9).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = fr.update(p);
let want = rsi.update(p).map(|r| {
let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
0.5 * ((1.0 + x) / (1.0 - x)).ln()
});
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-12);
}
}
}
#[test]
fn strong_uptrend_is_positive() {
// A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut f = FisherRsi::new(9).unwrap();
let last = f.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 1.0,
"strong uptrend should give a large positive value, got {last}"
);
}
#[test]
fn clamp_keeps_output_finite_at_extremes() {
// Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
let mut f = FisherRsi::new(5).unwrap();
for v in f.batch(&prices).into_iter().flatten() {
assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
}
}
#[test]
fn reset_clears_state() {
let mut f = FisherRsi::new(5).unwrap();
f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(f.is_ready());
f.reset();
assert!(!f.is_ready());
assert_eq!(f.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = FisherRsi::new(9).unwrap();
let mut b = FisherRsi::new(9).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,205 @@
//! Bill Williams' Gator Oscillator (derived from the Alligator).
use crate::error::Result;
use crate::indicators::alligator::Alligator;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Gator Oscillator output: the two histogram bars drawn above and below the
/// zero line.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GatorOscillatorOutput {
/// Upper histogram `|jaw - teeth|`, always `>= 0`.
pub upper: f64,
/// Lower histogram `-|teeth - lips|`, always `<= 0`.
pub lower: f64,
}
/// Bill Williams' Gator Oscillator: a convergence/divergence view of the
/// [`Alligator`] lines. The upper bar is the absolute gap between Jaw and
/// Teeth; the lower bar is the negated absolute gap between Teeth and Lips.
///
/// ```text
/// upper = |jaw - teeth|
/// lower = -|teeth - lips |
/// ```
///
/// Widening bars mean the Alligator's mouth is opening (a trending market);
/// shrinking bars mean it is closing (consolidation). Warmup matches the
/// underlying Alligator — the first value appears once the slowest line (Jaw)
/// has warmed up.
///
/// Reference: Bill Williams, *Trading Chaos*, 1995.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, GatorOscillator, Indicator};
///
/// let mut indicator = GatorOscillator::classic();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GatorOscillator {
alligator: Alligator,
}
impl GatorOscillator {
/// Construct a Gator Oscillator from explicit Alligator periods
/// `(jaw, teeth, lips)`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if any period is zero.
pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result<Self> {
Ok(Self {
alligator: Alligator::new(jaw_period, teeth_period, lips_period)?,
})
}
/// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`.
pub fn classic() -> Self {
Self {
alligator: Alligator::classic(),
}
}
/// Configured `(jaw_period, teeth_period, lips_period)`.
pub const fn periods(&self) -> (usize, usize, usize) {
self.alligator.periods()
}
}
impl Indicator for GatorOscillator {
type Input = Candle;
type Output = GatorOscillatorOutput;
fn update(&mut self, candle: Candle) -> Option<GatorOscillatorOutput> {
let lines = self.alligator.update(candle)?;
Some(GatorOscillatorOutput {
upper: (lines.jaw - lines.teeth).abs(),
lower: -(lines.teeth - lines.lips).abs(),
})
}
fn reset(&mut self) {
self.alligator.reset();
}
fn warmup_period(&self) -> usize {
self.alligator.warmup_period()
}
fn is_ready(&self) -> bool {
self.alligator.is_ready()
}
fn name(&self) -> &'static str {
"GatorOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, ts: i64) -> Candle {
let close = f64::midpoint(high, low);
Candle::new(close, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
GatorOscillator::new(0, 8, 5),
Err(Error::PeriodZero)
));
assert!(matches!(
GatorOscillator::new(13, 0, 5),
Err(Error::PeriodZero)
));
assert!(matches!(
GatorOscillator::new(13, 8, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let g = GatorOscillator::classic();
assert_eq!(g.periods(), (13, 8, 5));
assert_eq!(g.warmup_period(), 13);
assert_eq!(g.name(), "GatorOscillator");
assert!(!g.is_ready());
}
#[test]
fn constant_series_collapses_both_bars() {
// All three Alligator lines equal the constant median -> zero spread.
let mut g = GatorOscillator::classic();
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
let out = g.batch(&candles);
let last = out.last().unwrap().unwrap();
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
}
#[test]
fn trending_series_opens_the_mouth() {
// On a clean trend the lines separate -> upper > 0, lower < 0.
let mut g = GatorOscillator::classic();
let candles: Vec<Candle> = (0_i64..80)
.map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i))
.collect();
let last = g.batch(&candles).last().unwrap().unwrap();
assert!(last.upper > 0.0, "upper {} should be positive", last.upper);
assert!(last.lower < 0.0, "lower {} should be negative", last.lower);
}
#[test]
fn warmup_emits_first_value_at_longest_period() {
let mut g = GatorOscillator::new(5, 3, 2).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, i)).collect();
let out = g.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn reset_clears_state() {
let mut g = GatorOscillator::classic();
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
g.batch(&candles);
assert!(g.is_ready());
g.reset();
assert!(!g.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
candle(base + 1.0, base - 1.0, i)
})
.collect();
let mut a = GatorOscillator::classic();
let mut b = GatorOscillator::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,222 @@
//! Generalized DEMA (GD) — Tim Tillson's volume-factor double EMA.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Generalized DEMA — the building block of Tillson's [`T3`](crate::T3),
/// exposed on its own.
///
/// ```text
/// GD = (1 + v) · EMA(price) v · EMA(EMA(price))
/// ```
///
/// where both EMAs share the same `period` and `v ∈ [0, 1]` is the *volume
/// factor*. `v` controls how much of the second-order lag correction is
/// applied:
///
/// - `v = 0` collapses GD to a plain [`Ema`](crate::Ema) (no correction).
/// - `v = 1` recovers the standard [`Dema`](crate::Dema) `2·EMA EMA(EMA)`.
/// - intermediate values (Tillson uses `0.7`) trade a little lag reduction for
/// less overshoot than DEMA.
///
/// Because the coefficients `(1 + v)` and `v` always sum to `1`, a constant
/// series maps to itself. The first output lands after `2·period 1` inputs —
/// EMA1 seeds at `period`, then EMA2 needs another `period 1` of EMA1's
/// outputs to seed, exactly like DEMA.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, GeneralizedDema};
///
/// let mut indicator = GeneralizedDema::new(5, 0.7).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GeneralizedDema {
ema1: Ema,
ema2: Ema,
period: usize,
v: f64,
}
impl GeneralizedDema {
/// Construct a generalized DEMA with the given `period` and volume factor
/// `v`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidPeriod`] if `v` is non-finite or outside `[0.0, 1.0]`.
pub fn new(period: usize, v: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !v.is_finite() || !(0.0..=1.0).contains(&v) {
return Err(Error::InvalidPeriod {
message: "GD volume factor must be a finite value in [0.0, 1.0]",
});
}
Ok(Self {
ema1: Ema::new(period)?,
ema2: Ema::new(period)?,
period,
v,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured volume factor `v`.
pub const fn volume_factor(&self) -> f64 {
self.v
}
}
impl Indicator for GeneralizedDema {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
let e1 = self.ema1.update(input)?;
let e2 = self.ema2.update(e1)?;
Some((1.0 + self.v) * e1 - self.v * e2)
}
fn reset(&mut self) {
self.ema1.reset();
self.ema2.reset();
}
fn warmup_period(&self) -> usize {
// EMA1 seeds at period, then EMA2 needs another (period - 1) values.
2 * self.period - 1
}
fn is_ready(&self) -> bool {
self.ema2.is_ready()
}
fn name(&self) -> &'static str {
"GD"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::Dema;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
GeneralizedDema::new(0, 0.7),
Err(Error::PeriodZero)
));
}
#[test]
fn rejects_invalid_volume_factor() {
assert!(matches!(
GeneralizedDema::new(5, -0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
GeneralizedDema::new(5, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
assert!(GeneralizedDema::new(5, 0.0).is_ok());
assert!(GeneralizedDema::new(5, 1.0).is_ok());
}
/// Cover the const accessors `period` + `volume_factor` and the
/// Indicator-impl `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let gd = GeneralizedDema::new(5, 0.7).unwrap();
assert_eq!(gd.period(), 5);
assert_relative_eq!(gd.volume_factor(), 0.7, epsilon = 1e-12);
// EMA1 seeds at 5, EMA2 needs another 4 -> 2*period - 1 = 9.
assert_eq!(gd.warmup_period(), 9);
assert_eq!(gd.name(), "GD");
}
#[test]
fn constant_series_yields_constant() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
let out = gd.batch(&[100.0_f64; 60]);
let last = out.iter().rev().flatten().next().unwrap();
assert_relative_eq!(*last, 100.0, epsilon = 1e-9);
}
#[test]
fn v_one_equals_dema() {
// GD with v = 1 is exactly the standard DEMA.
let prices: Vec<f64> = (1..=80)
.map(|i| (f64::from(i) * 0.3).sin() * 10.0 + 50.0)
.collect();
let mut gd = GeneralizedDema::new(7, 1.0).unwrap();
let mut dema = Dema::new(7).unwrap();
let gd_out = gd.batch(&prices);
let dema_out = dema.batch(&prices);
for (g, d) in gd_out.iter().zip(dema_out.iter()) {
assert_eq!(g.is_some(), d.is_some());
if let (Some(a), Some(b)) = (g, d) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn v_zero_equals_ema() {
// GD with v = 0 is a plain EMA (no second-order correction).
let prices: Vec<f64> = (1..=60).map(|i| f64::from(i) * 0.5).collect();
let mut gd = GeneralizedDema::new(6, 0.0).unwrap();
let mut ema = Ema::new(6).unwrap();
let gd_out = gd.batch(&prices);
for (i, (g, p)) in gd_out.iter().zip(prices.iter()).enumerate() {
// GD(v=0) feeds EMA1 into EMA2 but outputs EMA1 alone (coefficient
// 1 on e1, 0 on e2); it is only ready once EMA2 is, so compare
// against a standalone EMA chained the same way.
let want = ema.update(*p).filter(|_| i + 1 >= gd.warmup_period());
if let (Some(a), Some(b)) = (g, want) {
assert_relative_eq!(*a, b, epsilon = 1e-9);
}
}
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=80).map(|i| f64::from(i) * 0.5).collect();
let mut a = GeneralizedDema::new(7, 0.7).unwrap();
let mut b = GeneralizedDema::new(7, 0.7).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn reset_clears_state() {
let mut gd = GeneralizedDema::new(5, 0.7).unwrap();
gd.batch(&(1..=50).map(f64::from).collect::<Vec<_>>());
assert!(gd.is_ready());
gd.reset();
assert!(!gd.is_ready());
assert_eq!(gd.update(1.0), None);
}
}
@@ -0,0 +1,275 @@
//! Geometric Moving Average (GMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Geometric Moving Average — the rolling geometric mean of the last `period`
/// inputs.
///
/// ```text
/// GMA = (Π value_i)^(1/period) = exp( (1/period) · Σ ln(value_i) )
/// ```
///
/// The geometric mean is the natural average for *multiplicative* quantities
/// such as prices and growth factors: averaging in log-space weights relative
/// (percentage) moves symmetrically, so a `+10%` followed by a `10%` move
/// pulls the average below the start, exactly as compounded returns do. It is
/// always less than or equal to the arithmetic mean of the same window.
///
/// Maintained incrementally in O(1): the running sum of natural logs is updated
/// by adding the newcomer's log and subtracting the departing value's log as
/// the window slides.
///
/// The geometric mean is only defined for **strictly positive** inputs. A
/// non-finite or non-positive input is ignored (it leaves the window unchanged
/// and returns the current value), mirroring the non-finite handling of the
/// other moving averages.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, GeometricMa};
///
/// let mut indicator = GeometricMa::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct GeometricMa {
period: usize,
/// Natural logs of the values currently in the window (oldest at front).
logs: VecDeque<f64>,
sum_logs: f64,
}
impl GeometricMa {
/// Construct a new geometric moving average over `period` inputs.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
logs: VecDeque::with_capacity(period),
sum_logs: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.logs.len() == self.period {
Some((self.sum_logs / self.period as f64).exp())
} else {
None
}
}
}
impl Indicator for GeometricMa {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() || input <= 0.0 {
return self.value();
}
if self.logs.len() == self.period {
let oldest = self.logs.pop_front().expect("window non-empty");
self.sum_logs -= oldest;
}
let ln = input.ln();
self.logs.push_back(ln);
self.sum_logs += ln;
self.value()
}
fn reset(&mut self) {
self.logs.clear();
self.sum_logs = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.logs.len() == self.period
}
fn name(&self) -> &'static str {
"GMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Reference implementation: explicit geometric mean over a window.
fn gma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
prices
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < period {
None
} else {
let window = &prices[i + 1 - period..=i];
let product: f64 = window.iter().product();
Some(product.powf(1.0 / period as f64))
}
})
.collect()
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(GeometricMa::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let gma = GeometricMa::new(7).unwrap();
assert_eq!(gma.period(), 7);
assert_eq!(gma.warmup_period(), 7);
assert_eq!(gma.name(), "GMA");
}
#[test]
fn warmup_returns_none() {
let mut gma = GeometricMa::new(3).unwrap();
assert_eq!(gma.update(1.0), None);
assert_eq!(gma.update(4.0), None);
// GMA(3) of [1, 4, 2] = (1·4·2)^(1/3) = 8^(1/3) = 2.
assert_relative_eq!(gma.update(2.0).unwrap(), 2.0, epsilon = 1e-12);
}
#[test]
fn known_value_period_2() {
// GMA(2) of [4, 9] = sqrt(36) = 6.
let mut gma = GeometricMa::new(2).unwrap();
let v = gma.batch(&[4.0, 9.0]);
assert_relative_eq!(v[1].unwrap(), 6.0, epsilon = 1e-12);
}
#[test]
fn constant_series_returns_the_constant() {
let mut gma = GeometricMa::new(5).unwrap();
for v in gma.batch(&[42.0; 20]).into_iter().flatten() {
assert_relative_eq!(v, 42.0, epsilon = 1e-9);
}
}
#[test]
fn period_one_is_pass_through() {
let mut gma = GeometricMa::new(1).unwrap();
assert_relative_eq!(gma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(gma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn below_or_equal_arithmetic_mean() {
// The geometric mean never exceeds the arithmetic mean of the same set.
let mut gma = GeometricMa::new(4).unwrap();
let prices = [10.0, 20.0, 5.0, 40.0];
let g = gma.batch(&prices)[3].unwrap();
let arithmetic = prices.iter().sum::<f64>() / 4.0;
assert!(
g < arithmetic,
"geometric {g} should be below arithmetic {arithmetic}"
);
}
#[test]
fn matches_naive_over_inputs() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 + 1.0).collect();
let mut gma = GeometricMa::new(7).unwrap();
let got = gma.batch(&prices);
let want = gma_naive(&prices, 7);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut gma = GeometricMa::new(4).unwrap();
gma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(gma.is_ready());
gma.reset();
assert!(!gma.is_ready());
assert_eq!(gma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5 + 1.0).collect();
let mut a = GeometricMa::new(5).unwrap();
let mut b = GeometricMa::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_and_non_positive_input() {
let mut gma = GeometricMa::new(3).unwrap();
gma.update(1.0);
gma.update(4.0);
let ready = gma.update(2.0).expect("GMA(3) ready after three inputs");
// Non-finite and non-positive inputs are skipped (geometric mean needs
// strictly positive values) and the window is left unchanged.
assert_eq!(gma.update(f64::NAN), Some(ready));
assert_eq!(gma.update(0.0), Some(ready));
assert_eq!(gma.update(-3.0), Some(ready));
// The window still holds 1, 4, 2 -> next real input slides it to 4, 2, 16.
let want = (4.0_f64 * 2.0 * 16.0).powf(1.0 / 3.0);
assert_relative_eq!(gma.update(16.0).unwrap(), want, epsilon = 1e-9);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn proptest_matches_naive(
period in 1usize..15,
prices in proptest::collection::vec(0.01_f64..1000.0, 0..120),
) {
let mut gma = GeometricMa::new(period).unwrap();
let got = gma.batch(&prices);
let want = gma_naive(&prices, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() <= 1e-6 * b.abs().max(1.0),
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
@@ -0,0 +1,315 @@
//! Holt's linear (double exponential) smoothing.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Holt's linear method — double exponential smoothing with a level and a
/// trend component.
///
/// A single [`Ema`](crate::Ema) tracks only a *level* and therefore lags any
/// sustained trend. Holt's method adds a second smoothed state, the trend, and
/// reports the one-step-ahead forecast `level + trend`, which removes that lag
/// on trending data while still smoothing noise.
///
/// ```text
/// level_t = α · price_t + (1 α) · (level_{t-1} + trend_{t-1})
/// trend_t = β · (level_t level_{t-1}) + (1 β) · trend_{t-1}
/// output = level_t + trend_t (one-step-ahead forecast)
/// ```
///
/// `α ∈ (0, 1]` is the level smoothing constant and `β ∈ (0, 1]` the trend
/// smoothing constant. The state is seeded from the first two inputs
/// (`level = price_1`, `trend = price_1 price_0`), so the first output lands
/// on the **second** input.
///
/// On a perfectly linear series the forecast is exact from the second bar
/// onward (for any `α`, `β`): if the level equals the current value and the
/// trend equals the slope, both invariants are preserved and `level + trend`
/// equals the next value.
///
/// # Example
///
/// ```
/// use wickra_core::{HoltWinters, Indicator};
///
/// let mut indicator = HoltWinters::new(0.2, 0.1).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct HoltWinters {
alpha: f64,
beta: f64,
/// `(level, trend)` once seeded.
state: Option<(f64, f64)>,
/// First input, held until the second arrives to seed the trend.
prev_price: Option<f64>,
}
impl HoltWinters {
/// Construct Holt's linear smoother with level constant `alpha` and trend
/// constant `beta`.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if either constant is non-finite or
/// outside `(0.0, 1.0]`.
pub fn new(alpha: f64, beta: f64) -> Result<Self> {
if !alpha.is_finite() || alpha <= 0.0 || alpha > 1.0 {
return Err(Error::InvalidPeriod {
message: "HoltWinters alpha must be in (0.0, 1.0]",
});
}
if !beta.is_finite() || beta <= 0.0 || beta > 1.0 {
return Err(Error::InvalidPeriod {
message: "HoltWinters beta must be in (0.0, 1.0]",
});
}
Ok(Self {
alpha,
beta,
state: None,
prev_price: None,
})
}
/// Level smoothing constant `alpha`.
pub const fn alpha(&self) -> f64 {
self.alpha
}
/// Trend smoothing constant `beta`.
pub const fn beta(&self) -> f64 {
self.beta
}
/// Current smoothed level, if seeded.
pub fn level(&self) -> Option<f64> {
self.state.map(|(level, _)| level)
}
/// Current smoothed trend, if seeded.
pub fn trend(&self) -> Option<f64> {
self.state.map(|(_, trend)| trend)
}
/// Current one-step-ahead forecast `level + trend`, if seeded.
pub fn value(&self) -> Option<f64> {
self.state.map(|(level, trend)| level + trend)
}
}
impl Indicator for HoltWinters {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.value();
}
match self.state {
None => {
if let Some(prev) = self.prev_price {
// Second input: seed level and trend.
let level = price;
let trend = price - prev;
self.state = Some((level, trend));
Some(level + trend)
} else {
// First input: hold it to seed the trend next time.
self.prev_price = Some(price);
None
}
}
Some((level, trend)) => {
let level_new = self.alpha * price + (1.0 - self.alpha) * (level + trend);
let trend_new = self.beta * (level_new - level) + (1.0 - self.beta) * trend;
self.state = Some((level_new, trend_new));
Some(level_new + trend_new)
}
}
}
fn reset(&mut self) {
self.state = None;
self.prev_price = None;
}
fn warmup_period(&self) -> usize {
// Two inputs are needed to seed the level and the trend.
2
}
fn is_ready(&self) -> bool {
self.state.is_some()
}
fn name(&self) -> &'static str {
"HoltWinters"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference for the steady-state recurrence.
fn naive(prices: &[f64], alpha: f64, beta: f64) -> Vec<Option<f64>> {
let mut state: Option<(f64, f64)> = None;
let mut prev: Option<f64> = None;
let mut out = Vec::with_capacity(prices.len());
for &price in prices {
let v = match state {
None => {
if let Some(p0) = prev {
let level = price;
let trend = price - p0;
state = Some((level, trend));
Some(level + trend)
} else {
prev = Some(price);
None
}
}
Some((level, trend)) => {
let ln = alpha * price + (1.0 - alpha) * (level + trend);
let tn = beta * (ln - level) + (1.0 - beta) * trend;
state = Some((ln, tn));
Some(ln + tn)
}
};
out.push(v);
}
out
}
#[test]
fn rejects_invalid_alpha() {
assert!(matches!(
HoltWinters::new(0.0, 0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(1.5, 0.1),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(f64::NAN, 0.1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn rejects_invalid_beta() {
assert!(matches!(
HoltWinters::new(0.2, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(0.2, 1.5),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
HoltWinters::new(0.2, f64::INFINITY),
Err(Error::InvalidPeriod { .. })
));
}
/// Cover the const accessors `alpha` + `beta` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let hw = HoltWinters::new(0.2, 0.1).unwrap();
assert_relative_eq!(hw.alpha(), 0.2, epsilon = 1e-12);
assert_relative_eq!(hw.beta(), 0.1, epsilon = 1e-12);
assert_eq!(hw.warmup_period(), 2);
assert_eq!(hw.name(), "HoltWinters");
}
#[test]
fn warmup_then_seed_on_second_input() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
assert_eq!(hw.update(10.0), None);
// Second input seeds level = 12, trend = 12 - 10 = 2 -> forecast 14.
assert_relative_eq!(hw.update(12.0).unwrap(), 14.0, epsilon = 1e-12);
assert_relative_eq!(hw.level().unwrap(), 12.0, epsilon = 1e-12);
assert_relative_eq!(hw.trend().unwrap(), 2.0, epsilon = 1e-12);
}
#[test]
fn linear_series_forecasts_exactly() {
// On a perfect ramp the one-step forecast equals the next value, for
// any alpha/beta, from the second bar onward.
let prices: Vec<f64> = (1..=20).map(f64::from).collect();
let mut hw = HoltWinters::new(0.3, 0.4).unwrap();
let out = hw.batch(&prices);
assert!(out[0].is_none());
for (i, v) in out.iter().enumerate().skip(1) {
// forecast at index i is the price at index i + 1 = (i + 2).
assert_relative_eq!(v.unwrap(), (i + 2) as f64, epsilon = 1e-9);
}
}
#[test]
fn constant_series_yields_constant() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
let out = hw.batch(&[42.0_f64; 30]);
for v in out.into_iter().skip(1).flatten() {
assert_relative_eq!(v, 42.0, epsilon = 1e-9);
}
}
#[test]
fn matches_naive_recurrence() {
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + f64::from(i) * 0.2)
.collect();
let mut hw = HoltWinters::new(0.25, 0.15).unwrap();
let got = hw.batch(&prices);
let want = naive(&prices, 0.25, 0.15);
for (g, w) in got.iter().zip(want.iter()) {
assert_eq!(g.is_some(), w.is_some());
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
hw.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(hw.is_ready());
hw.reset();
assert!(!hw.is_ready());
assert_eq!(hw.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 0.5).collect();
let mut a = HoltWinters::new(0.3, 0.2).unwrap();
let mut b = HoltWinters::new(0.3, 0.2).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input() {
let mut hw = HoltWinters::new(0.2, 0.1).unwrap();
// Non-finite before any state returns None.
assert_eq!(hw.update(f64::NAN), None);
hw.update(10.0);
let ready = hw.update(12.0).expect("seeded on second finite input");
// Non-finite after seeding returns the current forecast unchanged.
assert_eq!(hw.update(f64::NAN), Some(ready));
assert_eq!(hw.update(f64::INFINITY), Some(ready));
}
}
@@ -0,0 +1,228 @@
//! Intraday Momentum Index (IMI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close
/// move instead of the close-to-close move.
///
/// For each bar the body is an up-move when `close > open` and a down-move
/// otherwise; the IMI sums those bodies over `period` bars and forms the
/// RSI-style ratio:
///
/// ```text
/// gain = max(close - open, 0), loss = max(open - close, 0)
/// IMI = 100 * Σ gain / (Σ gain + Σ loss) over the last `period` bars
/// ```
///
/// Because it measures *intraday* (body) momentum rather than the gap-inclusive
/// close-to-close change, the IMI is a candle-pattern-flavoured overbought /
/// oversold gauge: persistent white bodies push it up, black bodies down. It is
/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the
/// neutral `50`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator};
///
/// let mut imi = IntradayMomentumIndex::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
/// last = imi.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct IntradayMomentumIndex {
period: usize,
/// Per-bar `(gain, loss)` bodies, oldest at the front.
window: VecDeque<(f64, f64)>,
sum_gain: f64,
sum_loss: f64,
}
impl IntradayMomentumIndex {
/// Construct an IMI over `period` bars.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_gain: 0.0,
sum_loss: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.window.len() != self.period {
return None;
}
let denom = self.sum_gain + self.sum_loss;
if denom == 0.0 {
Some(50.0)
} else {
Some(100.0 * self.sum_gain / denom)
}
}
}
impl Indicator for IntradayMomentumIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let body = candle.close - candle.open;
let gain = if body > 0.0 { body } else { 0.0 };
let loss = if body < 0.0 { -body } else { 0.0 };
if self.window.len() == self.period {
let (old_g, old_l) = self.window.pop_front().expect("window full");
self.sum_gain -= old_g;
self.sum_loss -= old_l;
}
self.window.push_back((gain, loss));
self.sum_gain += gain;
self.sum_loss += loss;
self.value()
}
fn reset(&mut self) {
self.window.clear();
self.sum_gain = 0.0;
self.sum_loss = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"IMI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, close: f64) -> Candle {
let hi = open.max(close) + 1.0;
let lo = open.min(close) - 1.0;
Candle::new(open, hi, lo, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
IntradayMomentumIndex::new(0),
Err(Error::PeriodZero)
));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let imi = IntradayMomentumIndex::new(14).unwrap();
assert_eq!(imi.period(), 14);
assert_eq!(imi.warmup_period(), 14);
assert_eq!(imi.name(), "IMI");
}
#[test]
fn all_up_bodies_is_one_hundred() {
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)];
let out = imi.batch(&bars);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12);
}
#[test]
fn all_down_bodies_is_zero() {
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)];
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn known_value_mixed_bodies() {
// bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75.
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)];
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12);
}
#[test]
fn doji_window_is_neutral() {
// close == open every bar -> no bodies -> neutral 50.
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)];
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn slides_window() {
// After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67.
let mut imi = IntradayMomentumIndex::new(3).unwrap();
let bars = [
candle(10.0, 11.0),
candle(11.0, 10.0),
candle(10.0, 12.0),
candle(12.0, 12.0),
];
let out = imi.batch(&bars);
assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut imi = IntradayMomentumIndex::new(3).unwrap();
imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]);
assert!(imi.is_ready());
imi.reset();
assert!(!imi.is_ready());
assert_eq!(imi.update(candle(1.0, 2.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base, base + (f64::from(i) * 0.5).sin())
})
.collect();
let mut a = IntradayMomentumIndex::new(7).unwrap();
let mut b = IntradayMomentumIndex::new(7).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,234 @@
//! Kase Permission Stochastic — a double-smoothed stochastic used as a
//! trade-permission filter.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Kase Permission Stochastic output: a fast and a slow line.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KasePermissionStochasticOutput {
/// Fast line: EMA of the raw `%K` over the smoothing period.
pub fast: f64,
/// Slow line: EMA of the fast line over the smoothing period.
pub slow: f64,
}
/// Cynthia Kase's Permission Stochastic: a stochastic oscillator smoothed twice,
/// whose fast/slow relationship grants or denies "permission" to trade in the
/// direction of a higher-timeframe signal.
///
/// ```text
/// raw%K = 100 * (close - LL) / (HH - LL) over `length` (50 when HH == LL)
/// fast = EMA(raw%K, smooth)
/// slow = EMA(fast, smooth)
/// ```
///
/// The raw stochastic is the usual `%K`, then an EMA produces the *fast* line
/// and a second EMA of that produces the *slow* line. Kase uses the pair as a
/// gate: a fast line above the slow line (and rising) gives permission for
/// longs, the reverse for shorts. When the lookback window is perfectly flat
/// (`HH == LL`), the raw stochastic is undefined and defaults to the neutral
/// `50`.
///
/// Reference: Cynthia Kase, *Trading with the Odds*, 1996.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, KasePermissionStochastic};
///
/// let mut indicator = KasePermissionStochastic::new(9, 3).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct KasePermissionStochastic {
length: usize,
smooth: usize,
window: VecDeque<(f64, f64)>,
fast_ema: Ema,
slow_ema: Ema,
}
impl KasePermissionStochastic {
/// Construct with the stochastic `length` and the EMA `smooth` period
/// applied twice.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `length == 0` or `smooth == 0`.
pub fn new(length: usize, smooth: usize) -> Result<Self> {
if length == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
length,
smooth,
window: VecDeque::with_capacity(length),
fast_ema: Ema::new(smooth)?,
slow_ema: Ema::new(smooth)?,
})
}
/// Cynthia Kase's classic parameters: `length = 9`, `smooth = 3`.
pub fn classic() -> Self {
Self::new(9, 3).expect("classic Kase Permission Stochastic parameters are valid")
}
/// Configured `(length, smooth)`.
pub const fn periods(&self) -> (usize, usize) {
(self.length, self.smooth)
}
}
impl Indicator for KasePermissionStochastic {
type Input = Candle;
type Output = KasePermissionStochasticOutput;
fn update(&mut self, candle: Candle) -> Option<KasePermissionStochasticOutput> {
self.window.push_back((candle.high, candle.low));
if self.window.len() > self.length {
self.window.pop_front();
}
if self.window.len() < self.length {
return None;
}
let highest = self.window.iter().map(|w| w.0).fold(f64::MIN, f64::max);
let lowest = self.window.iter().map(|w| w.1).fold(f64::MAX, f64::min);
let raw_k = if highest > lowest {
100.0 * (candle.close - lowest) / (highest - lowest)
} else {
50.0
};
let fast = self.fast_ema.update(raw_k)?;
let slow = self.slow_ema.update(fast)?;
Some(KasePermissionStochasticOutput { fast, slow })
}
fn reset(&mut self) {
self.window.clear();
self.fast_ema.reset();
self.slow_ema.reset();
}
fn warmup_period(&self) -> usize {
// raw%K ready after `length` bars; each EMA seeds over `smooth` values.
self.length + 2 * self.smooth - 2
}
fn is_ready(&self) -> bool {
self.slow_ema.is_ready()
}
fn name(&self) -> &'static str {
"KasePermissionStochastic"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
KasePermissionStochastic::new(0, 3),
Err(Error::PeriodZero)
));
assert!(matches!(
KasePermissionStochastic::new(9, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let k = KasePermissionStochastic::classic();
assert_eq!(k.periods(), (9, 3));
// 9 + 2*3 - 2 = 13.
assert_eq!(k.warmup_period(), 13);
assert_eq!(k.name(), "KasePermissionStochastic");
assert!(!k.is_ready());
}
#[test]
fn warmup_emits_at_expected_bar() {
let mut k = KasePermissionStochastic::new(3, 2).unwrap();
// warmup = 3 + 2*2 - 2 = 5 -> first value at input 5 (index 4).
let candles: Vec<Candle> = (0..8).map(|i| candle(11.0, 9.0, 10.5, i)).collect();
let out = k.batch(&candles);
assert!(out[3].is_none());
assert!(out[4].is_some());
}
#[test]
fn top_of_range_is_high() {
// Close pinned at the top of a rising range -> raw%K near 100, both
// smoothed lines high.
let mut k = KasePermissionStochastic::new(5, 3).unwrap();
let candles: Vec<Candle> = (0_i64..40)
.map(|i| {
let base = 100.0 + i as f64;
candle(base + 2.0, base - 2.0, base + 2.0, i)
})
.collect();
let last = k.batch(&candles).last().unwrap().unwrap();
assert!(last.fast > 80.0, "fast {} should be high", last.fast);
assert!(last.slow > 80.0, "slow {} should be high", last.slow);
}
#[test]
fn flat_window_defaults_to_neutral() {
// Constant high/low/close -> HH == LL -> raw%K defaults to 50, so both
// EMAs converge to 50.
let mut k = KasePermissionStochastic::new(4, 2).unwrap();
let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
let last = k.batch(&candles).last().unwrap().unwrap();
assert_relative_eq!(last.fast, 50.0, epsilon = 1e-9);
assert_relative_eq!(last.slow, 50.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut k = KasePermissionStochastic::classic();
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, 10.5, i)).collect();
k.batch(&candles);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80_i64)
.map(|i| {
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
candle(base + 2.0, base - 2.0, base + (i as f64 * 0.3).cos(), i)
})
.collect();
let mut a = KasePermissionStochastic::classic();
let mut b = KasePermissionStochastic::classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,205 @@
//! Median Moving Average.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Median Moving Average — the rolling median of the last `period` inputs.
///
/// For an odd `period` the output is the middle order statistic of the window;
/// for an even `period` it is the average of the two central values. Because it
/// is a rank statistic rather than a sum, the median MA is far more robust to
/// single outliers than the [`Sma`](crate::Sma): a lone spike shifts the rank
/// by at most one position instead of dragging the whole average.
///
/// Each `update` slides the window and computes the median by sorting a copy of
/// the `period` buffered values — O(`period` · log `period`) per step, with the
/// period fixed and bounded.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MedianMa};
///
/// let mut indicator = MedianMa::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MedianMa {
period: usize,
window: VecDeque<f64>,
}
impl MedianMa {
/// Construct a new median moving average over `period` inputs.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.window.len() != self.period {
return None;
}
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).expect("window holds only finite values"));
let mid = self.period / 2;
if self.period % 2 == 1 {
Some(sorted[mid])
} else {
Some(f64::midpoint(sorted[mid - 1], sorted[mid]))
}
}
}
impl Indicator for MedianMa {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
self.value()
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"MedianMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn new_rejects_zero_period() {
assert!(matches!(MedianMa::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let mma = MedianMa::new(7).unwrap();
assert_eq!(mma.period(), 7);
assert_eq!(mma.warmup_period(), 7);
assert_eq!(mma.name(), "MedianMA");
}
#[test]
fn warmup_returns_none_then_odd_median() {
let mut mma = MedianMa::new(3).unwrap();
assert_eq!(mma.update(5.0), None);
assert_eq!(mma.update(1.0), None);
// median of [5, 1, 3] = 3 (middle order statistic).
assert_relative_eq!(mma.update(3.0).unwrap(), 3.0, epsilon = 1e-12);
}
#[test]
fn even_period_averages_two_central_values() {
// median of [1, 2, 3, 4] = (2 + 3) / 2 = 2.5.
let mut mma = MedianMa::new(4).unwrap();
let v = mma.batch(&[1.0, 2.0, 3.0, 4.0]);
assert_relative_eq!(v[3].unwrap(), 2.5, epsilon = 1e-12);
}
#[test]
fn robust_to_single_outlier() {
// A lone spike does not move the median of an odd window the way it
// would move an SMA. median of [10, 11, 9999] = 11.
let mut mma = MedianMa::new(3).unwrap();
let v = mma.batch(&[10.0, 11.0, 9999.0]);
assert_relative_eq!(v[2].unwrap(), 11.0, epsilon = 1e-12);
}
#[test]
fn period_one_is_pass_through() {
let mut mma = MedianMa::new(1).unwrap();
assert_relative_eq!(mma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(mma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn slides_window_correctly() {
// After [1,2,3] the window slides to [2,3,4] -> median 3, then [3,4,5] -> 4.
let mut mma = MedianMa::new(3).unwrap();
let v = mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_relative_eq!(v[2].unwrap(), 2.0, epsilon = 1e-12);
assert_relative_eq!(v[3].unwrap(), 3.0, epsilon = 1e-12);
assert_relative_eq!(v[4].unwrap(), 4.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mma = MedianMa::new(4).unwrap();
mma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(mma.is_ready());
mma.reset();
assert!(!mma.is_ready());
assert_eq!(mma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| (f64::from(i) * 0.7).sin() * 5.0).collect();
let mut a = MedianMa::new(5).unwrap();
let mut b = MedianMa::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input_but_keeps_state() {
let mut mma = MedianMa::new(3).unwrap();
mma.update(5.0);
mma.update(1.0);
let ready = mma
.update(3.0)
.expect("MedianMA(3) ready after three inputs");
assert_eq!(mma.update(f64::NAN), Some(ready));
assert_eq!(mma.update(f64::INFINITY), Some(ready));
// Window still [5, 1, 3] -> next real input slides to [1, 3, 8] -> median 3.
assert_relative_eq!(mma.update(8.0).unwrap(), 3.0, epsilon = 1e-12);
}
}
+73 -1
View File
@@ -17,6 +17,7 @@ mod accelerator_oscillator;
mod ad_oscillator;
mod ad_volume_line;
mod adaptive_cycle;
mod adaptive_laguerre_filter;
mod adl;
mod advance_block;
mod advance_decline;
@@ -90,7 +91,9 @@ mod dema;
mod demand_index;
mod demark_pivots;
mod depth_slope;
mod derivative_oscillator;
mod detrended_std_dev;
mod disparity_index;
mod distance_ssd;
mod doji;
mod doji_star;
@@ -103,10 +106,13 @@ mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
mod dx;
mod dynamic_momentum_index;
mod ease_of_movement;
mod effective_spread;
mod ehlers_stochastic;
mod ehma;
mod elder_impulse;
mod elder_ray;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
@@ -124,6 +130,7 @@ mod fib_projection;
mod fib_retracement;
mod fib_time_zones;
mod fibonacci_pivots;
mod fisher_rsi;
mod fisher_transform;
mod flag_pennant;
mod footprint;
@@ -138,6 +145,9 @@ mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garman_klass;
mod gartley;
mod gator_oscillator;
mod generalized_dema;
mod geometric_ma;
mod golden_pocket;
mod granger_causality;
mod gravestone_doji;
@@ -155,6 +165,7 @@ mod hilbert_dominant_cycle;
mod hilo_activator;
mod historical_volatility;
mod hma;
mod holt_winters;
mod homing_pigeon;
mod ht_dcphase;
mod ht_phasor;
@@ -168,6 +179,7 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_momentum_index;
mod intraday_volatility_profile;
mod inverse_fisher_transform;
mod inverted_hammer;
@@ -176,6 +188,7 @@ mod jump_indicator;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
mod kase_permission_stochastic;
mod kelly_criterion;
mod keltner;
mod kicking;
@@ -212,6 +225,7 @@ mod mcclellan_oscillator;
mod mcclellan_summation_index;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_ma;
mod median_price;
mod mfi;
mod microprice;
@@ -254,10 +268,13 @@ mod plus_di;
mod plus_dm;
mod pmo;
mod point_and_figure_bars;
mod polarized_fractal_efficiency;
mod ppo;
mod profit_factor;
mod psar;
mod pvi;
mod qqe;
mod qstick;
mod quoted_spread;
mod r_squared;
mod realized_spread;
@@ -270,6 +287,7 @@ mod renko_bars;
mod renko_trailing_stop;
mod rickshaw_man;
mod rising_three_methods;
mod rmi;
mod roc;
mod rocp;
mod rocr;
@@ -283,6 +301,7 @@ mod rolling_percentile_rank;
mod rolling_quantile;
mod roofing_filter;
mod rsi;
mod rsx;
mod rvi;
mod rvi_volatility;
mod rwi;
@@ -298,6 +317,7 @@ mod shooting_star;
mod short_line;
mod signed_volume;
mod sine_wave;
mod sine_weighted_ma;
mod skewness;
mod sma;
mod smi;
@@ -318,6 +338,7 @@ mod step_trailing_stop;
mod stick_sandwich;
mod stoch_rsi;
mod stochastic;
mod stochastic_cci;
mod super_smoother;
mod super_trend;
mod t3;
@@ -351,6 +372,7 @@ mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trend_label;
mod trend_strength_index;
mod treynor_ratio;
mod triangle;
mod trima;
@@ -362,6 +384,7 @@ mod tsf;
mod tsi;
mod tsv;
mod ttm_squeeze;
mod ttm_trend;
mod turn_of_month;
mod tweezer;
mod two_crows;
@@ -389,6 +412,7 @@ mod vwap;
mod vwap_stddev_bands;
mod vwma;
mod vzo;
mod wave_pm;
mod wave_trend;
mod wedge;
mod weighted_close;
@@ -413,6 +437,7 @@ pub use accelerator_oscillator::AcceleratorOscillator;
pub use ad_oscillator::AdOscillator;
pub use ad_volume_line::AdVolumeLine;
pub use adaptive_cycle::AdaptiveCycle;
pub use adaptive_laguerre_filter::AdaptiveLaguerreFilter;
pub use adl::Adl;
pub use advance_block::AdvanceBlock;
pub use advance_decline::AdvanceDecline;
@@ -486,7 +511,9 @@ pub use dema::Dema;
pub use demand_index::DemandIndex;
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
pub use depth_slope::DepthSlope;
pub use derivative_oscillator::DerivativeOscillator;
pub use detrended_std_dev::DetrendedStdDev;
pub use disparity_index::DisparityIndex;
pub use distance_ssd::DistanceSsd;
pub use doji::Doji;
pub use doji_star::DojiStar;
@@ -499,10 +526,13 @@ pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
pub use dx::Dx;
pub use dynamic_momentum_index::DynamicMomentumIndex;
pub use ease_of_movement::EaseOfMovement;
pub use effective_spread::EffectiveSpread;
pub use ehlers_stochastic::EhlersStochastic;
pub use ehma::Ehma;
pub use elder_impulse::ElderImpulse;
pub use elder_ray::{ElderRay, ElderRayOutput};
pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
@@ -520,6 +550,7 @@ pub use fib_projection::{FibProjection, FibProjectionOutput};
pub use fib_retracement::{FibRetracement, FibRetracementOutput};
pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput};
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
pub use fisher_rsi::FisherRsi;
pub use fisher_transform::FisherTransform;
pub use flag_pennant::FlagPennant;
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
@@ -534,6 +565,9 @@ pub use gain_loss_ratio::GainLossRatio;
pub use gap_side_by_side_white::GapSideBySideWhite;
pub use garman_klass::GarmanKlassVolatility;
pub use gartley::Gartley;
pub use gator_oscillator::GatorOscillator;
pub use generalized_dema::GeneralizedDema;
pub use geometric_ma::GeometricMa;
pub use golden_pocket::{GoldenPocket, GoldenPocketOutput};
pub use granger_causality::GrangerCausality;
pub use gravestone_doji::GravestoneDoji;
@@ -551,6 +585,7 @@ pub use hilbert_dominant_cycle::HilbertDominantCycle;
pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use holt_winters::HoltWinters;
pub use homing_pigeon::HomingPigeon;
pub use ht_dcphase::HtDcPhase;
pub use ht_phasor::{HtPhasor, HtPhasorOutput};
@@ -564,6 +599,7 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_momentum_index::IntradayMomentumIndex;
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
@@ -572,6 +608,7 @@ pub use jump_indicator::JumpIndicator;
pub use kagi_bars::{KagiBar, KagiBars};
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
pub use kama::Kama;
pub use kase_permission_stochastic::KasePermissionStochastic;
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
pub use kicking::Kicking;
@@ -608,6 +645,7 @@ pub use mcclellan_oscillator::McClellanOscillator;
pub use mcclellan_summation_index::McClellanSummationIndex;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_ma::MedianMa;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
@@ -650,10 +688,13 @@ pub use plus_di::PlusDi;
pub use plus_dm::PlusDm;
pub use pmo::Pmo;
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
pub use pvi::Pvi;
pub use qqe::{Qqe, QqeOutput};
pub use qstick::Qstick;
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use realized_spread::RealizedSpread;
@@ -666,6 +707,7 @@ pub use renko_bars::{RenkoBars, RenkoBrick};
pub use renko_trailing_stop::RenkoTrailingStop;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
pub use rmi::Rmi;
pub use roc::Roc;
pub use rocp::Rocp;
pub use rocr::Rocr;
@@ -679,6 +721,7 @@ pub use rolling_percentile_rank::RollingPercentileRank;
pub use rolling_quantile::RollingQuantile;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rsx::Rsx;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
@@ -694,6 +737,7 @@ pub use shooting_star::ShootingStar;
pub use short_line::ShortLine;
pub use signed_volume::SignedVolume;
pub use sine_wave::SineWave;
pub use sine_weighted_ma::SineWeightedMa;
pub use skewness::Skewness;
pub use sma::Sma;
pub use smi::Smi;
@@ -714,6 +758,7 @@ pub use step_trailing_stop::StepTrailingStop;
pub use stick_sandwich::StickSandwich;
pub use stoch_rsi::StochRsi;
pub use stochastic::{Stochastic, StochasticOutput};
pub use stochastic_cci::StochasticCci;
pub use super_smoother::SuperSmoother;
pub use super_trend::{SuperTrend, SuperTrendOutput};
pub use t3::T3;
@@ -747,6 +792,7 @@ pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProf
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trend_label::TrendLabel;
pub use trend_strength_index::TrendStrengthIndex;
pub use treynor_ratio::TreynorRatio;
pub use triangle::Triangle;
pub use trima::Trima;
@@ -758,6 +804,7 @@ pub use tsf::Tsf;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use ttm_trend::TtmTrend;
pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use two_crows::TwoCrows;
@@ -785,6 +832,7 @@ pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
pub use vwma::Vwma;
pub use vzo::Vzo;
pub use wave_pm::WavePm;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use wedge::Wedge;
pub use weighted_close::WeightedClose;
@@ -830,6 +878,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Jma",
"Alligator",
"Evwma",
"SineWeightedMa",
"GeometricMa",
"Ehma",
"MedianMa",
"AdaptiveLaguerreFilter",
"GeneralizedDema",
"HoltWinters",
],
),
(
@@ -859,6 +914,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Rocp",
"Rocr",
"Rocr100",
"DisparityIndex",
"FisherRsi",
"Rsx",
"DynamicMomentumIndex",
"StochasticCci",
"Rmi",
"DerivativeOscillator",
"ElderRay",
"IntradayMomentumIndex",
"Qqe",
],
),
(
@@ -885,6 +950,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"MinusDi",
"Dx",
"TrendLabel",
"TtmTrend",
"TrendStrengthIndex",
"Qstick",
"PolarizedFractalEfficiency",
"WavePm",
"GatorOscillator",
"KasePermissionStochastic",
],
),
(
@@ -1342,6 +1414,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 396, "FAMILIES total drifted from indicator count");
assert_eq!(total, 420, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,243 @@
//! Polarized Fractal Efficiency (PFE).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Polarized Fractal Efficiency: how efficiently price travelled over the last
/// `period` bars, signed by direction and smoothed by an EMA.
///
/// ```text
/// straight = sqrt((C_t - C_{t-n})^2 + n^2) (direct distance over n bars)
/// path = Σ_{i=1..n} sqrt((C_{t-i+1} - C_{t-i})^2 + 1) (sum of single-bar steps)
/// raw = 100 * sign(C_t - C_{t-n}) * straight / path
/// PFE = EMA(raw, smoothing)
/// ```
///
/// The ratio `straight / path` is the fractal efficiency: it is `1` when price
/// moved in a perfectly straight line and falls toward `0` as the path becomes
/// jagged. Polarizing it by the sign of the net move pushes the reading to
/// `+100` for an efficient up-move and `-100` for an efficient down-move, with
/// choppy markets oscillating near zero. Because each single-bar step and the
/// `n`-bar diagonal both carry the bar count on the x-axis (`+1` and `+n^2`),
/// the path length is always `>= n`, so the denominator can never be zero.
///
/// Reference: Hans Hannula, *Stocks & Commodities*, 1994.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, PolarizedFractalEfficiency};
///
/// let mut indicator = PolarizedFractalEfficiency::new(10, 5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PolarizedFractalEfficiency {
period: usize,
smoothing: usize,
closes: VecDeque<f64>,
prev_close: Option<f64>,
segments: VecDeque<f64>,
segment_sum: f64,
ema: Ema,
}
impl PolarizedFractalEfficiency {
/// Construct a PFE with the fractal lookback `period` and the EMA
/// `smoothing` period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` or `smoothing == 0`.
pub fn new(period: usize, smoothing: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
smoothing,
closes: VecDeque::with_capacity(period + 1),
prev_close: None,
segments: VecDeque::with_capacity(period),
segment_sum: 0.0,
ema: Ema::new(smoothing)?,
})
}
/// Configured `(period, smoothing)`.
pub const fn periods(&self) -> (usize, usize) {
(self.period, self.smoothing)
}
}
impl Indicator for PolarizedFractalEfficiency {
type Input = f64;
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
if let Some(prev) = self.prev_close {
let diff = close - prev;
let segment = diff.mul_add(diff, 1.0).sqrt();
self.segment_sum += segment;
self.segments.push_back(segment);
if self.segments.len() > self.period {
self.segment_sum -= self.segments.pop_front().unwrap_or(0.0);
}
}
self.prev_close = Some(close);
self.closes.push_back(close);
if self.closes.len() > self.period + 1 {
self.closes.pop_front();
}
if self.closes.len() <= self.period {
return None;
}
let oldest = *self.closes.front().unwrap_or(&close);
let net = close - oldest;
let direction = if net > 0.0 {
1.0
} else if net < 0.0 {
-1.0
} else {
0.0
};
let span = self.period as f64;
let straight = net.mul_add(net, span * span).sqrt();
let raw = 100.0 * direction * straight / self.segment_sum;
self.ema.update(raw)
}
fn reset(&mut self) {
self.closes.clear();
self.prev_close = None;
self.segments.clear();
self.segment_sum = 0.0;
self.ema.reset();
}
fn warmup_period(&self) -> usize {
self.period + self.smoothing
}
fn is_ready(&self) -> bool {
self.ema.is_ready()
}
fn name(&self) -> &'static str {
"PolarizedFractalEfficiency"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(
PolarizedFractalEfficiency::new(0, 5),
Err(Error::PeriodZero)
));
assert!(matches!(
PolarizedFractalEfficiency::new(10, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn accessors_and_metadata() {
let pfe = PolarizedFractalEfficiency::new(10, 5).unwrap();
assert_eq!(pfe.periods(), (10, 5));
assert_eq!(pfe.warmup_period(), 15);
assert_eq!(pfe.name(), "PolarizedFractalEfficiency");
assert!(!pfe.is_ready());
}
#[test]
fn warmup_emits_after_period_plus_smoothing() {
let mut pfe = PolarizedFractalEfficiency::new(4, 2).unwrap();
// raw needs period+1 = 5 closes; EMA(2) needs 2 raws -> first value at
// input 6 (index 5).
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
let out = pfe.batch(&inputs);
assert!(out[4].is_none());
assert!(out[5].is_some());
}
#[test]
fn perfect_uptrend_is_strongly_positive() {
// A straight ramp: every step is +1, the diagonal is maximally
// efficient, so PFE saturates near +100.
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
let inputs: Vec<f64> = (0..30).map(f64::from).collect();
let last = pfe.batch(&inputs).last().unwrap().unwrap();
assert!(last > 99.0, "pfe {last} should be near +100");
}
#[test]
fn perfect_downtrend_is_strongly_negative() {
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
let inputs: Vec<f64> = (0..30).map(|i| -f64::from(i)).collect();
let last = pfe.batch(&inputs).last().unwrap().unwrap();
assert!(last < -99.0, "pfe {last} should be near -100");
}
#[test]
fn flat_market_returns_zero() {
// No net move over the window -> direction 0 -> raw 0 -> PFE 0.
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
let inputs = [10.0; 20];
let last = pfe.batch(&inputs).last().unwrap().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn choppy_market_is_inefficient() {
// A sawtooth whip: the net move is tiny relative to the jagged path, so
// efficiency stays well below the +-100 saturation of a clean trend.
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
let inputs: Vec<f64> = (0..40)
.map(|i| if i % 2 == 0 { 100.0 } else { 102.0 })
.collect();
let last = pfe.batch(&inputs).last().unwrap().unwrap();
assert!(
last.abs() < 60.0,
"choppy pfe {last} should be far from +-100"
);
}
#[test]
fn reset_clears_state() {
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
let inputs: Vec<f64> = (0..30).map(f64::from).collect();
pfe.batch(&inputs);
assert!(pfe.is_ready());
pfe.reset();
assert!(!pfe.is_ready());
assert_eq!(pfe.periods(), (5, 3));
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let mut a = PolarizedFractalEfficiency::new(10, 5).unwrap();
let mut b = PolarizedFractalEfficiency::new(10, 5).unwrap();
assert_eq!(
a.batch(&inputs),
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+358
View File
@@ -0,0 +1,358 @@
//! QQE — Quantitative Qualitative Estimation.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::rsi::Rsi;
use crate::traits::Indicator;
/// One QQE reading: the smoothed RSI and its volatility-trailing line.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QqeOutput {
/// The EMA-smoothed RSI (the fast QQE line).
pub rsi_ma: f64,
/// The trailing line (the slow QQE line): an ATR-of-RSI trailing stop that
/// the smoothed RSI rides above in an uptrend and below in a downtrend.
pub trailing_line: f64,
}
/// QQE — Quantitative Qualitative Estimation (Igor Livshin).
///
/// QQE smooths the RSI, then builds an "ATR of the RSI" trailing stop around it.
/// Crossovers of the smoothed RSI and that trailing line give cleaner momentum
/// signals than the raw RSI:
///
/// ```text
/// rsi_ma = EMA(RSI(price, rsi_period), smoothing)
/// atr_rsi = |rsi_ma rsi_ma_prev|
/// ma_atr = EMA(atr_rsi, 2·rsi_period 1) // Wilder length
/// dar = EMA(ma_atr, 2·rsi_period 1) · factor // smoothed band width
///
/// long_band = (rsi_ma_prev > long_band_prev && rsi_ma > long_band_prev)
/// ? max(long_band_prev, rsi_ma dar) : rsi_ma dar
/// short_band = (rsi_ma_prev < short_band_prev && rsi_ma < short_band_prev)
/// ? min(short_band_prev, rsi_ma + dar) : rsi_ma + dar
/// trend = cross-up of short_band → +1, cross-down of long_band → 1, else hold
/// trailing = trend == +1 ? long_band : short_band
/// ```
///
/// The trailing line ratchets in the trend direction (only ever tightening until
/// the smoothed RSI crosses it), exactly like a [`SuperTrend`](crate::SuperTrend)
/// on the RSI. Livshin's defaults are `rsi_period = 14`, `smoothing = 5`,
/// `factor = 4.236`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Qqe};
///
/// let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
/// let mut last = None;
/// for i in 0..200 {
/// last = qqe.update(100.0 + (f64::from(i) * 0.1).sin() * 8.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Qqe {
rsi: Rsi,
rsi_ma: Ema,
ma_atr: Ema,
dar_ema: Ema,
factor: f64,
prev_rsi_ma: Option<f64>,
bands: Option<(f64, f64, i8)>, // (long_band, short_band, trend)
last_value: Option<QqeOutput>,
}
impl Qqe {
/// Construct a QQE with the RSI period, RSI smoothing, and band `factor`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `rsi_period` or `smoothing` is `0`, or
/// [`Error::InvalidPeriod`] if `factor` is non-finite or not positive.
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<Self> {
if rsi_period == 0 || smoothing == 0 {
return Err(Error::PeriodZero);
}
if !factor.is_finite() || factor <= 0.0 {
return Err(Error::InvalidPeriod {
message: "QQE factor must be a finite positive value",
});
}
let wilders = 2 * rsi_period - 1;
Ok(Self {
rsi: Rsi::new(rsi_period)?,
rsi_ma: Ema::new(smoothing)?,
ma_atr: Ema::new(wilders)?,
dar_ema: Ema::new(wilders)?,
factor,
prev_rsi_ma: None,
bands: None,
last_value: None,
})
}
/// Configured band factor.
pub const fn factor(&self) -> f64 {
self.factor
}
/// Current value if available.
pub const fn value(&self) -> Option<QqeOutput> {
self.last_value
}
}
impl Indicator for Qqe {
type Input = f64;
type Output = QqeOutput;
fn update(&mut self, price: f64) -> Option<QqeOutput> {
let rsi = self.rsi.update(price)?;
let rsi_ma = self.rsi_ma.update(rsi)?;
let Some(prev_ma) = self.prev_rsi_ma else {
self.prev_rsi_ma = Some(rsi_ma);
return None;
};
let atr_rsi = (rsi_ma - prev_ma).abs();
self.prev_rsi_ma = Some(rsi_ma);
let ma_atr = self.ma_atr.update(atr_rsi)?;
let dar = self.dar_ema.update(ma_atr)? * self.factor;
let new_long = rsi_ma - dar;
let new_short = rsi_ma + dar;
let (long_band, short_band, trend) = match self.bands {
Some((lb_prev, sb_prev, tr_prev)) => {
let lb = if prev_ma > lb_prev && rsi_ma > lb_prev {
lb_prev.max(new_long)
} else {
new_long
};
let sb = if prev_ma < sb_prev && rsi_ma < sb_prev {
sb_prev.min(new_short)
} else {
new_short
};
let tr = if prev_ma <= sb_prev && rsi_ma > sb_prev {
1
} else if prev_ma >= lb_prev && rsi_ma < lb_prev {
-1
} else {
tr_prev
};
(lb, sb, tr)
}
None => (new_long, new_short, 1),
};
self.bands = Some((long_band, short_band, trend));
let trailing_line = if trend == 1 { long_band } else { short_band };
let out = QqeOutput {
rsi_ma,
trailing_line,
};
self.last_value = Some(out);
Some(out)
}
fn reset(&mut self) {
self.rsi.reset();
self.rsi_ma.reset();
self.ma_atr.reset();
self.dar_ema.reset();
self.prev_rsi_ma = None;
self.bands = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
// RSI (rsi_period + 1) -> rsi_ma EMA -> one bar for the first atr_rsi ->
// ma_atr EMA -> dar EMA. Expressed via the component warmups so it stays
// correct if those change.
self.rsi.warmup_period()
+ self.rsi_ma.warmup_period()
+ self.ma_atr.warmup_period()
+ self.dar_ema.warmup_period()
- 2
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"QQE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Independent reference replaying the full QQE recurrence.
fn naive(
prices: &[f64],
rsi_period: usize,
smoothing: usize,
factor: f64,
) -> Vec<Option<QqeOutput>> {
let mut rsi = Rsi::new(rsi_period).unwrap();
let mut rsi_ma = Ema::new(smoothing).unwrap();
let wilders = 2 * rsi_period - 1;
let mut ma_atr = Ema::new(wilders).unwrap();
let mut dar_ema = Ema::new(wilders).unwrap();
let mut prev_ma: Option<f64> = None;
let mut bands: Option<(f64, f64, i8)> = None;
let mut out = Vec::with_capacity(prices.len());
for &p in prices {
let v = (|| {
let r = rsi.update(p)?;
let m = rsi_ma.update(r)?;
let Some(pm) = prev_ma else {
prev_ma = Some(m);
return None;
};
let atr = (m - pm).abs();
prev_ma = Some(m);
let ma = ma_atr.update(atr)?;
let dar = dar_ema.update(ma)? * factor;
let nl = m - dar;
let ns = m + dar;
let (lb, sb, tr) = match bands {
Some((lbp, sbp, trp)) => {
let lb = if pm > lbp && m > lbp { lbp.max(nl) } else { nl };
let sb = if pm < sbp && m < sbp { sbp.min(ns) } else { ns };
let tr = if pm <= sbp && m > sbp {
1
} else if pm >= lbp && m < lbp {
-1
} else {
trp
};
(lb, sb, tr)
}
None => (nl, ns, 1),
};
bands = Some((lb, sb, tr));
Some(QqeOutput {
rsi_ma: m,
trailing_line: if tr == 1 { lb } else { sb },
})
})();
out.push(v);
}
out
}
#[test]
fn rejects_bad_params() {
assert!(matches!(Qqe::new(0, 5, 4.236), Err(Error::PeriodZero)));
assert!(matches!(Qqe::new(14, 0, 4.236), Err(Error::PeriodZero)));
assert!(matches!(
Qqe::new(14, 5, 0.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
Qqe::new(14, 5, f64::NAN),
Err(Error::InvalidPeriod { .. })
));
}
/// Cover the const accessors `factor` + `value` and the Indicator-impl
/// `name`. `warmup_period` is covered by `first_emission_matches_warmup`.
#[test]
fn accessors_and_metadata() {
let qqe = Qqe::new(14, 5, 4.236).unwrap();
assert_relative_eq!(qqe.factor(), 4.236, epsilon = 1e-12);
assert_eq!(qqe.value(), None);
assert_eq!(qqe.name(), "QQE");
}
#[test]
fn first_emission_matches_warmup() {
// A long trend-up-then-down series exercises both trend flips and the
// band tighten/reset branches.
let prices: Vec<f64> = (0..200)
.map(|i| 100.0 + (f64::from(i) * 0.06).sin() * 20.0)
.collect();
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
let out = qqe.batch(&prices);
let warmup = qqe.warmup_period();
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(
out[warmup - 1].is_some(),
"first value at warmup_period - 1"
);
}
#[test]
fn matches_naive_over_full_cycle() {
// Up, range, and down phases so every band/trend branch is traversed.
let prices: Vec<f64> = (0..220)
.map(|i| {
let t = f64::from(i);
100.0 + (t * 0.05).sin() * 18.0 + (t * 0.2).cos() * 4.0
})
.collect();
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
let got = qqe.batch(&prices);
let want = naive(&prices, 14, 5, 4.236);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(a.rsi_ma, b.rsi_ma, epsilon = 1e-9);
assert_relative_eq!(a.trailing_line, b.trailing_line, epsilon = 1e-9);
}
}
}
#[test]
fn trailing_line_below_rsi_ma_in_uptrend() {
// Sustained rise: trend resolves to +1 and the trailing (long) band sits
// below the smoothed RSI.
let prices: Vec<f64> = (1..=120).map(f64::from).collect();
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
let last = qqe.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last.trailing_line <= last.rsi_ma,
"uptrend trailing {} should sit at/below rsi_ma {}",
last.trailing_line,
last.rsi_ma
);
}
#[test]
fn reset_clears_state() {
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
qqe.batch(
&(0..120)
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 8.0)
.collect::<Vec<_>>(),
);
assert!(qqe.is_ready());
qqe.reset();
assert!(!qqe.is_ready());
assert_eq!(qqe.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (0..150)
.map(|i| 50.0 + (f64::from(i) * 0.12).sin() * 12.0)
.collect();
let mut a = Qqe::new(14, 5, 4.236).unwrap();
let mut b = Qqe::new(14, 5, 4.236).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Qstick — Tushar Chande's measure of buying vs. selling pressure.
use crate::error::Result;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Qstick: the simple moving average of the body `close - open` over `period`
/// bars.
///
/// Positive values indicate a run of bars that closed above their open (net
/// buying pressure); negative values indicate net selling pressure. A zero
/// crossing is read as a shift in short-term sentiment.
///
/// ```text
/// Qstick = SMA(close - open, period)
/// ```
///
/// Reference: Tushar Chande, *The New Technical Trader*, 1994.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Qstick};
///
/// let mut indicator = Qstick::new(5).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 1.0, base + 1.0, 1.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Qstick {
period: usize,
sma: Sma,
}
impl Qstick {
/// Construct a Qstick with the given averaging period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
sma: Sma::new(period)?,
})
}
/// Configured averaging period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Qstick {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.sma.update(candle.close - candle.open)
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"Qstick"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(open: f64, close: f64, ts: i64) -> Candle {
let high = open.max(close) + 1.0;
let low = open.min(close) - 1.0;
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Qstick::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let q = Qstick::new(5).unwrap();
assert_eq!(q.period(), 5);
assert_eq!(q.warmup_period(), 5);
assert_eq!(q.name(), "Qstick");
assert!(!q.is_ready());
}
#[test]
fn warmup_emits_first_value_at_period() {
let mut q = Qstick::new(3).unwrap();
let candles: Vec<Candle> = (0..3).map(|i| candle(10.0, 11.0, i)).collect();
let out = q.batch(&candles);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert!(out[2].is_some());
}
#[test]
fn constant_bodies_yield_the_body() {
// Every bar closes 1.5 above its open -> Qstick converges to 1.5.
let mut q = Qstick::new(4).unwrap();
let candles: Vec<Candle> = (0..10).map(|i| candle(10.0, 11.5, i)).collect();
let out = q.batch(&candles);
assert_relative_eq!(out.last().unwrap().unwrap(), 1.5, epsilon = 1e-12);
}
#[test]
fn selling_pressure_is_negative() {
let mut q = Qstick::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 10.0, i)).collect();
let last = q.batch(&candles).last().unwrap().unwrap();
assert!(last < 0.0, "qstick {last} should be negative");
}
#[test]
fn reset_clears_state() {
let mut q = Qstick::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(10.0, 11.0, i)).collect();
q.batch(&candles);
assert!(q.is_ready());
q.reset();
assert!(!q.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40_i64)
.map(|i| {
candle(
100.0 + (i as f64 * 0.3).sin(),
100.0 + (i as f64 * 0.4).cos(),
i,
)
})
.collect();
let mut a = Qstick::new(7).unwrap();
let mut b = Qstick::new(7).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
+276
View File
@@ -0,0 +1,276 @@
//! Relative Momentum Index (RMI).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Relative Momentum Index — RSI generalised to a multi-bar momentum lookback.
///
/// Wilder's [`Rsi`](crate::Rsi) compares each close to the *previous* close.
/// The RMI (Roger Altman, 1993) compares it to the close `momentum` bars ago,
/// then applies the same Wilder-smoothed up/down accumulator over `period`:
///
/// ```text
/// change_t = close_t - close_{t-momentum}
/// gain = max(change, 0), loss = max(-change, 0)
/// avg_gain, avg_loss = Wilder-smoothed over `period`
/// RMI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// `momentum = 1` reduces the RMI exactly to the RSI. Larger `momentum` makes
/// the oscillator smoother and slower to flip, holding overbought/oversold
/// readings longer in a trend. Output is bounded in `[0, 100]`; a flat market
/// (no gains and no losses) returns the neutral `50`.
///
/// The first value lands after `momentum + period` inputs: `momentum` to fill
/// the lookback, then `period` changes to seed Wilder's averages.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rmi};
///
/// let mut indicator = Rmi::new(14, 5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rmi {
period: usize,
momentum: usize,
/// The last `momentum` prices, oldest at the front.
window: VecDeque<f64>,
seed_gains: Vec<f64>,
seed_losses: Vec<f64>,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last_value: Option<f64>,
}
impl Rmi {
/// Construct an RMI with the given smoothing `period` and `momentum`
/// lookback.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if either `period` or `momentum` is `0`.
pub fn new(period: usize, momentum: usize) -> Result<Self> {
if period == 0 || momentum == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
momentum,
window: VecDeque::with_capacity(momentum),
seed_gains: Vec::with_capacity(period),
seed_losses: Vec::with_capacity(period),
avg_gain: None,
avg_loss: None,
last_value: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured momentum lookback.
pub const fn momentum(&self) -> usize {
self.momentum
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn rmi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
100.0 * (avg_gain / denom)
}
}
}
impl Indicator for Rmi {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
if self.window.len() < self.momentum {
// Still filling the momentum lookback; no change to measure yet.
self.window.push_back(input);
return None;
}
let past = self.window.pop_front().expect("window full");
self.window.push_back(input);
let change = input - past;
let gain = if change > 0.0 { change } else { 0.0 };
let loss = if change < 0.0 { -change } else { 0.0 };
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
let n = self.period as f64;
let new_ag = (ag * (n - 1.0) + gain) / n;
let new_al = (al * (n - 1.0) + loss) / n;
self.avg_gain = Some(new_ag);
self.avg_loss = Some(new_al);
let v = Self::rmi_from_avgs(new_ag, new_al);
self.last_value = Some(v);
return Some(v);
}
self.seed_gains.push(gain);
self.seed_losses.push(loss);
if self.seed_gains.len() == self.period {
let ag = self.seed_gains.iter().sum::<f64>() / self.period as f64;
let al = self.seed_losses.iter().sum::<f64>() / self.period as f64;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rmi_from_avgs(ag, al);
self.last_value = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.window.clear();
self.seed_gains.clear();
self.seed_losses.clear();
self.avg_gain = None;
self.avg_loss = None;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
self.momentum + self.period
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"RMI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::indicators::Rsi;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_params() {
assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero)));
assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `period` + `momentum` + `value` and the
/// Indicator-impl `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let rmi = Rmi::new(14, 5).unwrap();
assert_eq!(rmi.period(), 14);
assert_eq!(rmi.momentum(), 5);
assert_eq!(rmi.value(), None);
assert_eq!(rmi.warmup_period(), 19);
assert_eq!(rmi.name(), "RMI");
}
#[test]
fn momentum_one_equals_rsi() {
// With momentum = 1 the RMI is exactly Wilder's RSI.
let prices: Vec<f64> = (0..60)
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
.collect();
let mut rmi = Rmi::new(14, 1).unwrap();
let mut rsi = Rsi::new(14).unwrap();
for (i, &p) in prices.iter().enumerate() {
let got = rmi.update(p);
let want = rsi.update(p);
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
if let (Some(a), Some(b)) = (got, want) {
assert_relative_eq!(a, b, epsilon = 1e-9);
}
}
}
#[test]
fn warmup_then_emits() {
// momentum + period = 3 + 2 = 5 inputs before the first value.
let mut rmi = Rmi::new(2, 3).unwrap();
let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
for (i, v) in out.iter().enumerate().take(4) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[4].is_some(), "first value at warmup_period - 1");
}
#[test]
fn pure_uptrend_is_one_hundred() {
// Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100.
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
let mut rmi = Rmi::new(5, 3).unwrap();
let last = rmi.batch(&prices).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn flat_market_is_neutral() {
// No change -> no gains and no losses -> neutral 50.
let mut rmi = Rmi::new(3, 2).unwrap();
let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn ignores_non_finite_input() {
let mut rmi = Rmi::new(2, 2).unwrap();
let ready = rmi
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(rmi.update(f64::NAN), Some(ready));
assert_eq!(rmi.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut rmi = Rmi::new(3, 2).unwrap();
rmi.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
assert!(rmi.is_ready());
rmi.reset();
assert!(!rmi.is_ready());
assert_eq!(rmi.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=40)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = Rmi::new(14, 5).unwrap();
let mut b = Rmi::new(14, 5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
+291
View File
@@ -0,0 +1,291 @@
//! RSX — Jurik-style smoothed RSI.
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// RSX — a noise-free RSI built from Jurik's three-stage smoothing cascade.
///
/// Where Wilder's [`Rsi`](crate::Rsi) smooths the up/down moves with a single
/// EMA, the RSX runs the signed price change *and* its absolute value through
/// three cascaded "double-EMA with overshoot" stages (each stage is
/// `x = 1.5·a 0.5·b`, the same lag-cancelling trick as a DEMA), then forms the
/// RSI-style ratio from the two smoothed streams:
///
/// ```text
/// f18 = 3 / (length + 2), f20 = 1 - f18
/// each stage: a = f20·a + f18·in; b = f18·a + f20·b; out = 1.5·a 0.5·b
/// v14 = stage3(signed change), v1C = stage3(|change|)
/// RSX = clamp((v14 / v1C + 1) · 50, 0, 100) (50 when v1C == 0)
/// ```
///
/// The result is an oscillator in `[0, 100]` that tracks the RSI but is far
/// smoother for the same responsiveness — it has very little of the RSI's
/// bar-to-bar jitter, so threshold crosses and divergences are cleaner. A flat
/// market returns the neutral `50`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rsx};
///
/// let mut indicator = Rsx::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rsx {
length: usize,
f18: f64,
f20: f64,
prev: Option<f64>,
count: usize,
// Signed-change cascade (three stages: a/b pairs).
s_a0: f64,
s_b0: f64,
s_a1: f64,
s_b1: f64,
s_a2: f64,
s_b2: f64,
// Absolute-change cascade.
a_a0: f64,
a_b0: f64,
a_a1: f64,
a_b1: f64,
a_a2: f64,
a_b2: f64,
last_value: Option<f64>,
}
impl Rsx {
/// Construct an RSX with the given smoothing length.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `length == 0`.
pub fn new(length: usize) -> Result<Self> {
if length == 0 {
return Err(Error::PeriodZero);
}
let f18 = 3.0 / (length as f64 + 2.0);
Ok(Self {
length,
f18,
f20: 1.0 - f18,
prev: None,
count: 0,
s_a0: 0.0,
s_b0: 0.0,
s_a1: 0.0,
s_b1: 0.0,
s_a2: 0.0,
s_b2: 0.0,
a_a0: 0.0,
a_b0: 0.0,
a_a1: 0.0,
a_b1: 0.0,
a_a2: 0.0,
a_b2: 0.0,
last_value: None,
})
}
/// Configured length.
pub const fn length(&self) -> usize {
self.length
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
/// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place
/// and returns `1.5·a 0.5·b`.
fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 {
*a = self.f20 * *a + self.f18 * input;
*b = self.f18 * *a + self.f20 * *b;
1.5 * *a - 0.5 * *b
}
}
impl Indicator for Rsx {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
if !price.is_finite() {
return self.last_value;
}
let Some(prev) = self.prev else {
self.prev = Some(price);
return None;
};
self.prev = Some(price);
let change = price - prev;
// Signed-change cascade.
let (mut sa0, mut sb0) = (self.s_a0, self.s_b0);
let v_c = self.stage(&mut sa0, &mut sb0, change);
self.s_a0 = sa0;
self.s_b0 = sb0;
let (mut sa1, mut sb1) = (self.s_a1, self.s_b1);
let v_10 = self.stage(&mut sa1, &mut sb1, v_c);
self.s_a1 = sa1;
self.s_b1 = sb1;
let (mut sa2, mut sb2) = (self.s_a2, self.s_b2);
let v_14 = self.stage(&mut sa2, &mut sb2, v_10);
self.s_a2 = sa2;
self.s_b2 = sb2;
// Absolute-change cascade.
let abs = change.abs();
let (mut aa0, mut ab0) = (self.a_a0, self.a_b0);
let v_c1 = self.stage(&mut aa0, &mut ab0, abs);
self.a_a0 = aa0;
self.a_b0 = ab0;
let (mut aa1, mut ab1) = (self.a_a1, self.a_b1);
let v_18 = self.stage(&mut aa1, &mut ab1, v_c1);
self.a_a1 = aa1;
self.a_b1 = ab1;
let (mut aa2, mut ab2) = (self.a_a2, self.a_b2);
let v_1c = self.stage(&mut aa2, &mut ab2, v_18);
self.a_a2 = aa2;
self.a_b2 = ab2;
let v4 = if v_1c > 0.0 {
(v_14 / v_1c + 1.0) * 50.0
} else {
50.0
};
let rsx = v4.clamp(0.0, 100.0);
self.count += 1;
self.last_value = Some(rsx);
if self.count >= self.length {
Some(rsx)
} else {
None
}
}
fn reset(&mut self) {
*self = Self::new(self.length).expect("length already validated");
}
fn warmup_period(&self) -> usize {
// One input to seed `prev`, then `length` changes to settle the cascade.
self.length + 1
}
fn is_ready(&self) -> bool {
self.count >= self.length
}
fn name(&self) -> &'static str {
"RSX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_length() {
assert!(matches!(Rsx::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessors `length` + `value` and the Indicator-impl
/// `warmup_period` + `name`.
#[test]
fn accessors_and_metadata() {
let rsx = Rsx::new(14).unwrap();
assert_eq!(rsx.length(), 14);
assert_eq!(rsx.value(), None);
assert_eq!(rsx.warmup_period(), 15);
assert_eq!(rsx.name(), "RSX");
}
#[test]
fn warmup_then_emits() {
let mut rsx = Rsx::new(3).unwrap();
// 1 input seeds prev; then 3 changes settle -> first Some on input 4.
assert_eq!(rsx.update(10.0), None);
assert_eq!(rsx.update(11.0), None);
assert_eq!(rsx.update(12.0), None);
assert!(rsx.update(13.0).is_some());
}
#[test]
fn flat_market_is_neutral() {
// No movement -> absolute cascade is zero -> neutral 50.
let mut rsx = Rsx::new(5).unwrap();
let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_stays_in_range() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0)
.collect();
let mut rsx = Rsx::new(14).unwrap();
for v in rsx.batch(&prices).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]");
}
}
#[test]
fn strong_uptrend_is_high() {
// A sustained rise drives RSX well above the neutral 50.
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
let mut rsx = Rsx::new(14).unwrap();
let last = rsx.batch(&prices).into_iter().flatten().last().unwrap();
assert!(
last > 80.0,
"strong uptrend should push RSX high, got {last}"
);
}
#[test]
fn ignores_non_finite_input() {
let mut rsx = Rsx::new(3).unwrap();
let ready = rsx
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
.into_iter()
.flatten()
.last()
.unwrap();
assert_eq!(rsx.update(f64::NAN), Some(ready));
assert_eq!(rsx.update(f64::INFINITY), Some(ready));
}
#[test]
fn reset_clears_state() {
let mut rsx = Rsx::new(5).unwrap();
rsx.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
assert!(rsx.is_ready());
rsx.reset();
assert!(!rsx.is_ready());
assert_eq!(rsx.update(1.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=60)
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
.collect();
let mut a = Rsx::new(14).unwrap();
let mut b = Rsx::new(14).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,273 @@
//! Sine-Weighted Moving Average (SWMA).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Sine-Weighted Moving Average — a windowed average whose weights follow one
/// half-cycle of a sine wave.
///
/// Over the last `period` inputs the weight of the value at position
/// `i = 0, 1, …, period 1` (oldest to newest) is
///
/// ```text
/// w_i = sin(π · (i + 1) / (period + 1))
/// SWMA = Σ (w_i · value_i) / Σ w_i
/// ```
///
/// The window is symmetric: weights rise to a peak in the middle of the window
/// and fall off at both ends, so the central observations dominate while the
/// extremes are de-emphasised. Every weight is strictly positive because the
/// argument `(i + 1) / (period + 1)` lies in the open interval `(0, 1)`, so the
/// normaliser is always non-zero.
///
/// Each `update` is O(`period`): the fixed weight vector is dotted with the
/// trailing window, mirroring the way [`Alma`](crate::Alma) recomputes its
/// Gaussian weights. `period == 1` collapses to a pass-through
/// (`w_0 = sin(π/2) = 1`).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, SineWeightedMa};
///
/// let mut indicator = SineWeightedMa::new(5).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SineWeightedMa {
period: usize,
window: VecDeque<f64>,
/// Sine weights for positions `0..period` (oldest to newest), constant in
/// `period`.
weights: Vec<f64>,
weights_total: f64,
}
impl SineWeightedMa {
/// Construct a new sine-weighted moving average over `period` inputs.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
let denom = period as f64 + 1.0;
let weights: Vec<f64> = (0..period)
.map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin())
.collect();
let weights_total = weights.iter().sum();
Ok(Self {
period,
window: VecDeque::with_capacity(period),
weights,
weights_total,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if the window is full.
pub fn value(&self) -> Option<f64> {
if self.window.len() == self.period {
let dot: f64 = self
.window
.iter()
.zip(&self.weights)
.map(|(v, w)| v * w)
.sum();
Some(dot / self.weights_total)
} else {
None
}
}
}
impl Indicator for SineWeightedMa {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.value();
}
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(input);
self.value()
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"SWMA"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Reference implementation: explicit sine-weighted average over a window.
fn swma_naive(prices: &[f64], period: usize) -> Vec<Option<f64>> {
let denom = period as f64 + 1.0;
let weights: Vec<f64> = (0..period)
.map(|i| (std::f64::consts::PI * (i as f64 + 1.0) / denom).sin())
.collect();
let total: f64 = weights.iter().sum();
prices
.iter()
.enumerate()
.map(|(i, _)| {
if i + 1 < period {
None
} else {
let window = &prices[i + 1 - period..=i];
let dot: f64 = window.iter().zip(&weights).map(|(v, w)| v * w).sum();
Some(dot / total)
}
})
.collect()
}
#[test]
fn new_rejects_zero_period() {
assert!(matches!(SineWeightedMa::new(0), Err(Error::PeriodZero)));
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let swma = SineWeightedMa::new(7).unwrap();
assert_eq!(swma.period(), 7);
assert_eq!(swma.warmup_period(), 7);
assert_eq!(swma.name(), "SWMA");
}
#[test]
fn warmup_returns_none() {
let mut swma = SineWeightedMa::new(3).unwrap();
assert_eq!(swma.update(1.0), None);
assert_eq!(swma.update(2.0), None);
// SWMA(3): weights sin(pi/4), sin(pi/2), sin(3pi/4) = [√½, 1, √½].
// Over [1,2,3]: (√½·1 + 1·2 + √½·3) / (√½ + 1 + √½).
let s = std::f64::consts::FRAC_1_SQRT_2;
let total = s + 1.0 + s;
let want = (s * 1.0 + 1.0 * 2.0 + s * 3.0) / total;
assert_relative_eq!(swma.update(3.0).unwrap(), want, epsilon = 1e-12);
}
#[test]
fn symmetric_weights_give_midpoint_on_linear_window() {
// For a perfectly linear window the symmetric weighting reproduces the
// arithmetic centre of the window.
let mut swma = SineWeightedMa::new(5).unwrap();
let v = swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert_relative_eq!(v[4].unwrap(), 3.0, epsilon = 1e-12);
}
#[test]
fn period_one_is_pass_through() {
let mut swma = SineWeightedMa::new(1).unwrap();
assert_relative_eq!(swma.update(5.5).unwrap(), 5.5, epsilon = 1e-12);
assert_relative_eq!(swma.update(7.5).unwrap(), 7.5, epsilon = 1e-12);
}
#[test]
fn matches_naive_over_inputs() {
let prices: Vec<f64> = (1..=30).map(|i| f64::from(i) * 1.7 - 5.0).collect();
let mut swma = SineWeightedMa::new(7).unwrap();
let got = swma.batch(&prices);
let want = swma_naive(&prices, 7);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(g.is_some(), w.is_some(), "warmup mismatch at index {i}");
if let (Some(a), Some(b)) = (g, w) {
assert_relative_eq!(*a, *b, epsilon = 1e-9);
}
}
}
#[test]
fn reset_clears_state() {
let mut swma = SineWeightedMa::new(4).unwrap();
swma.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
assert!(swma.is_ready());
swma.reset();
assert!(!swma.is_ready());
assert_eq!(swma.update(10.0), None);
}
#[test]
fn batch_equals_streaming() {
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 0.5).collect();
let mut a = SineWeightedMa::new(5).unwrap();
let mut b = SineWeightedMa::new(5).unwrap();
assert_eq!(
a.batch(&prices),
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
);
}
#[test]
fn ignores_non_finite_input_but_keeps_state() {
let mut swma = SineWeightedMa::new(3).unwrap();
swma.update(1.0);
swma.update(2.0);
let ready = swma.update(3.0).expect("SWMA(3) ready after three inputs");
assert_eq!(swma.update(f64::NAN), Some(ready));
assert_eq!(swma.update(f64::INFINITY), Some(ready));
// The window still holds 1, 2, 3 -> next real input slides it to 2, 3, 4.
let s = std::f64::consts::FRAC_1_SQRT_2;
let total = s + 1.0 + s;
let want = (s * 2.0 + 1.0 * 3.0 + s * 4.0) / total;
assert_relative_eq!(swma.update(4.0).unwrap(), want, epsilon = 1e-12);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config::with_cases(48))]
#[test]
fn proptest_matches_naive(
period in 1usize..15,
prices in proptest::collection::vec(-500.0_f64..500.0, 0..120),
) {
let mut swma = SineWeightedMa::new(period).unwrap();
let got = swma.batch(&prices);
let want = swma_naive(&prices, period);
proptest::prop_assert_eq!(got.len(), want.len());
for (g, w) in got.iter().zip(want.iter()) {
match (g, w) {
(None, None) => {}
(Some(a), Some(b)) => proptest::prop_assert!(
(a - b).abs() < 1e-7,
"got={a} want={b}"
),
_ => proptest::prop_assert!(false, "warmup mismatch"),
}
}
}
}
}
@@ -0,0 +1,231 @@
//! Stochastic CCI — a stochastic oscillator applied to the CCI.
use std::collections::VecDeque;
use crate::error::Result;
use crate::indicators::cci::Cci;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Stochastic CCI — the stochastic oscillator computed over the
/// [`Cci`](crate::Cci) instead of price.
///
/// The CCI is unbounded and spends most of its time inside `±100`, which makes
/// fixed overbought/oversold lines awkward. Running a stochastic over the CCI
/// re-scales it to `[0, 100]` relative to its own recent range, turning it into
/// a bounded, self-normalising momentum oscillator:
///
/// ```text
/// cci = CCI(typical price, period)
/// %K = 100 * (cci - lowest(cci, period)) / (highest(cci, period) - lowest(cci, period))
/// ```
///
/// The same `period` is used for the CCI and the stochastic lookback. When the
/// CCI range over the window is zero (a flat market, where the CCI is pinned at
/// `0`) the oscillator returns the neutral `50`. The first value lands after
/// `2·period 1` bars: `period` to seed the CCI, then `period` CCI values to
/// fill the stochastic window.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, StochasticCci, Indicator};
///
/// let mut sc = StochasticCci::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
/// last = sc.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct StochasticCci {
period: usize,
cci: Cci,
/// The last `period` CCI values.
window: VecDeque<f64>,
}
impl StochasticCci {
/// Construct a Stochastic CCI with the given period (shared by the CCI and
/// the stochastic lookback).
///
/// # Errors
///
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
cci: Cci::new(period)?,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for StochasticCci {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let cci = self.cci.update(candle)?;
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(cci);
if self.window.len() < self.period {
return None;
}
let mut lo = f64::MAX;
let mut hi = f64::MIN;
for &v in &self.window {
if v < lo {
lo = v;
}
if v > hi {
hi = v;
}
}
let range = hi - lo;
if range == 0.0 {
return Some(50.0);
}
// Ratio first, then scale: `100 * x / x` can round to 100.0000…1.
Some(100.0 * ((cci - lo) / range))
}
fn reset(&mut self) {
self.cci.reset();
self.window.clear();
}
fn warmup_period(&self) -> usize {
// CCI seeds at `period`, then `period` CCI values fill the stochastic window.
2 * self.period - 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"StochasticCCI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64) -> Candle {
Candle::new(close, high, low, close, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(StochasticCci::new(0).is_err());
}
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
/// + `name`.
#[test]
fn accessors_and_metadata() {
let sc = StochasticCci::new(14).unwrap();
assert_eq!(sc.period(), 14);
assert_eq!(sc.warmup_period(), 27);
assert_eq!(sc.name(), "StochasticCCI");
}
#[test]
fn first_emission_matches_warmup_period() {
let bars: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0;
candle(base + 1.0, base - 1.0, base)
})
.collect();
let mut sc = StochasticCci::new(5).unwrap();
let out = sc.batch(&bars);
let warmup = sc.warmup_period();
assert_eq!(warmup, 9);
for (i, v) in out.iter().enumerate().take(warmup - 1) {
assert!(v.is_none(), "index {i} must be None during warmup");
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn bounded_zero_to_hundred() {
let bars: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0;
candle(base + 2.0, base - 2.0, base)
})
.collect();
let mut sc = StochasticCci::new(9).unwrap();
for v in sc.batch(&bars).into_iter().flatten() {
assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]");
}
}
#[test]
fn flat_market_is_neutral() {
// Constant candles -> CCI pinned at 0 -> zero range -> neutral 50.
let mut sc = StochasticCci::new(4).unwrap();
let bars = vec![candle(10.0, 10.0, 10.0); 20];
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn highest_cci_in_window_is_hundred() {
// When the latest CCI is the window maximum, %K must be 100.
// A long rise then makes the final CCI the highest in its window.
let mut bars: Vec<Candle> = (0..20)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect();
// Strong final push so the last CCI tops its window.
bars.push(candle(100.0, 98.0, 100.0));
let mut sc = StochasticCci::new(5).unwrap();
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut sc = StochasticCci::new(5).unwrap();
sc.batch(
&(0..30)
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
.collect::<Vec<_>>(),
);
assert!(sc.is_ready());
sc.reset();
assert!(!sc.is_ready());
assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let bars: Vec<Candle> = (0..60)
.map(|i| {
let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0;
candle(base + 1.5, base - 1.5, base)
})
.collect();
let mut a = StochasticCci::new(9).unwrap();
let mut b = StochasticCci::new(9).unwrap();
assert_eq!(
a.batch(&bars),
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,218 @@
//! Trend Strength Index — the signed coefficient of determination of a linear
//! regression of price against time.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Trend Strength Index: fits an ordinary-least-squares line to the last
/// `period` prices against their bar index and reports the coefficient of
/// determination `r^2`, signed by the slope of the fit.
///
/// ```text
/// regress y = close on x = 0..period-1
/// r^2 = (n·Σxy Σx·Σy)^2 / [ (n·Σx² (Σx)²)(n·Σy² (Σy)²) ]
/// TSI = sign(slope) · r^2 (slope sign = sign of n·Σxy Σx·Σy)
/// ```
///
/// `r^2` in `[0, 1]` measures how well a straight line explains the price over
/// the window — how *trendy* the segment is, regardless of direction. Carrying
/// the slope sign turns it into a directional reading in `[-1, 1]`: values near
/// `+1` are a strong, clean uptrend; near `-1` a strong downtrend; near `0` a
/// flat or noisy market with no linear structure. A window of constant prices
/// (zero variance in `y`) has no defined trend and returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, TrendStrengthIndex};
///
/// let mut indicator = TrendStrengthIndex::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// // A clean ramp is a perfect uptrend -> r^2 = 1.
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct TrendStrengthIndex {
period: usize,
buf: VecDeque<f64>,
}
impl TrendStrengthIndex {
/// Construct a Trend Strength Index over the given window.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`, or [`Error::InvalidPeriod`]
/// if `period == 1` (a regression needs at least two points).
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if period == 1 {
return Err(Error::InvalidPeriod {
message: "period must be >= 2 for a regression",
});
}
Ok(Self {
period,
buf: VecDeque::with_capacity(period),
})
}
/// Configured window length.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for TrendStrengthIndex {
type Input = f64;
type Output = f64;
fn update(&mut self, price: f64) -> Option<f64> {
self.buf.push_back(price);
if self.buf.len() > self.period {
self.buf.pop_front();
}
if self.buf.len() < self.period {
return None;
}
let count = self.period as f64;
let mut sum_x = 0.0;
let mut sum_xx = 0.0;
let mut sum_y = 0.0;
let mut sum_yy = 0.0;
let mut sum_xy = 0.0;
for (idx, &price) in self.buf.iter().enumerate() {
let x = idx as f64;
sum_x += x;
sum_xx += x * x;
sum_y += price;
sum_yy += price * price;
sum_xy += x * price;
}
let cov = count.mul_add(sum_xy, -(sum_x * sum_y));
let var_x = count.mul_add(sum_xx, -(sum_x * sum_x));
let var_y = count.mul_add(sum_yy, -(sum_y * sum_y));
if var_y <= 0.0 {
return Some(0.0);
}
let r2 = (cov * cov) / (var_x * var_y);
Some(if cov >= 0.0 { r2 } else { -r2 })
}
fn reset(&mut self) {
self.buf.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.buf.len() >= self.period
}
fn name(&self) -> &'static str {
"TrendStrengthIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_invalid_period() {
assert!(matches!(TrendStrengthIndex::new(0), Err(Error::PeriodZero)));
assert!(matches!(
TrendStrengthIndex::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let tsi = TrendStrengthIndex::new(20).unwrap();
assert_eq!(tsi.period(), 20);
assert_eq!(tsi.warmup_period(), 20);
assert_eq!(tsi.name(), "TrendStrengthIndex");
assert!(!tsi.is_ready());
}
#[test]
fn warmup_emits_at_period() {
let mut tsi = TrendStrengthIndex::new(4).unwrap();
let inputs: Vec<f64> = (0..6).map(f64::from).collect();
let out = tsi.batch(&inputs);
assert!(out[2].is_none());
assert!(out[3].is_some());
}
#[test]
fn perfect_uptrend_is_plus_one() {
let mut tsi = TrendStrengthIndex::new(10).unwrap();
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
let last = tsi.batch(&inputs).last().unwrap().unwrap();
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
}
#[test]
fn perfect_downtrend_is_minus_one() {
let mut tsi = TrendStrengthIndex::new(10).unwrap();
let inputs: Vec<f64> = (0..10).map(|i| 100.0 - f64::from(i)).collect();
let last = tsi.batch(&inputs).last().unwrap().unwrap();
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
}
#[test]
fn flat_market_returns_zero() {
let mut tsi = TrendStrengthIndex::new(8).unwrap();
let inputs = [42.0; 12];
let last = tsi.batch(&inputs).last().unwrap().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn noisy_trend_is_between() {
// An upward drift with noise: positive but not a perfect fit.
let mut tsi = TrendStrengthIndex::new(12).unwrap();
let inputs: Vec<f64> = (0..12)
.map(|i| f64::from(i) + if i % 2 == 0 { 0.0 } else { 3.0 })
.collect();
let last = tsi.batch(&inputs).last().unwrap().unwrap();
assert!(last > 0.0 && last < 1.0, "tsi {last} should be in (0, 1)");
}
#[test]
fn reset_clears_state() {
let mut tsi = TrendStrengthIndex::new(10).unwrap();
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
tsi.batch(&inputs);
assert!(tsi.is_ready());
tsi.reset();
assert!(!tsi.is_ready());
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut a = TrendStrengthIndex::new(15).unwrap();
let mut b = TrendStrengthIndex::new(15).unwrap();
assert_eq!(
a.batch(&inputs),
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,167 @@
//! TTM Trend — John Carter's bar-coloring trend filter.
use crate::error::Result;
use crate::indicators::sma::Sma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// TTM Trend: compares the current close to the simple moving average of the
/// recent median prices `(high + low) / 2`. A close above that reference colors
/// the bar as an uptrend (`+1.0`); a close at or below it as a downtrend
/// (`-1.0`).
///
/// ```text
/// reference = SMA((high + low) / 2, period)
/// TTM Trend = +1 if close > reference
/// -1 otherwise
/// ```
///
/// The classic TTM Trend uses the trailing six bars. The signal is a regime
/// label rather than a level: it stays `None` during warmup and then emits
/// `±1.0` on every bar.
///
/// Reference: John Carter, *Mastering the Trade*, 2005.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TtmTrend};
///
/// let mut indicator = TtmTrend::new(6).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert_eq!(last, Some(1.0));
/// ```
#[derive(Debug, Clone)]
pub struct TtmTrend {
period: usize,
sma: Sma,
}
impl TtmTrend {
/// Construct a TTM Trend over the given lookback.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
period,
sma: Sma::new(period)?,
})
}
/// Configured lookback period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for TtmTrend {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let median = f64::midpoint(candle.high, candle.low);
let reference = self.sma.update(median)?;
Some(if candle.close > reference { 1.0 } else { -1.0 })
}
fn reset(&mut self) {
self.sma.reset();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.sma.is_ready()
}
fn name(&self) -> &'static str {
"TtmTrend"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::traits::BatchExt;
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TtmTrend::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let t = TtmTrend::new(6).unwrap();
assert_eq!(t.period(), 6);
assert_eq!(t.warmup_period(), 6);
assert_eq!(t.name(), "TtmTrend");
assert!(!t.is_ready());
}
#[test]
fn warmup_then_emits() {
let mut t = TtmTrend::new(3).unwrap();
let candles: Vec<Candle> = (0..3).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
let out = t.batch(&candles);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert!(out[2].is_some());
}
#[test]
fn close_above_reference_is_uptrend() {
// Close (12) sits above the median reference (13 + 9) / 2 = 11 -> +1.
let mut t = TtmTrend::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
assert_eq!(t.batch(&candles).last().unwrap().unwrap(), 1.0);
}
#[test]
fn close_at_or_below_reference_is_downtrend() {
// Constant median 10, close equal to the reference -> not strictly above -> -1.
let mut t = TtmTrend::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
assert_eq!(t.batch(&candles).last().unwrap().unwrap(), -1.0);
}
#[test]
fn reset_clears_state() {
let mut t = TtmTrend::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
t.batch(&candles);
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40_i64)
.map(|i| {
let base = 100.0 + (i as f64 * 0.25).sin() * 4.0;
candle(base + 1.0, base - 1.0, base + (i as f64 * 0.5).cos(), i)
})
.collect();
let mut a = TtmTrend::new(6).unwrap();
let mut b = TtmTrend::new(6).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,212 @@
//! Wave PM — Cynthia Kase's peak-momentum statistic (Wickra reconstruction).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::traits::Indicator;
/// Wave PM (Peak Momentum): a `0..100` statistic that rises when the current
/// `length`-bar momentum is large relative to its own recent energy — Cynthia
/// Kase's gauge of how "peaked" the move is.
///
/// ```text
/// m = close_t - close_{t-length} (length-bar momentum)
/// energy = EMA(m^2, length) (mean squared momentum)
/// raw = 1 - exp( -m^2 / (2 * energy) ) (0 if energy == 0)
/// WavePM = 100 * EMA(raw, smoothing)
/// ```
///
/// The momentum `m` is normalised by its recent variance (`energy`): a move that
/// merely matches its typical energy sits at the baseline
/// `100·(1 e^{1/2}) ≈ 39.35`, while a momentum *spike* that exceeds recent
/// energy drives the reading toward `100`. A flat market (`m = 0`) reads `0`.
/// High readings mark a peaking, possibly exhausted move rather than a fresh one.
///
/// Kase's published `WavePM` is platform-specific; this is Wickra's faithful
/// reconstruction of its variance-normalised peak-momentum form. The exact
/// constants differ from any single vendor implementation, but the shape — flat
/// at zero, a fixed baseline on a steady trend, and saturation on an
/// acceleration — matches the indicator's intent.
///
/// Reference: Cynthia Kase, *Trading with the Odds*, 1996 (Wickra reconstruction).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, WavePm};
///
/// let mut indicator = WavePm::new(10, 3).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct WavePm {
length: usize,
smoothing: usize,
closes: VecDeque<f64>,
energy_ema: Ema,
smooth_ema: Ema,
}
impl WavePm {
/// Construct a Wave PM with the momentum `length` and the output `smoothing`
/// period.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `length == 0` or `smoothing == 0`.
pub fn new(length: usize, smoothing: usize) -> Result<Self> {
if length == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
length,
smoothing,
closes: VecDeque::with_capacity(length + 1),
energy_ema: Ema::new(length)?,
smooth_ema: Ema::new(smoothing)?,
})
}
/// Configured `(length, smoothing)`.
pub const fn periods(&self) -> (usize, usize) {
(self.length, self.smoothing)
}
}
impl Indicator for WavePm {
type Input = f64;
type Output = f64;
fn update(&mut self, close: f64) -> Option<f64> {
self.closes.push_back(close);
if self.closes.len() > self.length + 1 {
self.closes.pop_front();
}
if self.closes.len() <= self.length {
return None;
}
let oldest = *self.closes.front().unwrap_or(&close);
let momentum = close - oldest;
let energy = self.energy_ema.update(momentum * momentum)?;
let raw = if energy <= 0.0 {
0.0
} else {
1.0 - (-(momentum * momentum) / (2.0 * energy)).exp()
};
self.smooth_ema.update(raw).map(|v| v * 100.0)
}
fn reset(&mut self) {
self.closes.clear();
self.energy_ema.reset();
self.smooth_ema.reset();
}
fn warmup_period(&self) -> usize {
2 * self.length + self.smoothing - 1
}
fn is_ready(&self) -> bool {
self.smooth_ema.is_ready()
}
fn name(&self) -> &'static str {
"WavePm"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(WavePm::new(0, 3), Err(Error::PeriodZero)));
assert!(matches!(WavePm::new(10, 0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let w = WavePm::new(10, 3).unwrap();
assert_eq!(w.periods(), (10, 3));
// 2*10 + 3 - 1 = 22.
assert_eq!(w.warmup_period(), 22);
assert_eq!(w.name(), "WavePm");
assert!(!w.is_ready());
}
#[test]
fn warmup_emits_at_expected_bar() {
let mut w = WavePm::new(3, 2).unwrap();
// warmup = 2*3 + 2 - 1 = 7 -> first value at input 7 (index 6).
let inputs: Vec<f64> = (0..12).map(f64::from).collect();
let out = w.batch(&inputs);
assert!(out[5].is_none());
assert!(out[6].is_some());
}
#[test]
fn flat_market_reads_zero() {
let mut w = WavePm::new(4, 2).unwrap();
let inputs = [50.0; 20];
let last = w.batch(&inputs).last().unwrap().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn steady_trend_reads_baseline() {
// Constant-slope ramp: momentum equals its own energy every bar, so the
// reading pins to the baseline 100*(1 - e^-0.5).
let mut w = WavePm::new(10, 3).unwrap();
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
let last = w.batch(&inputs).last().unwrap().unwrap();
let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
assert_relative_eq!(last, baseline, epsilon = 1e-9);
}
#[test]
fn acceleration_reads_above_baseline() {
// A quadratic path: momentum keeps outrunning its lagged energy, so the
// reading sits above the steady-trend baseline.
let mut w = WavePm::new(10, 3).unwrap();
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i * i) * 0.1).collect();
let last = w.batch(&inputs).last().unwrap().unwrap();
let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
assert!(
last > baseline,
"accelerating wpm {last} should exceed {baseline}"
);
assert!(last <= 100.0, "wpm {last} must stay <= 100");
}
#[test]
fn reset_clears_state() {
let mut w = WavePm::new(10, 3).unwrap();
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
w.batch(&inputs);
assert!(w.is_ready());
w.reset();
assert!(!w.is_ready());
}
#[test]
fn batch_equals_streaming() {
let inputs: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
.collect();
let mut a = WavePm::new(10, 3).unwrap();
let mut b = WavePm::new(10, 3).unwrap();
assert_eq!(
a.batch(&inputs),
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+63 -58
View File
@@ -57,82 +57,87 @@ pub use derivatives::DerivativesTick;
pub use error::{Error, Result};
pub use indicators::{
AbandonedBaby, Abcd, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock,
AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma,
Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput,
Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation,
AverageDailyRange, AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram,
BalanceOfPower, Bat, BeltHold, Beta, BetaNeutralSpread, BodySizePct, BollingerBands,
BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, Butterfly,
CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo,
ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput,
ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput,
CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib,
AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput,
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow,
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji,
DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy, FallingThreeMethods,
Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean,
FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, Gartley,
GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
GarmanKlassVolatility, Gartley, GatorOscillator, GeneralizedDema, GeometricMa, GoldenPocket,
GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami,
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform,
InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama,
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu,
MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice,
MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio,
KalmanHedgeRatioOutput, Kama, KasePermissionStochastic, KellyCriterion, Keltner, KeltnerOutput,
Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom,
LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine,
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput,
Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow,
MaxDrawdown, McClellanOscillator, McClellanSummationIndex, McGinleyDynamic,
MedianAbsoluteDeviation, MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi,
MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared,
RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel,
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure,
RollingCorrelation, RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness,
Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient,
SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother,
PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo, ProfitFactor, Psar,
Pvi, Qqe, QqeOutput, Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100,
RogersSatchellVolatility, RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr,
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi,
RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow,
SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap, Shark, SharpeRatio,
ShootingStar, ShortLine, SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands,
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput, SuperSmoother,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix,
TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows,
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile,
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
Zlema, FAMILIES, T3,
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
TripleTopBottom, Trix, TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend,
TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver,
UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop,
VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **396 indicators** across
- A per-indicator deep dive for every one of the **420 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.5.4",
"version": "0.5.7",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.4",
"wickra-darwin-x64": "0.5.4",
"wickra-linux-arm64-gnu": "0.5.4",
"wickra-linux-x64-gnu": "0.5.4",
"wickra-win32-arm64-msvc": "0.5.4",
"wickra-win32-x64-msvc": "0.5.4"
"wickra-darwin-arm64": "0.5.7",
"wickra-darwin-x64": "0.5.7",
"wickra-linux-arm64-gnu": "0.5.7",
"wickra-linux-x64-gnu": "0.5.7",
"wickra-win32-arm64-msvc": "0.5.7",
"wickra-win32-x64-msvc": "0.5.7"
}
},
"node_modules/wickra": {
+27 -1
View File
@@ -14,7 +14,7 @@
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
/// Drive a single streaming + batch run through one scalar indicator. Marked
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
@@ -43,6 +43,13 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| Dema::new(14).unwrap(), &data);
drive(|| Tema::new(14).unwrap(), &data);
drive(|| Hma::new(14).unwrap(), &data);
drive(|| SineWeightedMa::new(14).unwrap(), &data);
drive(|| GeometricMa::new(14).unwrap(), &data);
drive(|| Ehma::new(9).unwrap(), &data);
drive(|| MedianMa::new(14).unwrap(), &data);
drive(|| AdaptiveLaguerreFilter::new(13).unwrap(), &data);
drive(|| GeneralizedDema::new(5, 0.7).unwrap(), &data);
drive(|| HoltWinters::new(0.2, 0.1).unwrap(), &data);
drive(|| Roc::new(14).unwrap(), &data);
drive(|| Rocp::new(14).unwrap(), &data);
drive(|| Rocr::new(14).unwrap(), &data);
@@ -60,6 +67,15 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| T3::new(14, 0.7).unwrap(), &data);
drive(|| Mom::new(14).unwrap(), &data);
drive(|| Cmo::new(14).unwrap(), &data);
drive(|| DisparityIndex::new(14).unwrap(), &data);
drive(|| FisherRsi::new(14).unwrap(), &data);
drive(|| Rsx::new(14).unwrap(), &data);
drive(|| DynamicMomentumIndex::new(14).unwrap(), &data);
drive(|| Rmi::new(14, 5).unwrap(), &data);
drive(|| DerivativeOscillator::new(14, 5, 3, 9).unwrap(), &data);
drive(|| TrendStrengthIndex::new(20).unwrap(), &data);
drive(|| PolarizedFractalEfficiency::new(10, 5).unwrap(), &data);
drive(|| WavePm::new(32, 3).unwrap(), &data);
drive(|| Tsi::new(25, 13).unwrap(), &data);
drive(|| Pmo::new(35, 20).unwrap(), &data);
drive(|| Tii::new(60, 30).unwrap(), &data);
@@ -116,6 +132,16 @@ fuzz_target!(|data: Vec<f64>| {
let _ = Kst::classic().batch(&data);
}
// QQE is scalar-input but emits `QqeOutput`, so it bypasses the generic
// `drive` helper. Streaming + batch are still both exercised.
{
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
for &x in &data {
let _ = qqe.update(x);
}
let _ = Qqe::new(14, 5, 4.236).unwrap().batch(&data);
}
// Zero-Lag MACD shares MACD's multi-output topology, so it gets the
// same hand-rolled streaming + batch drive as classic MACD below.
{
+8 -1
View File
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -87,6 +87,10 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles);
// --- Trend & Directional ---
drive(|| KasePermissionStochastic::new(9, 3).unwrap(), &candles);
drive(|| GatorOscillator::new(13, 8, 5).unwrap(), &candles);
drive(|| Qstick::new(10).unwrap(), &candles);
drive(|| TtmTrend::new(6).unwrap(), &candles);
drive(|| Adx::new(14).unwrap(), &candles);
drive(|| Adxr::new(14).unwrap(), &candles);
drive(|| PlusDm::new(14).unwrap(), &candles);
@@ -105,6 +109,9 @@ fuzz_target!(|data: Vec<f64>| {
// --- Momentum & Oscillators ---
drive(|| Cci::new(20).unwrap(), &candles);
drive(|| StochasticCci::new(14).unwrap(), &candles);
drive(|| ElderRay::new(13).unwrap(), &candles);
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);
drive(|| Rvi::new(10).unwrap(), &candles);
drive(|| Inertia::new(14, 20).unwrap(), &candles);
drive(|| Pgo::new(14).unwrap(), &candles);