Compare commits

..

2 Commits

Author SHA1 Message Date
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
33 changed files with 2545 additions and 105 deletions
+11 -1
View File
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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 +1247,8 @@ 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.5...HEAD
[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.5"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.5.4"
version = "0.5.5"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.4"
version = "0.5.5"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.4"
version = "0.5.5"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.4"
version = "0.5.5"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.4"
version = "0.5.5"
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.5"
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.5" }
thiserror = "2"
rayon = "1.10"
+5 -5
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=403" 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 403 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,14 +136,14 @@ python -m benchmarks.compare_libraries
## Indicators
396 streaming-first indicators across twenty-four families. Every one passes the
403 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 |
| 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) |
| 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 |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
@@ -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 403 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -28,6 +28,13 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
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),
+63
View File
@@ -872,6 +872,51 @@ 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 JumpIndicatorNode = JumpIndicator
export declare class JumpIndicator {
constructor(period: number, threshold: number)
@@ -1677,6 +1722,24 @@ 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 TsiNode = TSI
export declare class TSI {
constructor(long: number, short: number)
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.5",
"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.5",
"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.5",
"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.5",
"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.5",
"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.5",
"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.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.4",
"version": "0.5.5",
"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.5",
"wickra-darwin-x64": "0.5.5",
"wickra-linux-arm64-gnu": "0.5.5",
"wickra-linux-x64-gnu": "0.5.5",
"wickra-win32-arm64-msvc": "0.5.5",
"wickra-win32-x64-msvc": "0.5.5"
}
},
"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.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.5.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.5",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.5.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.5",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.5.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.5",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.5.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.5",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.5.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.5",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.5.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.5",
"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.5",
"wickra-linux-arm64-gnu": "0.5.5",
"wickra-darwin-x64": "0.5.5",
"wickra-darwin-arm64": "0.5.5",
"wickra-win32-x64-msvc": "0.5.5",
"wickra-win32-arm64-msvc": "0.5.5"
},
"scripts": {
"build": "napi build --platform --release",
+83
View File
@@ -197,6 +197,15 @@ 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
);
#[napi(js_name = "JumpIndicator")]
pub struct JumpIndicatorNode {
inner: wc::JumpIndicator,
@@ -3794,6 +3803,80 @@ 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
}
}
// ============================== 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.5"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+14
View File
@@ -25,6 +25,13 @@ from __future__ import annotations
from ._wickra import (
__version__,
HoltWinters,
GD,
AdaptiveLaguerre,
MedianMA,
EHMA,
GMA,
SWMA,
Expectancy,
WinRate,
RegimeLabel,
@@ -449,6 +456,13 @@ from ._wickra import (
)
__all__ = [
"HoltWinters",
"GD",
"AdaptiveLaguerre",
"MedianMA",
"EHMA",
"GMA",
"SWMA",
"Expectancy",
"WinRate",
"RegimeLabel",
+375
View File
@@ -2354,6 +2354,250 @@ impl PyExpectancy {
}
}
// ============================== SineWeightedMa ==============================
#[pyclass(name = "SWMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySineWeightedMa {
inner: wc::SineWeightedMa,
}
#[pymethods]
impl PySineWeightedMa {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::SineWeightedMa::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("SWMA(period={})", self.inner.period())
}
}
// ============================== GeometricMa ==============================
#[pyclass(name = "GMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGeometricMa {
inner: wc::GeometricMa,
}
#[pymethods]
impl PyGeometricMa {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::GeometricMa::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("GMA(period={})", self.inner.period())
}
}
// ============================== Ehma ==============================
#[pyclass(name = "EHMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyEhma {
inner: wc::Ehma,
}
#[pymethods]
impl PyEhma {
#[new]
#[pyo3(signature = (period=9))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::Ehma::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("EHMA(period={})", self.inner.period())
}
}
// ============================== MedianMa ==============================
#[pyclass(name = "MedianMA", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMedianMa {
inner: wc::MedianMa,
}
#[pymethods]
impl PyMedianMa {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::MedianMa::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("MedianMA(period={})", self.inner.period())
}
}
// ============================== AdaptiveLaguerreFilter ==============================
#[pyclass(
name = "AdaptiveLaguerre",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAdaptiveLaguerreFilter {
inner: wc::AdaptiveLaguerreFilter,
}
#[pymethods]
impl PyAdaptiveLaguerreFilter {
#[new]
#[pyo3(signature = (period=13))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::AdaptiveLaguerreFilter::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let s = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(s)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("AdaptiveLaguerre(period={})", self.inner.period())
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "Stochastic", module = "wickra._wickra", skip_from_py_object)]
@@ -6197,6 +6441,130 @@ impl PyT3 {
}
}
// ============================== GD ==============================
#[pyclass(name = "GD", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyGeneralizedDema {
inner: wc::GeneralizedDema,
}
#[pymethods]
impl PyGeneralizedDema {
#[new]
#[pyo3(signature = (period, v=0.7))]
fn new(period: usize, v: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::GeneralizedDema::new(period, v).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
#[getter]
fn volume_factor(&self) -> f64 {
self.inner.volume_factor()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"GD(period={}, v={})",
self.inner.period(),
self.inner.volume_factor()
)
}
}
// ============================== HoltWinters ==============================
#[pyclass(name = "HoltWinters", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyHoltWinters {
inner: wc::HoltWinters,
}
#[pymethods]
impl PyHoltWinters {
#[new]
#[pyo3(signature = (alpha=0.2, beta=0.1))]
fn new(alpha: f64, beta: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::HoltWinters::new(alpha, beta).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
}
#[getter]
fn alpha(&self) -> f64 {
self.inner.alpha()
}
#[getter]
fn beta(&self) -> f64 {
self.inner.beta()
}
#[getter]
fn level(&self) -> Option<f64> {
self.inner.level()
}
#[getter]
fn trend(&self) -> Option<f64> {
self.inner.trend()
}
#[getter]
fn value(&self) -> Option<f64> {
self.inner.value()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"HoltWinters(alpha={}, beta={})",
self.inner.alpha(),
self.inner.beta()
)
}
}
// ============================== VWMA ==============================
#[pyclass(name = "VWMA", module = "wickra._wickra", skip_from_py_object)]
@@ -19667,6 +20035,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyTrima>()?;
m.add_class::<PyZlema>()?;
m.add_class::<PyT3>()?;
m.add_class::<PyGeneralizedDema>()?;
m.add_class::<PyHoltWinters>()?;
m.add_class::<PyVwma>()?;
m.add_class::<PyMom>()?;
m.add_class::<PyCmo>()?;
@@ -20031,5 +20401,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyRegimeLabel>()?;
m.add_class::<PyWinRate>()?;
m.add_class::<PyExpectancy>()?;
m.add_class::<PySineWeightedMa>()?;
m.add_class::<PyGeometricMa>()?;
m.add_class::<PyEhma>()?;
m.add_class::<PyMedianMa>()?;
m.add_class::<PyAdaptiveLaguerreFilter>()?;
Ok(())
}
@@ -45,6 +45,13 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(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)),
+7
View File
@@ -10213,6 +10213,13 @@ 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);
// --- 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));
}
}
+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,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,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);
}
}
+22 -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;
@@ -106,6 +107,7 @@ mod dx;
mod ease_of_movement;
mod effective_spread;
mod ehlers_stochastic;
mod ehma;
mod elder_impulse;
mod ema;
mod empirical_mode_decomposition;
@@ -138,6 +140,8 @@ mod gain_loss_ratio;
mod gap_side_by_side_white;
mod garman_klass;
mod gartley;
mod generalized_dema;
mod geometric_ma;
mod golden_pocket;
mod granger_causality;
mod gravestone_doji;
@@ -155,6 +159,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;
@@ -212,6 +217,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;
@@ -298,6 +304,7 @@ mod shooting_star;
mod short_line;
mod signed_volume;
mod sine_wave;
mod sine_weighted_ma;
mod skewness;
mod sma;
mod smi;
@@ -413,6 +420,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;
@@ -502,6 +510,7 @@ pub use dx::Dx;
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 ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
@@ -534,6 +543,8 @@ 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 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 +562,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};
@@ -608,6 +620,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;
@@ -694,6 +707,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;
@@ -830,6 +844,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Jma",
"Alligator",
"Evwma",
"SineWeightedMa",
"GeometricMa",
"Ehma",
"MedianMa",
"AdaptiveLaguerreFilter",
"GeneralizedDema",
"HoltWinters",
],
),
(
@@ -1342,6 +1363,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, 403, "FAMILIES total drifted from indicator count");
}
}
@@ -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"),
}
}
}
}
}
+49 -46
View File
@@ -57,37 +57,39 @@ 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,
HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange,
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, 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, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi,
HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave, Hikkake,
HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, 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,
@@ -97,27 +99,28 @@ pub use indicators::{
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,
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,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
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, SineWeightedMa, 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, 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,
+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 **403 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.5",
"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.5",
"wickra-darwin-x64": "0.5.5",
"wickra-linux-arm64-gnu": "0.5.5",
"wickra-linux-x64-gnu": "0.5.5",
"wickra-win32-arm64-msvc": "0.5.5",
"wickra-win32-x64-msvc": "0.5.5"
}
},
"node_modules/wickra": {
+8 -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, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, 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, Ppo, ProfitFactor, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, 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};
/// 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);