Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac8f6acf08 | |||
| 4f81222aed |
+14
-1
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.6] - 2026-06-04
|
||||
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
|
||||
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
|
||||
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
|
||||
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
|
||||
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
|
||||
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
|
||||
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
|
||||
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
|
||||
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
|
||||
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
|
||||
|
||||
## [0.5.5] - 2026-06-04
|
||||
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
|
||||
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
|
||||
@@ -1247,7 +1259,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.5...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.6...HEAD
|
||||
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
|
||||
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
|
||||
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
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.5" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.5.6" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -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=403" 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=413" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](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 403 indicators; start at the
|
||||
every one of the 413 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,7 +136,7 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
403 streaming-first indicators across twenty-four families. Every one passes the
|
||||
413 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).
|
||||
@@ -144,7 +144,7 @@ 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, 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) |
|
||||
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
|
||||
@@ -245,7 +245,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 403 indicators
|
||||
│ ├── wickra-core/ core engine + all 413 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
|
||||
@@ -28,6 +28,12 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
|
||||
RMI: () => new wickra.RMI(14, 5),
|
||||
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
|
||||
RSX: () => new wickra.RSX(14),
|
||||
FisherRSI: () => new wickra.FisherRSI(14),
|
||||
DisparityIndex: () => new wickra.DisparityIndex(14),
|
||||
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
|
||||
GD: () => new wickra.GD(5, 0.7),
|
||||
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
|
||||
@@ -334,6 +340,8 @@ const candleScalar = {
|
||||
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -416,6 +424,8 @@ const multi = {
|
||||
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
|
||||
Vendored
+98
@@ -69,6 +69,14 @@ export interface HtPhasorValue {
|
||||
inphase: number
|
||||
quadrature: number
|
||||
}
|
||||
export interface QqeValue {
|
||||
rsiMa: number
|
||||
trailingLine: number
|
||||
}
|
||||
export interface ElderRayValue {
|
||||
bullPower: number
|
||||
bearPower: number
|
||||
}
|
||||
export interface StochValue {
|
||||
k: number
|
||||
d: number
|
||||
@@ -917,6 +925,42 @@ export declare class AdaptiveLaguerre {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DisparityIndexNode = DisparityIndex
|
||||
export declare class DisparityIndex {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FisherRsiNode = FisherRSI
|
||||
export declare class FisherRSI {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RsxNode = RSX
|
||||
export declare class RSX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DynamicMomentumIndexNode = DynamicMomentumIndex
|
||||
export declare class DynamicMomentumIndex {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type JumpIndicatorNode = JumpIndicator
|
||||
export declare class JumpIndicator {
|
||||
constructor(period: number, threshold: number)
|
||||
@@ -1414,6 +1458,42 @@ export declare class HighLowRange {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochasticCciNode = StochasticCCI
|
||||
export declare class StochasticCCI {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ImiNode = IMI
|
||||
export declare class IMI {
|
||||
constructor(period: number)
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type QqeNode = QQE
|
||||
export declare class QQE {
|
||||
constructor(rsiPeriod: number, smoothing: number, factor: number)
|
||||
update(value: number): QqeValue | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ElderRayNode = ElderRay
|
||||
export declare class ElderRay {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): ElderRayValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochNode = Stochastic
|
||||
export declare class Stochastic {
|
||||
constructor(kPeriod: number, dPeriod: number)
|
||||
@@ -1740,6 +1820,24 @@ export declare class HoltWinters {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RmiNode = RMI
|
||||
export declare class RMI {
|
||||
constructor(period: number, momentum: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DerivativeOscillatorNode = DerivativeOscillator
|
||||
export declare class DerivativeOscillator {
|
||||
constructor(rsiPeriod: number, smooth1: number, smooth2: number, signalPeriod: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TsiNode = TSI
|
||||
export declare class TSI {
|
||||
constructor(long: number, short: number)
|
||||
|
||||
+11
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"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.5",
|
||||
"version": "0.5.6",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"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.5",
|
||||
"version": "0.5.6",
|
||||
"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.5",
|
||||
"version": "0.5.6",
|
||||
"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": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"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.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"
|
||||
"wickra-darwin-arm64": "0.5.6",
|
||||
"wickra-darwin-x64": "0.5.6",
|
||||
"wickra-linux-arm64-gnu": "0.5.6",
|
||||
"wickra-linux-x64-gnu": "0.5.6",
|
||||
"wickra-win32-arm64-msvc": "0.5.6",
|
||||
"wickra-win32-x64-msvc": "0.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.5.tgz",
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.6.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.5.tgz",
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.6.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.5.tgz",
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.6.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.5.tgz",
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.6.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.5.tgz",
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.6.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.5.5",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.5.tgz",
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.6.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"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.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"
|
||||
"wickra-linux-x64-gnu": "0.5.6",
|
||||
"wickra-linux-arm64-gnu": "0.5.6",
|
||||
"wickra-darwin-x64": "0.5.6",
|
||||
"wickra-darwin-arm64": "0.5.6",
|
||||
"wickra-win32-x64-msvc": "0.5.6",
|
||||
"wickra-win32-arm64-msvc": "0.5.6"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -206,6 +206,14 @@ node_scalar_indicator!(
|
||||
"AdaptiveLaguerre",
|
||||
wc::AdaptiveLaguerreFilter
|
||||
);
|
||||
node_scalar_indicator!(DisparityIndexNode, "DisparityIndex", wc::DisparityIndex);
|
||||
node_scalar_indicator!(FisherRsiNode, "FisherRSI", wc::FisherRsi);
|
||||
node_scalar_indicator!(RsxNode, "RSX", wc::Rsx);
|
||||
node_scalar_indicator!(
|
||||
DynamicMomentumIndexNode,
|
||||
"DynamicMomentumIndex",
|
||||
wc::DynamicMomentumIndex
|
||||
);
|
||||
#[napi(js_name = "JumpIndicator")]
|
||||
pub struct JumpIndicatorNode {
|
||||
inner: wc::JumpIndicator,
|
||||
@@ -2051,6 +2059,242 @@ impl HighLowRangeNode {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "StochasticCCI")]
|
||||
pub struct StochasticCciNode {
|
||||
inner: wc::StochasticCci,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl StochasticCciNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::StochasticCci::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], 0.0)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "IMI")]
|
||||
pub struct ImiNode {
|
||||
inner: wc::IntradayMomentumIndex,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ImiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::IntradayMomentumIndex::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd4(open, high, low, close)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: Vec<f64>,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = open.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd4(open[i], high[i], low[i], close[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct QqeValue {
|
||||
pub rsi_ma: f64,
|
||||
pub trailing_line: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "QQE")]
|
||||
pub struct QqeNode {
|
||||
inner: wc::Qqe,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl QqeNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(rsi_period: u32, smoothing: u32, factor: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Qqe::new(rsi_period as usize, smoothing as usize, factor)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<QqeValue> {
|
||||
self.inner.update(value).map(|o| QqeValue {
|
||||
rsi_ma: o.rsi_ma,
|
||||
trailing_line: o.trailing_line,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; prices.len() * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.rsi_ma;
|
||||
out[i * 2 + 1] = o.trailing_line;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ElderRayValue {
|
||||
pub bull_power: f64,
|
||||
pub bear_power: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "ElderRay")]
|
||||
pub struct ElderRayNode {
|
||||
inner: wc::ElderRay,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ElderRayNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderRay::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<ElderRayValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| ElderRayValue {
|
||||
bull_power: o.bull_power,
|
||||
bear_power: o.bear_power,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 2] = o.bull_power;
|
||||
out[i * 2 + 1] = o.bear_power;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct StochValue {
|
||||
pub k: f64,
|
||||
@@ -3877,6 +4121,91 @@ impl HoltWintersNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RMI ==============================
|
||||
|
||||
#[napi(js_name = "RMI")]
|
||||
pub struct RmiNode {
|
||||
inner: wc::Rmi,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl RmiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, momentum: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rmi::new(period as usize, momentum as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== DerivativeOscillator ==============================
|
||||
|
||||
#[napi(js_name = "DerivativeOscillator")]
|
||||
pub struct DerivativeOscillatorNode {
|
||||
inner: wc::DerivativeOscillator,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DerivativeOscillatorNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(
|
||||
rsi_period: u32,
|
||||
smooth1: u32,
|
||||
smooth2: u32,
|
||||
signal_period: u32,
|
||||
) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DerivativeOscillator::new(
|
||||
rsi_period as usize,
|
||||
smooth1 as usize,
|
||||
smooth2 as usize,
|
||||
signal_period as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== TSI ==============================
|
||||
|
||||
#[napi(js_name = "TSI")]
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.5.5"
|
||||
version = "0.5.6"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -25,6 +25,16 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
QQE,
|
||||
IMI,
|
||||
ElderRay,
|
||||
DerivativeOscillator,
|
||||
RMI,
|
||||
StochasticCCI,
|
||||
DynamicMomentumIndex,
|
||||
RSX,
|
||||
FisherRSI,
|
||||
DisparityIndex,
|
||||
HoltWinters,
|
||||
GD,
|
||||
AdaptiveLaguerre,
|
||||
@@ -456,6 +466,16 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"QQE",
|
||||
"IMI",
|
||||
"ElderRay",
|
||||
"DerivativeOscillator",
|
||||
"RMI",
|
||||
"StochasticCCI",
|
||||
"DynamicMomentumIndex",
|
||||
"RSX",
|
||||
"FisherRSI",
|
||||
"DisparityIndex",
|
||||
"HoltWinters",
|
||||
"GD",
|
||||
"AdaptiveLaguerre",
|
||||
|
||||
@@ -2598,8 +2598,471 @@ impl PyAdaptiveLaguerreFilter {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== DisparityIndex ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "DisparityIndex",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyDisparityIndex {
|
||||
inner: wc::DisparityIndex,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDisparityIndex {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=14))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DisparityIndex::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!("DisparityIndex(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== FisherRsi ==============================
|
||||
|
||||
#[pyclass(name = "FisherRSI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyFisherRsi {
|
||||
inner: wc::FisherRsi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFisherRsi {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=14))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::FisherRsi::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!("FisherRSI(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Rsx ==============================
|
||||
|
||||
#[pyclass(name = "RSX", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyRsx {
|
||||
inner: wc::Rsx,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRsx {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=14))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rsx::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 length(&self) -> usize {
|
||||
self.inner.length()
|
||||
}
|
||||
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!("RSX(length={})", self.inner.length())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== DynamicMomentumIndex ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "DynamicMomentumIndex",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyDynamicMomentumIndex {
|
||||
inner: wc::DynamicMomentumIndex,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDynamicMomentumIndex {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=14))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DynamicMomentumIndex::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!("DynamicMomentumIndex(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== StochasticCci ==============================
|
||||
|
||||
#[pyclass(name = "StochasticCCI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyStochasticCci {
|
||||
inner: wc::StochasticCci,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyStochasticCci {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=14))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::StochasticCci::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over numpy columns: high, low, close (all 1-D, equal length).
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(h.len());
|
||||
for i in 0..h.len() {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.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!("StochasticCCI(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Stochastic ==============================
|
||||
|
||||
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyImi {
|
||||
inner: wc::IntradayMomentumIndex,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyImi {
|
||||
#[new]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::IntradayMomentumIndex::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
/// Batch over open/high/low/close numpy columns (the IMI needs the open).
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
open: PyReadonlyArray1<'py, f64>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let o = open
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if o.len() != h.len() || h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"open, high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let n = o.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.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()
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass(name = "QQE", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyQqe {
|
||||
inner: wc::Qqe,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyQqe {
|
||||
#[new]
|
||||
#[pyo3(signature = (rsi_period=14, smoothing=5, factor=4.236))]
|
||||
fn new(rsi_period: usize, smoothing: usize, factor: f64) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Qqe::new(rsi_period, smoothing, factor).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `(rsi_ma, trailing_line)` or `None` during warmup.
|
||||
fn update(&mut self, value: f64) -> Option<(f64, f64)> {
|
||||
self.inner
|
||||
.update(value)
|
||||
.map(|o| (o.rsi_ma, o.trailing_line))
|
||||
}
|
||||
/// Batch over a numpy array of closes. Returns shape `(n, 2)` with columns
|
||||
/// `[rsi_ma, trailing_line]`. Warmup rows are NaN.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
prices: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let slice = prices
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let n = slice.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for (i, p) in slice.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.rsi_ma;
|
||||
out[i * 2 + 1] = o.trailing_line;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn factor(&self) -> f64 {
|
||||
self.inner.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()
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass(name = "ElderRay", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyElderRay {
|
||||
inner: wc::ElderRay,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyElderRay {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=13))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderRay::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
|
||||
let c = extract_candle(candle)?;
|
||||
Ok(self.inner.update(c).map(|o| (o.bull_power, o.bear_power)))
|
||||
}
|
||||
/// Batch over high/low/close numpy columns. Returns shape `(n, 2)` for
|
||||
/// `[bull_power, bear_power]`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
high: PyReadonlyArray1<'py, f64>,
|
||||
low: PyReadonlyArray1<'py, f64>,
|
||||
close: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let h = high
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let l = low
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let c = close
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if h.len() != l.len() || l.len() != c.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"high, low, close must be equal length",
|
||||
));
|
||||
}
|
||||
let n = h.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * 2] = o.bull_power;
|
||||
out[i * 2 + 1] = o.bear_power;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
|
||||
.expect("shape consistent")
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
#[pyclass(name = "Stochastic", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyStoch {
|
||||
@@ -6565,6 +7028,113 @@ impl PyHoltWinters {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RMI ==============================
|
||||
|
||||
#[pyclass(name = "RMI", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyRmi {
|
||||
inner: wc::Rmi,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRmi {
|
||||
#[new]
|
||||
#[pyo3(signature = (period, momentum))]
|
||||
fn new(period: usize, momentum: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Rmi::new(period, momentum).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 momentum(&self) -> usize {
|
||||
self.inner.momentum()
|
||||
}
|
||||
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!(
|
||||
"RMI(period={}, momentum={})",
|
||||
self.inner.period(),
|
||||
self.inner.momentum()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== DerivativeOscillator ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "DerivativeOscillator",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyDerivativeOscillator {
|
||||
inner: wc::DerivativeOscillator,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDerivativeOscillator {
|
||||
#[new]
|
||||
#[pyo3(signature = (rsi_period=14, smooth1=5, smooth2=3, signal_period=9))]
|
||||
fn new(
|
||||
rsi_period: usize,
|
||||
smooth1: usize,
|
||||
smooth2: usize,
|
||||
signal_period: usize,
|
||||
) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DerivativeOscillator::new(rsi_period, smooth1, smooth2, signal_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 slice = prices
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
Ok(flatten(self.inner.batch(slice)).into_pyarray(py))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== VWMA ==============================
|
||||
|
||||
#[pyclass(name = "VWMA", module = "wickra._wickra", skip_from_py_object)]
|
||||
@@ -20001,6 +20571,9 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyHtPhasor>()?;
|
||||
m.add_class::<PyBb>()?;
|
||||
m.add_class::<PyAtr>()?;
|
||||
m.add_class::<PyImi>()?;
|
||||
m.add_class::<PyQqe>()?;
|
||||
m.add_class::<PyElderRay>()?;
|
||||
m.add_class::<PyStoch>()?;
|
||||
m.add_class::<PyObv>()?;
|
||||
m.add_class::<PyDema>()?;
|
||||
@@ -20037,6 +20610,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyT3>()?;
|
||||
m.add_class::<PyGeneralizedDema>()?;
|
||||
m.add_class::<PyHoltWinters>()?;
|
||||
m.add_class::<PyRmi>()?;
|
||||
m.add_class::<PyDerivativeOscillator>()?;
|
||||
m.add_class::<PyVwma>()?;
|
||||
m.add_class::<PyMom>()?;
|
||||
m.add_class::<PyCmo>()?;
|
||||
@@ -20406,5 +20981,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyEhma>()?;
|
||||
m.add_class::<PyMedianMa>()?;
|
||||
m.add_class::<PyAdaptiveLaguerreFilter>()?;
|
||||
m.add_class::<PyDisparityIndex>()?;
|
||||
m.add_class::<PyFisherRsi>()?;
|
||||
m.add_class::<PyRsx>()?;
|
||||
m.add_class::<PyDynamicMomentumIndex>()?;
|
||||
m.add_class::<PyStochasticCci>()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.DerivativeOscillator, (14, 5, 3, 9)),
|
||||
(ta.RMI, (14, 5)),
|
||||
(ta.DynamicMomentumIndex, (14,)),
|
||||
(ta.RSX, (14,)),
|
||||
(ta.FisherRSI, (14,)),
|
||||
(ta.DisparityIndex, (14,)),
|
||||
(ta.HoltWinters, (0.2, 0.1)),
|
||||
(ta.GD, (5, 0.7)),
|
||||
(ta.AdaptiveLaguerre, (13,)),
|
||||
@@ -157,6 +163,7 @@ SCALAR = [
|
||||
# Family 05 band/channel indicators with scalar input and multi-output.
|
||||
# `cols` is the expected number of band columns from `batch`.
|
||||
SCALAR_MULTI = {
|
||||
"Qqe": (lambda: ta.QQE(14, 5, 4.236), 2),
|
||||
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
|
||||
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
|
||||
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
|
||||
@@ -348,6 +355,7 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
# Per-bar OHLC transforms (open matters). The streaming harness feeds
|
||||
# open == close, so batch passes the close column in for open to match.
|
||||
"HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
|
||||
@@ -884,6 +892,11 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"ElderRay": (
|
||||
lambda: ta.ElderRay(13),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"FibFan": (
|
||||
lambda: ta.FibFan(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
@@ -2741,6 +2754,31 @@ def test_spread_ar1_coefficient_reference():
|
||||
out = ta.SpreadAr1Coefficient(20).batch(a, b)
|
||||
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_elder_ray_reference():
|
||||
er = ta.ElderRay(3)
|
||||
high = np.array([11.0, 13.0, 16.0])
|
||||
low = np.array([9.0, 11.0, 13.0])
|
||||
close = np.array([10.0, 12.0, 14.0])
|
||||
out = er.batch(high, low, close)
|
||||
# EMA(3) seeds at the third bar with mean close 12; bar high 16 -> bull 4,
|
||||
# low 13 -> bear 1.
|
||||
assert out[2][0] == pytest.approx(4.0)
|
||||
assert out[2][1] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_imi_reference():
|
||||
imi = ta.IMI(3)
|
||||
open_ = np.array([10.0, 11.0, 10.0])
|
||||
high = np.array([12.0, 12.0, 13.0])
|
||||
low = np.array([9.0, 9.0, 9.0])
|
||||
close = np.array([11.0, 10.0, 12.0])
|
||||
out = imi.batch(open_, high, low, close)
|
||||
# bodies +1, -1, +2 -> gain 3, loss 1 -> 100 * 3 / 4 = 75.
|
||||
assert math.isnan(out[0])
|
||||
assert math.isnan(out[1])
|
||||
assert out[2] == pytest.approx(75.0)
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -2050,6 +2050,211 @@ impl WasmHighLowRange {
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = StochasticCCI)]
|
||||
pub struct WasmStochasticCci {
|
||||
inner: wc::StochasticCci,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = StochasticCCI)]
|
||||
impl WasmStochasticCci {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmStochasticCci, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::StochasticCci::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = IMI)]
|
||||
pub struct WasmImi {
|
||||
inner: wc::IntradayMomentumIndex,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = IMI)]
|
||||
impl WasmImi {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmImi, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::IntradayMomentumIndex::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Batch over open/high/low/close arrays; `NaN` during warmup.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = open.len();
|
||||
if high.len() != n || low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("open, high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
|
||||
if let Some(v) = self.inner.update(c) {
|
||||
out[i] = v;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update over one candle's open/high/low/close.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle_ohlc(open, high, low, close)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = QQE)]
|
||||
pub struct WasmQqe {
|
||||
inner: wc::Qqe,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = QQE)]
|
||||
impl WasmQqe {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<WasmQqe, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Qqe::new(rsi_period, smoothing, factor).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[rsiMa0, trailing0, rsiMa1, trailing1, ...]`, length `2 * n`.
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let mut out = vec![f64::NAN; prices.len() * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.rsi_ma;
|
||||
out[i * 2 + 1] = o.trailing_line;
|
||||
}
|
||||
}
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ rsiMa, trailingLine }` once warm, else `null`.
|
||||
pub fn update(&mut self, value: f64) -> JsValue {
|
||||
match self.inner.update(value) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"rsiMa".into(), &o.rsi_ma.into()).ok();
|
||||
Reflect::set(&obj, &"trailingLine".into(), &o.trailing_line.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = ElderRay)]
|
||||
pub struct WasmElderRay {
|
||||
inner: wc::ElderRay,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ElderRay)]
|
||||
impl WasmElderRay {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmElderRay, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderRay::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[bull0, bear0, bull1, bear1, ...]`, length `2 * n`.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = high.len();
|
||||
if low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.bull_power;
|
||||
out[i * 2 + 1] = o.bear_power;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ bullPower, bearPower }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"bullPower".into(), &o.bull_power.into()).ok();
|
||||
Reflect::set(&obj, &"bearPower".into(), &o.bear_power.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Stochastic)]
|
||||
pub struct WasmStoch {
|
||||
inner: wc::Stochastic,
|
||||
@@ -10220,6 +10425,12 @@ wasm_scalar_indicator!(WasmMedianMa, "MedianMA", wc::MedianMa, period: usize);
|
||||
wasm_scalar_indicator!(WasmAdaptiveLaguerreFilter, "AdaptiveLaguerre", wc::AdaptiveLaguerreFilter, period: usize);
|
||||
wasm_scalar_indicator!(WasmGeneralizedDema, "GD", wc::GeneralizedDema, period: usize, v: f64);
|
||||
wasm_scalar_indicator!(WasmHoltWinters, "HoltWinters", wc::HoltWinters, alpha: f64, beta: f64);
|
||||
wasm_scalar_indicator!(WasmDisparityIndex, "DisparityIndex", wc::DisparityIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmFisherRsi, "FisherRSI", wc::FisherRsi, period: usize);
|
||||
wasm_scalar_indicator!(WasmRsx, "RSX", wc::Rsx, period: usize);
|
||||
wasm_scalar_indicator!(WasmDynamicMomentumIndex, "DynamicMomentumIndex", wc::DynamicMomentumIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmRmi, "RMI", wc::Rmi, period: usize, momentum: usize);
|
||||
wasm_scalar_indicator!(WasmDerivativeOscillator, "DerivativeOscillator", wc::DerivativeOscillator, rsi_period: usize, smooth1: usize, smooth2: usize, signal_period: usize);
|
||||
|
||||
// --- DrawdownDuration: u32 output, no constructor args ---
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Derivative Oscillator (Constance Brown).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Derivative Oscillator — Constance Brown's double-smoothed RSI histogram.
|
||||
///
|
||||
/// The RSI is smoothed twice with EMAs, then a simple moving average of that
|
||||
/// double-smoothed line is subtracted as a signal, leaving a zero-centered
|
||||
/// histogram:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi = RSI(price, rsi_period)
|
||||
/// s1 = EMA(rsi, smooth1)
|
||||
/// s2 = EMA(s1, smooth2) // double-smoothed RSI
|
||||
/// signal = SMA(s2, signal_period)
|
||||
/// DerivativeOscillator = s2 - signal
|
||||
/// ```
|
||||
///
|
||||
/// The double EMA smoothing strips the RSI's high-frequency noise, and
|
||||
/// subtracting the SMA signal removes the residual level, so the result
|
||||
/// oscillates around zero: positive (and rising) bars mark accelerating bullish
|
||||
/// momentum, negative bars bearish. Brown's defaults are `rsi_period = 14`,
|
||||
/// `smooth1 = 5`, `smooth2 = 3`, `signal_period = 9`.
|
||||
///
|
||||
/// The first value lands after `rsi_period + smooth1 + smooth2 + signal_period − 2`
|
||||
/// inputs, the point at which the whole RSI → EMA → EMA → SMA chain is seeded.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativeOscillator, Indicator};
|
||||
///
|
||||
/// let mut indicator = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..120 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DerivativeOscillator {
|
||||
rsi: Rsi,
|
||||
ema1: Ema,
|
||||
ema2: Ema,
|
||||
signal: Sma,
|
||||
warmup: usize,
|
||||
}
|
||||
|
||||
impl DerivativeOscillator {
|
||||
/// Construct a Derivative Oscillator with the RSI, two EMA smoothing, and
|
||||
/// SMA signal periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is `0`.
|
||||
pub fn new(
|
||||
rsi_period: usize,
|
||||
smooth1: usize,
|
||||
smooth2: usize,
|
||||
signal_period: usize,
|
||||
) -> Result<Self> {
|
||||
if rsi_period == 0 || smooth1 == 0 || smooth2 == 0 || signal_period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
ema1: Ema::new(smooth1)?,
|
||||
ema2: Ema::new(smooth2)?,
|
||||
signal: Sma::new(signal_period)?,
|
||||
// RSI seeds at rsi_period + 1, then each stage adds (len - 1).
|
||||
warmup: rsi_period + smooth1 + smooth2 + signal_period - 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Total warmup length (also returned by `warmup_period`).
|
||||
pub const fn warmup(&self) -> usize {
|
||||
self.warmup
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DerivativeOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let rsi = self.rsi.update(input)?;
|
||||
let s1 = self.ema1.update(rsi)?;
|
||||
let s2 = self.ema2.update(s1)?;
|
||||
let signal = self.signal.update(s2)?;
|
||||
Some(s2 - signal)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
self.ema1.reset();
|
||||
self.ema2.reset();
|
||||
self.signal.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.warmup
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.signal.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DerivativeOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_periods() {
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(0, 5, 3, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 0, 3, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 5, 0, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
DerivativeOscillator::new(14, 5, 3, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `warmup` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
// 14 + 5 + 3 + 9 - 2 = 29.
|
||||
assert_eq!(d.warmup(), 29);
|
||||
assert_eq!(d.warmup_period(), 29);
|
||||
assert_eq!(d.name(), "DerivativeOscillator");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 6.0)
|
||||
.collect();
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let out = d.batch(&prices);
|
||||
let warmup = d.warmup_period();
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first value must land at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_manual_chain() {
|
||||
// Equals RSI -> EMA -> EMA, minus the SMA signal of that line.
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let mut rsi = Rsi::new(14).unwrap();
|
||||
let mut e1 = Ema::new(5).unwrap();
|
||||
let mut e2 = Ema::new(3).unwrap();
|
||||
let mut sig = Sma::new(9).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = d.update(p);
|
||||
let want = rsi
|
||||
.update(p)
|
||||
.and_then(|r| e1.update(r))
|
||||
.and_then(|x| e2.update(x))
|
||||
.and_then(|s2| sig.update(s2).map(|s| s2 - s));
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (got, want) {
|
||||
assert_relative_eq!(a, b, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
d.batch(&(0..60).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
let mut b = DerivativeOscillator::new(14, 5, 3, 9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Disparity Index.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Disparity Index — the percentage gap between price and its moving average.
|
||||
///
|
||||
/// ```text
|
||||
/// Disparity = 100 * (price - SMA(price, period)) / SMA(price, period)
|
||||
/// ```
|
||||
///
|
||||
/// Originating in Japanese technical analysis (*kairi*), the disparity index
|
||||
/// expresses how far price has stretched from its `period`-bar simple moving
|
||||
/// average, as a percentage of that average. Positive readings mean price is
|
||||
/// above the mean (potentially overbought / strong), negative readings mean it
|
||||
/// is below (potentially oversold / weak); the magnitude measures how
|
||||
/// over-extended the move is.
|
||||
///
|
||||
/// The first output lands once the inner SMA is ready (input `period`). If the
|
||||
/// moving average is exactly zero the gap percentage is undefined and the index
|
||||
/// returns `0.0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DisparityIndex, Indicator};
|
||||
///
|
||||
/// let mut indicator = DisparityIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DisparityIndex {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl DisparityIndex {
|
||||
/// Construct a disparity index over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DisparityIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let mean = self.sma.update(input)?;
|
||||
if mean == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(100.0 * (input - mean) / mean)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DisparityIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(DisparityIndex::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let di = DisparityIndex::new(14).unwrap();
|
||||
assert_eq!(di.period(), 14);
|
||||
assert_eq!(di.warmup_period(), 14);
|
||||
assert_eq!(di.name(), "DisparityIndex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_known_value() {
|
||||
// SMA(3) of [2, 4, 6] = 4; price 6 -> 100 * (6 - 4) / 4 = 50.
|
||||
let mut di = DisparityIndex::new(3).unwrap();
|
||||
assert_eq!(di.update(2.0), None);
|
||||
assert_eq!(di.update(4.0), None);
|
||||
assert_relative_eq!(di.update(6.0).unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_is_zero() {
|
||||
// Price equals its own mean -> zero disparity.
|
||||
let mut di = DisparityIndex::new(5).unwrap();
|
||||
for v in di.batch(&[42.0; 20]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_when_below_mean() {
|
||||
// SMA(3) of [10, 8, 6] = 8; price 6 -> 100 * (6 - 8) / 8 = -25.
|
||||
let mut di = DisparityIndex::new(3).unwrap();
|
||||
let v = di.batch(&[10.0, 8.0, 6.0]);
|
||||
assert_relative_eq!(v[2].unwrap(), -25.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_mean_returns_zero() {
|
||||
// A window summing to zero (mean 0) makes the percentage undefined; the
|
||||
// index returns 0.0 rather than a non-finite value.
|
||||
let mut di = DisparityIndex::new(2).unwrap();
|
||||
assert_eq!(di.update(-3.0), None);
|
||||
// SMA(2) of [-3, 3] = 0 -> guarded to 0.0.
|
||||
assert_relative_eq!(di.update(3.0).unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut di = DisparityIndex::new(5).unwrap();
|
||||
di.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(di.is_ready());
|
||||
di.reset();
|
||||
assert!(!di.is_ready());
|
||||
assert_eq!(di.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=30)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.3).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DisparityIndex::new(7).unwrap();
|
||||
let mut b = DisparityIndex::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! Dynamic Momentum Index (Chande's volatility-adaptive RSI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::indicators::std_dev::StdDev;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
// Chande's definitional constants.
|
||||
const STD_PERIOD: usize = 5; // volatility window
|
||||
const STD_AVG_PERIOD: usize = 10; // smoothing of the volatility
|
||||
const MIN_PERIOD: usize = 5; // fastest RSI lookback
|
||||
const MAX_PERIOD: usize = 30; // slowest RSI lookback
|
||||
|
||||
/// Dynamic Momentum Index — Tushar Chande's RSI whose lookback shrinks in
|
||||
/// volatile markets and lengthens in calm ones.
|
||||
///
|
||||
/// A standard RSI uses a fixed period; the DMI varies it from the recent
|
||||
/// volatility so the oscillator stays responsive when the market is fast and
|
||||
/// smooth when it is quiet:
|
||||
///
|
||||
/// ```text
|
||||
/// vol = StdDev(close, 5)
|
||||
/// vol_avg = SMA(vol, 10)
|
||||
/// Vi = vol / vol_avg (volatility index)
|
||||
/// td = clamp(round(period / Vi), 5, 30) (dynamic lookback)
|
||||
/// avg_gain, avg_loss = simple means of the last `td` price changes
|
||||
/// DMI = 100 * avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// High volatility (`Vi > 1`) shortens `td` toward `5` (faster); low volatility
|
||||
/// lengthens it toward `30` (slower). The averages of gains and losses are
|
||||
/// simple means over the last `td` changes (not Wilder-smoothed), recomputed as
|
||||
/// the window length flexes. Output is bounded in `[0, 100]`; a flat market
|
||||
/// returns the neutral `50`.
|
||||
///
|
||||
/// The first value lands after `MAX_PERIOD + 1 = 31` inputs, so the change
|
||||
/// buffer always holds enough history for any dynamic lookback up to `30`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DynamicMomentumIndex, Indicator};
|
||||
///
|
||||
/// let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = dmi.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DynamicMomentumIndex {
|
||||
period: usize,
|
||||
vol: StdDev,
|
||||
vol_avg: Sma,
|
||||
prev_close: Option<f64>,
|
||||
/// The last `MAX_PERIOD` price changes, oldest at the front.
|
||||
changes: VecDeque<f64>,
|
||||
last_vol_avg: Option<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl DynamicMomentumIndex {
|
||||
/// Construct a DMI with the given base RSI period (Chande uses 14).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
vol: StdDev::new(STD_PERIOD)?,
|
||||
vol_avg: Sma::new(STD_AVG_PERIOD)?,
|
||||
prev_close: None,
|
||||
changes: VecDeque::with_capacity(MAX_PERIOD),
|
||||
last_vol_avg: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured base period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// Dynamic lookback for the current volatility, clamped to `[5, 30]`.
|
||||
fn dynamic_period(&self, vol: f64, vol_avg: f64) -> usize {
|
||||
if vol_avg <= 0.0 || vol <= 0.0 {
|
||||
// No measurable volatility -> slowest (calmest) lookback.
|
||||
return MAX_PERIOD;
|
||||
}
|
||||
let vi = vol / vol_avg;
|
||||
let td = (self.period as f64 / vi).round();
|
||||
// td is finite and positive here; clamp into the valid band.
|
||||
(td as usize).clamp(MIN_PERIOD, MAX_PERIOD)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DynamicMomentumIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
// Track the smoothed volatility on every close.
|
||||
if let Some(v) = self.vol.update(input) {
|
||||
self.last_vol_avg = self.vol_avg.update(v);
|
||||
}
|
||||
|
||||
// Record the price change.
|
||||
if let Some(prev) = self.prev_close {
|
||||
let change = input - prev;
|
||||
if self.changes.len() == MAX_PERIOD {
|
||||
self.changes.pop_front();
|
||||
}
|
||||
self.changes.push_back(change);
|
||||
}
|
||||
self.prev_close = Some(input);
|
||||
|
||||
let vol = self.vol.value()?;
|
||||
let vol_avg = self.last_vol_avg?;
|
||||
if self.changes.len() < MAX_PERIOD {
|
||||
return None;
|
||||
}
|
||||
|
||||
let td = self.dynamic_period(vol, vol_avg);
|
||||
// Average gains and losses over the last `td` changes.
|
||||
let mut sum_gain = 0.0;
|
||||
let mut sum_loss = 0.0;
|
||||
for &c in self.changes.iter().skip(MAX_PERIOD - td) {
|
||||
if c > 0.0 {
|
||||
sum_gain += c;
|
||||
} else if c < 0.0 {
|
||||
sum_loss -= c;
|
||||
}
|
||||
}
|
||||
let denom = sum_gain + sum_loss;
|
||||
let v = if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
|
||||
100.0 * (sum_gain / denom)
|
||||
};
|
||||
self.last_value = Some(v);
|
||||
Some(v)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.vol.reset();
|
||||
self.vol_avg.reset();
|
||||
self.prev_close = None;
|
||||
self.changes.clear();
|
||||
self.last_vol_avg = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The change buffer (MAX_PERIOD changes => MAX_PERIOD + 1 inputs) is the
|
||||
// binding constraint; the volatility chain (5 + 10 - 1 = 14) is shorter.
|
||||
MAX_PERIOD + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DynamicMomentumIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
DynamicMomentumIndex::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `period` + `value` and the Indicator-impl
|
||||
/// `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(dmi.period(), 14);
|
||||
assert_eq!(dmi.value(), None);
|
||||
assert_eq!(dmi.warmup_period(), 31);
|
||||
assert_eq!(dmi.name(), "DynamicMomentumIndex");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let prices: Vec<f64> = (0..50)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 6.0)
|
||||
.collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let out = dmi.batch(&prices);
|
||||
for (i, v) in out.iter().enumerate().take(30) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[30].is_some(), "first value at warmup_period - 1 = 30");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
// Every change positive -> avg_loss 0 -> 100, regardless of dynamic period.
|
||||
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let last = dmi.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// Constant prices: no volatility (dynamic period -> max) and no changes
|
||||
// -> neutral 50.
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let last = dmi.batch(&[42.0; 50]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_in_range() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 10.0 + (f64::from(i) * 0.07).cos() * 4.0)
|
||||
.collect();
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
for v in dmi.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "DMI {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_volatility_shortens_period() {
|
||||
let dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
// Vi = 2 (vol twice its average) -> td = round(14 / 2) = 7.
|
||||
assert_eq!(dmi.dynamic_period(2.0, 1.0), 7);
|
||||
// Vi = 0.5 (calm) -> td = round(14 / 0.5) = 28.
|
||||
assert_eq!(dmi.dynamic_period(0.5, 1.0), 28);
|
||||
// Extreme calm clamps to MAX_PERIOD; extreme volatility clamps to MIN.
|
||||
assert_eq!(dmi.dynamic_period(0.1, 1.0), MAX_PERIOD);
|
||||
assert_eq!(dmi.dynamic_period(100.0, 1.0), MIN_PERIOD);
|
||||
// Zero volatility -> slowest lookback.
|
||||
assert_eq!(dmi.dynamic_period(0.0, 1.0), MAX_PERIOD);
|
||||
assert_eq!(dmi.dynamic_period(1.0, 0.0), MAX_PERIOD);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
let ready = dmi
|
||||
.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(dmi.update(f64::NAN), Some(ready));
|
||||
assert_eq!(dmi.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut dmi = DynamicMomentumIndex::new(14).unwrap();
|
||||
dmi.batch(&(0..40).map(|i| 100.0 + f64::from(i)).collect::<Vec<_>>());
|
||||
assert!(dmi.is_ready());
|
||||
dmi.reset();
|
||||
assert!(!dmi.is_ready());
|
||||
assert_eq!(dmi.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..80)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = DynamicMomentumIndex::new(14).unwrap();
|
||||
let mut b = DynamicMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Elder Ray — Bull Power and Bear Power.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One Elder Ray reading: the bull and bear power for a bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ElderRayOutput {
|
||||
/// `high − EMA(close)`: how far buyers pushed price above the trend mean.
|
||||
pub bull_power: f64,
|
||||
/// `low − EMA(close)`: how far sellers pushed price below the trend mean
|
||||
/// (negative in a normal market).
|
||||
pub bear_power: f64,
|
||||
}
|
||||
|
||||
/// Elder Ray — Alexander Elder's Bull Power / Bear Power oscillator.
|
||||
///
|
||||
/// An EMA of the close marks the market's consensus of value; the bar's high and
|
||||
/// low relative to it measure how far the bulls and bears could push price away
|
||||
/// from that consensus:
|
||||
///
|
||||
/// ```text
|
||||
/// ema = EMA(close, period)
|
||||
/// BullPower = high - ema
|
||||
/// BearPower = low - ema
|
||||
/// ```
|
||||
///
|
||||
/// Bull Power is normally positive (the high prints above the mean) and Bear
|
||||
/// Power normally negative (the low prints below it). Their behaviour relative
|
||||
/// to zero and to the EMA's slope drives Elder's signals: e.g. in an uptrend
|
||||
/// (rising EMA), a bounce in a negative-but-rising Bear Power is a buy setup.
|
||||
///
|
||||
/// The first reading lands once the inner EMA is seeded, at bar `period`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, ElderRay, Indicator};
|
||||
///
|
||||
/// let mut er = ElderRay::new(13).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = er.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ElderRay {
|
||||
period: usize,
|
||||
ema: Ema,
|
||||
}
|
||||
|
||||
impl ElderRay {
|
||||
/// Construct an Elder Ray with the given EMA period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
ema: Ema::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ElderRay {
|
||||
type Input = Candle;
|
||||
type Output = ElderRayOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<ElderRayOutput> {
|
||||
let ema = self.ema.update(candle.close)?;
|
||||
Some(ElderRayOutput {
|
||||
bull_power: candle.high - ema,
|
||||
bear_power: candle.low - ema,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ElderRay"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(ElderRay::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let er = ElderRay::new(13).unwrap();
|
||||
assert_eq!(er.period(), 13);
|
||||
assert_eq!(er.warmup_period(), 13);
|
||||
assert_eq!(er.name(), "ElderRay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_known_value() {
|
||||
// EMA(3) seeds at bar 3 with SMA([10,12,14]) = 12 (closes).
|
||||
// bar 3: high 16, low 13 -> bull = 16 - 12 = 4, bear = 13 - 12 = 1.
|
||||
let mut er = ElderRay::new(3).unwrap();
|
||||
assert_eq!(er.update(candle(11.0, 9.0, 10.0)), None);
|
||||
assert_eq!(er.update(candle(13.0, 11.0, 12.0)), None);
|
||||
let v = er.update(candle(16.0, 13.0, 14.0)).unwrap();
|
||||
assert_relative_eq!(v.bull_power, 4.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(v.bear_power, 1.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_manual_ema() {
|
||||
let bars: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
candle(base + 2.0, base - 2.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut er = ElderRay::new(13).unwrap();
|
||||
let mut ema = Ema::new(13).unwrap();
|
||||
for (i, c) in bars.iter().enumerate() {
|
||||
let got = er.update(*c);
|
||||
let want = ema.update(c.close).map(|e| (c.high - e, c.low - e));
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(g), Some((b, be))) = (got, want) {
|
||||
assert_relative_eq!(g.bull_power, b, epsilon = 1e-9);
|
||||
assert_relative_eq!(g.bear_power, be, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut er = ElderRay::new(5).unwrap();
|
||||
er.batch(
|
||||
&(0..20)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(er.is_ready());
|
||||
er.reset();
|
||||
assert!(!er.is_ready());
|
||||
assert_eq!(er.update(candle(2.0, 0.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 50.0 + f64::from(i);
|
||||
candle(base + 1.5, base - 1.5, base)
|
||||
})
|
||||
.collect();
|
||||
let mut a = ElderRay::new(7).unwrap();
|
||||
let mut b = ElderRay::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Fisher-transformed RSI.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Fisher RSI — the Fisher transform applied to a normalised [`Rsi`](crate::Rsi).
|
||||
///
|
||||
/// The RSI is bounded in `[0, 100]` and its distribution piles up near the
|
||||
/// middle, which blurs turning points. The Fisher transform reshapes a bounded
|
||||
/// input toward a Gaussian, sharpening the extremes into clear, near-symmetric
|
||||
/// peaks:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi = RSI(price, period) in [0, 100]
|
||||
/// x = clamp((rsi - 50) / 50, ±0.999) normalise to (-1, 1)
|
||||
/// Fisher = 0.5 * ln((1 + x) / (1 - x))
|
||||
/// ```
|
||||
///
|
||||
/// The clamp keeps the logarithm finite when the RSI pins at `0` or `100`. The
|
||||
/// output is unbounded but in practice oscillates in roughly `[-3, 3]`, with
|
||||
/// sharp excursions marking momentum extremes. The first value lands with the
|
||||
/// inner RSI, after `period + 1` inputs.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{FisherRsi, Indicator};
|
||||
///
|
||||
/// let mut indicator = FisherRsi::new(9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.3).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FisherRsi {
|
||||
period: usize,
|
||||
rsi: Rsi,
|
||||
}
|
||||
|
||||
impl FisherRsi {
|
||||
/// Construct a Fisher RSI with the given RSI period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
rsi: Rsi::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FisherRsi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let rsi = self.rsi.update(input)?;
|
||||
let x = ((rsi - 50.0) / 50.0).clamp(-0.999, 0.999);
|
||||
Some(0.5 * ((1.0 + x) / (1.0 - x)).ln())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.rsi.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.rsi.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FisherRSI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(FisherRsi::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FisherRsi::new(9).unwrap();
|
||||
assert_eq!(f.period(), 9);
|
||||
// RSI warmup is period + 1.
|
||||
assert_eq!(f.warmup_period(), 10);
|
||||
assert_eq!(f.name(), "FisherRSI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_matches_rsi() {
|
||||
let mut f = FisherRsi::new(3).unwrap();
|
||||
// RSI(3) needs 4 inputs; the first three return None.
|
||||
assert_eq!(f.update(1.0), None);
|
||||
assert_eq!(f.update(2.0), None);
|
||||
assert_eq!(f.update(3.0), None);
|
||||
assert!(f.update(4.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_fisher_of_rsi() {
|
||||
// Fisher RSI must equal the Fisher transform of the standalone RSI.
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut fr = FisherRsi::new(9).unwrap();
|
||||
let mut rsi = Rsi::new(9).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = fr.update(p);
|
||||
let want = rsi.update(p).map(|r| {
|
||||
let x = ((r - 50.0) / 50.0).clamp(-0.999, 0.999);
|
||||
0.5 * ((1.0 + x) / (1.0 - x)).ln()
|
||||
});
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (got, want) {
|
||||
assert_relative_eq!(a, b, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_uptrend_is_positive() {
|
||||
// A pure uptrend pins RSI near 100 -> x near +1 -> large positive Fisher.
|
||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||
let mut f = FisherRsi::new(9).unwrap();
|
||||
let last = f.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 1.0,
|
||||
"strong uptrend should give a large positive value, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_keeps_output_finite_at_extremes() {
|
||||
// Monotonic rise pins RSI at 100; the clamp must keep Fisher finite.
|
||||
let prices: Vec<f64> = (1..=30).map(f64::from).collect();
|
||||
let mut f = FisherRsi::new(5).unwrap();
|
||||
for v in f.batch(&prices).into_iter().flatten() {
|
||||
assert!(v.is_finite(), "Fisher RSI must stay finite, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FisherRsi::new(5).unwrap();
|
||||
f.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
assert_eq!(f.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=40)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = FisherRsi::new(9).unwrap();
|
||||
let mut b = FisherRsi::new(9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Intraday Momentum Index (IMI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close
|
||||
/// move instead of the close-to-close move.
|
||||
///
|
||||
/// For each bar the body is an up-move when `close > open` and a down-move
|
||||
/// otherwise; the IMI sums those bodies over `period` bars and forms the
|
||||
/// RSI-style ratio:
|
||||
///
|
||||
/// ```text
|
||||
/// gain = max(close - open, 0), loss = max(open - close, 0)
|
||||
/// IMI = 100 * Σ gain / (Σ gain + Σ loss) over the last `period` bars
|
||||
/// ```
|
||||
///
|
||||
/// Because it measures *intraday* (body) momentum rather than the gap-inclusive
|
||||
/// close-to-close change, the IMI is a candle-pattern-flavoured overbought /
|
||||
/// oversold gauge: persistent white bodies push it up, black bodies down. It is
|
||||
/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the
|
||||
/// neutral `50`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator};
|
||||
///
|
||||
/// let mut imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = imi.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntradayMomentumIndex {
|
||||
period: usize,
|
||||
/// Per-bar `(gain, loss)` bodies, oldest at the front.
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_gain: f64,
|
||||
sum_loss: f64,
|
||||
}
|
||||
|
||||
impl IntradayMomentumIndex {
|
||||
/// Construct an IMI over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_gain: 0.0,
|
||||
sum_loss: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if the window is full.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.window.len() != self.period {
|
||||
return None;
|
||||
}
|
||||
let denom = self.sum_gain + self.sum_loss;
|
||||
if denom == 0.0 {
|
||||
Some(50.0)
|
||||
} else {
|
||||
Some(100.0 * self.sum_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayMomentumIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let body = candle.close - candle.open;
|
||||
let gain = if body > 0.0 { body } else { 0.0 };
|
||||
let loss = if body < 0.0 { -body } else { 0.0 };
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let (old_g, old_l) = self.window.pop_front().expect("window full");
|
||||
self.sum_gain -= old_g;
|
||||
self.sum_loss -= old_l;
|
||||
}
|
||||
self.window.push_back((gain, loss));
|
||||
self.sum_gain += gain;
|
||||
self.sum_loss += loss;
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IMI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, close: f64) -> Candle {
|
||||
let hi = open.max(close) + 1.0;
|
||||
let lo = open.min(close) - 1.0;
|
||||
Candle::new(open, hi, lo, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
IntradayMomentumIndex::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(imi.period(), 14);
|
||||
assert_eq!(imi.warmup_period(), 14);
|
||||
assert_eq!(imi.name(), "IMI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_up_bodies_is_one_hundred() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)];
|
||||
let out = imi.batch(&bars);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_down_bodies_is_zero() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value_mixed_bodies() {
|
||||
// bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_window_is_neutral() {
|
||||
// close == open every bar -> no bodies -> neutral 50.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_window() {
|
||||
// After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [
|
||||
candle(10.0, 11.0),
|
||||
candle(11.0, 10.0),
|
||||
candle(10.0, 12.0),
|
||||
candle(12.0, 12.0),
|
||||
];
|
||||
let out = imi.batch(&bars);
|
||||
assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]);
|
||||
assert!(imi.is_ready());
|
||||
imi.reset();
|
||||
assert!(!imi.is_ready());
|
||||
assert_eq!(imi.update(candle(1.0, 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base, base + (f64::from(i) * 0.5).sin())
|
||||
})
|
||||
.collect();
|
||||
let mut a = IntradayMomentumIndex::new(7).unwrap();
|
||||
let mut b = IntradayMomentumIndex::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,9 @@ mod dema;
|
||||
mod demand_index;
|
||||
mod demark_pivots;
|
||||
mod depth_slope;
|
||||
mod derivative_oscillator;
|
||||
mod detrended_std_dev;
|
||||
mod disparity_index;
|
||||
mod distance_ssd;
|
||||
mod doji;
|
||||
mod doji_star;
|
||||
@@ -104,11 +106,13 @@ mod dpo;
|
||||
mod dragonfly_doji;
|
||||
mod drawdown_duration;
|
||||
mod dx;
|
||||
mod dynamic_momentum_index;
|
||||
mod ease_of_movement;
|
||||
mod effective_spread;
|
||||
mod ehlers_stochastic;
|
||||
mod ehma;
|
||||
mod elder_impulse;
|
||||
mod elder_ray;
|
||||
mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
@@ -126,6 +130,7 @@ mod fib_projection;
|
||||
mod fib_retracement;
|
||||
mod fib_time_zones;
|
||||
mod fibonacci_pivots;
|
||||
mod fisher_rsi;
|
||||
mod fisher_transform;
|
||||
mod flag_pennant;
|
||||
mod footprint;
|
||||
@@ -173,6 +178,7 @@ mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod intraday_momentum_index;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
@@ -264,6 +270,7 @@ mod ppo;
|
||||
mod profit_factor;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod qqe;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
@@ -276,6 +283,7 @@ mod renko_bars;
|
||||
mod renko_trailing_stop;
|
||||
mod rickshaw_man;
|
||||
mod rising_three_methods;
|
||||
mod rmi;
|
||||
mod roc;
|
||||
mod rocp;
|
||||
mod rocr;
|
||||
@@ -289,6 +297,7 @@ mod rolling_percentile_rank;
|
||||
mod rolling_quantile;
|
||||
mod roofing_filter;
|
||||
mod rsi;
|
||||
mod rsx;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
@@ -325,6 +334,7 @@ mod step_trailing_stop;
|
||||
mod stick_sandwich;
|
||||
mod stoch_rsi;
|
||||
mod stochastic;
|
||||
mod stochastic_cci;
|
||||
mod super_smoother;
|
||||
mod super_trend;
|
||||
mod t3;
|
||||
@@ -494,7 +504,9 @@ pub use dema::Dema;
|
||||
pub use demand_index::DemandIndex;
|
||||
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
|
||||
pub use depth_slope::DepthSlope;
|
||||
pub use derivative_oscillator::DerivativeOscillator;
|
||||
pub use detrended_std_dev::DetrendedStdDev;
|
||||
pub use disparity_index::DisparityIndex;
|
||||
pub use distance_ssd::DistanceSsd;
|
||||
pub use doji::Doji;
|
||||
pub use doji_star::DojiStar;
|
||||
@@ -507,11 +519,13 @@ pub use dpo::Dpo;
|
||||
pub use dragonfly_doji::DragonflyDoji;
|
||||
pub use drawdown_duration::DrawdownDuration;
|
||||
pub use dx::Dx;
|
||||
pub use dynamic_momentum_index::DynamicMomentumIndex;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
pub use effective_spread::EffectiveSpread;
|
||||
pub use ehlers_stochastic::EhlersStochastic;
|
||||
pub use ehma::Ehma;
|
||||
pub use elder_impulse::ElderImpulse;
|
||||
pub use elder_ray::{ElderRay, ElderRayOutput};
|
||||
pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
@@ -529,6 +543,7 @@ pub use fib_projection::{FibProjection, FibProjectionOutput};
|
||||
pub use fib_retracement::{FibRetracement, FibRetracementOutput};
|
||||
pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput};
|
||||
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
|
||||
pub use fisher_rsi::FisherRsi;
|
||||
pub use fisher_transform::FisherTransform;
|
||||
pub use flag_pennant::FlagPennant;
|
||||
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
@@ -576,6 +591,7 @@ pub use inertia::Inertia;
|
||||
pub use information_ratio::InformationRatio;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
pub use instantaneous_trendline::InstantaneousTrendline;
|
||||
pub use intraday_momentum_index::IntradayMomentumIndex;
|
||||
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
@@ -667,6 +683,7 @@ pub use ppo::Ppo;
|
||||
pub use profit_factor::ProfitFactor;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use qqe::{Qqe, QqeOutput};
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
@@ -679,6 +696,7 @@ pub use renko_bars::{RenkoBars, RenkoBrick};
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
pub use rickshaw_man::RickshawMan;
|
||||
pub use rising_three_methods::RisingThreeMethods;
|
||||
pub use rmi::Rmi;
|
||||
pub use roc::Roc;
|
||||
pub use rocp::Rocp;
|
||||
pub use rocr::Rocr;
|
||||
@@ -692,6 +710,7 @@ pub use rolling_percentile_rank::RollingPercentileRank;
|
||||
pub use rolling_quantile::RollingQuantile;
|
||||
pub use roofing_filter::RoofingFilter;
|
||||
pub use rsi::Rsi;
|
||||
pub use rsx::Rsx;
|
||||
pub use rvi::Rvi;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
@@ -728,6 +747,7 @@ pub use step_trailing_stop::StepTrailingStop;
|
||||
pub use stick_sandwich::StickSandwich;
|
||||
pub use stoch_rsi::StochRsi;
|
||||
pub use stochastic::{Stochastic, StochasticOutput};
|
||||
pub use stochastic_cci::StochasticCci;
|
||||
pub use super_smoother::SuperSmoother;
|
||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||
pub use t3::T3;
|
||||
@@ -880,6 +900,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Rocp",
|
||||
"Rocr",
|
||||
"Rocr100",
|
||||
"DisparityIndex",
|
||||
"FisherRsi",
|
||||
"Rsx",
|
||||
"DynamicMomentumIndex",
|
||||
"StochasticCci",
|
||||
"Rmi",
|
||||
"DerivativeOscillator",
|
||||
"ElderRay",
|
||||
"IntradayMomentumIndex",
|
||||
"Qqe",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1363,6 +1393,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, 403, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 413, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//! QQE — Quantitative Qualitative Estimation.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::rsi::Rsi;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One QQE reading: the smoothed RSI and its volatility-trailing line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct QqeOutput {
|
||||
/// The EMA-smoothed RSI (the fast QQE line).
|
||||
pub rsi_ma: f64,
|
||||
/// The trailing line (the slow QQE line): an ATR-of-RSI trailing stop that
|
||||
/// the smoothed RSI rides above in an uptrend and below in a downtrend.
|
||||
pub trailing_line: f64,
|
||||
}
|
||||
|
||||
/// QQE — Quantitative Qualitative Estimation (Igor Livshin).
|
||||
///
|
||||
/// QQE smooths the RSI, then builds an "ATR of the RSI" trailing stop around it.
|
||||
/// Crossovers of the smoothed RSI and that trailing line give cleaner momentum
|
||||
/// signals than the raw RSI:
|
||||
///
|
||||
/// ```text
|
||||
/// rsi_ma = EMA(RSI(price, rsi_period), smoothing)
|
||||
/// atr_rsi = |rsi_ma − rsi_ma_prev|
|
||||
/// ma_atr = EMA(atr_rsi, 2·rsi_period − 1) // Wilder length
|
||||
/// dar = EMA(ma_atr, 2·rsi_period − 1) · factor // smoothed band width
|
||||
///
|
||||
/// long_band = (rsi_ma_prev > long_band_prev && rsi_ma > long_band_prev)
|
||||
/// ? max(long_band_prev, rsi_ma − dar) : rsi_ma − dar
|
||||
/// short_band = (rsi_ma_prev < short_band_prev && rsi_ma < short_band_prev)
|
||||
/// ? min(short_band_prev, rsi_ma + dar) : rsi_ma + dar
|
||||
/// trend = cross-up of short_band → +1, cross-down of long_band → −1, else hold
|
||||
/// trailing = trend == +1 ? long_band : short_band
|
||||
/// ```
|
||||
///
|
||||
/// The trailing line ratchets in the trend direction (only ever tightening until
|
||||
/// the smoothed RSI crosses it), exactly like a [`SuperTrend`](crate::SuperTrend)
|
||||
/// on the RSI. Livshin's defaults are `rsi_period = 14`, `smoothing = 5`,
|
||||
/// `factor = 4.236`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Qqe};
|
||||
///
|
||||
/// let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..200 {
|
||||
/// last = qqe.update(100.0 + (f64::from(i) * 0.1).sin() * 8.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Qqe {
|
||||
rsi: Rsi,
|
||||
rsi_ma: Ema,
|
||||
ma_atr: Ema,
|
||||
dar_ema: Ema,
|
||||
factor: f64,
|
||||
prev_rsi_ma: Option<f64>,
|
||||
bands: Option<(f64, f64, i8)>, // (long_band, short_band, trend)
|
||||
last_value: Option<QqeOutput>,
|
||||
}
|
||||
|
||||
impl Qqe {
|
||||
/// Construct a QQE with the RSI period, RSI smoothing, and band `factor`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `rsi_period` or `smoothing` is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `factor` is non-finite or not positive.
|
||||
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<Self> {
|
||||
if rsi_period == 0 || smoothing == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !factor.is_finite() || factor <= 0.0 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "QQE factor must be a finite positive value",
|
||||
});
|
||||
}
|
||||
let wilders = 2 * rsi_period - 1;
|
||||
Ok(Self {
|
||||
rsi: Rsi::new(rsi_period)?,
|
||||
rsi_ma: Ema::new(smoothing)?,
|
||||
ma_atr: Ema::new(wilders)?,
|
||||
dar_ema: Ema::new(wilders)?,
|
||||
factor,
|
||||
prev_rsi_ma: None,
|
||||
bands: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured band factor.
|
||||
pub const fn factor(&self) -> f64 {
|
||||
self.factor
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<QqeOutput> {
|
||||
self.last_value
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Qqe {
|
||||
type Input = f64;
|
||||
type Output = QqeOutput;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<QqeOutput> {
|
||||
let rsi = self.rsi.update(price)?;
|
||||
let rsi_ma = self.rsi_ma.update(rsi)?;
|
||||
|
||||
let Some(prev_ma) = self.prev_rsi_ma else {
|
||||
self.prev_rsi_ma = Some(rsi_ma);
|
||||
return None;
|
||||
};
|
||||
let atr_rsi = (rsi_ma - prev_ma).abs();
|
||||
self.prev_rsi_ma = Some(rsi_ma);
|
||||
|
||||
let ma_atr = self.ma_atr.update(atr_rsi)?;
|
||||
let dar = self.dar_ema.update(ma_atr)? * self.factor;
|
||||
|
||||
let new_long = rsi_ma - dar;
|
||||
let new_short = rsi_ma + dar;
|
||||
|
||||
let (long_band, short_band, trend) = match self.bands {
|
||||
Some((lb_prev, sb_prev, tr_prev)) => {
|
||||
let lb = if prev_ma > lb_prev && rsi_ma > lb_prev {
|
||||
lb_prev.max(new_long)
|
||||
} else {
|
||||
new_long
|
||||
};
|
||||
let sb = if prev_ma < sb_prev && rsi_ma < sb_prev {
|
||||
sb_prev.min(new_short)
|
||||
} else {
|
||||
new_short
|
||||
};
|
||||
let tr = if prev_ma <= sb_prev && rsi_ma > sb_prev {
|
||||
1
|
||||
} else if prev_ma >= lb_prev && rsi_ma < lb_prev {
|
||||
-1
|
||||
} else {
|
||||
tr_prev
|
||||
};
|
||||
(lb, sb, tr)
|
||||
}
|
||||
None => (new_long, new_short, 1),
|
||||
};
|
||||
self.bands = Some((long_band, short_band, trend));
|
||||
|
||||
let trailing_line = if trend == 1 { long_band } else { short_band };
|
||||
let out = QqeOutput {
|
||||
rsi_ma,
|
||||
trailing_line,
|
||||
};
|
||||
self.last_value = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.rsi.reset();
|
||||
self.rsi_ma.reset();
|
||||
self.ma_atr.reset();
|
||||
self.dar_ema.reset();
|
||||
self.prev_rsi_ma = None;
|
||||
self.bands = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// RSI (rsi_period + 1) -> rsi_ma EMA -> one bar for the first atr_rsi ->
|
||||
// ma_atr EMA -> dar EMA. Expressed via the component warmups so it stays
|
||||
// correct if those change.
|
||||
self.rsi.warmup_period()
|
||||
+ self.rsi_ma.warmup_period()
|
||||
+ self.ma_atr.warmup_period()
|
||||
+ self.dar_ema.warmup_period()
|
||||
- 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"QQE"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
/// Independent reference replaying the full QQE recurrence.
|
||||
fn naive(
|
||||
prices: &[f64],
|
||||
rsi_period: usize,
|
||||
smoothing: usize,
|
||||
factor: f64,
|
||||
) -> Vec<Option<QqeOutput>> {
|
||||
let mut rsi = Rsi::new(rsi_period).unwrap();
|
||||
let mut rsi_ma = Ema::new(smoothing).unwrap();
|
||||
let wilders = 2 * rsi_period - 1;
|
||||
let mut ma_atr = Ema::new(wilders).unwrap();
|
||||
let mut dar_ema = Ema::new(wilders).unwrap();
|
||||
let mut prev_ma: Option<f64> = None;
|
||||
let mut bands: Option<(f64, f64, i8)> = None;
|
||||
let mut out = Vec::with_capacity(prices.len());
|
||||
for &p in prices {
|
||||
let v = (|| {
|
||||
let r = rsi.update(p)?;
|
||||
let m = rsi_ma.update(r)?;
|
||||
let Some(pm) = prev_ma else {
|
||||
prev_ma = Some(m);
|
||||
return None;
|
||||
};
|
||||
let atr = (m - pm).abs();
|
||||
prev_ma = Some(m);
|
||||
let ma = ma_atr.update(atr)?;
|
||||
let dar = dar_ema.update(ma)? * factor;
|
||||
let nl = m - dar;
|
||||
let ns = m + dar;
|
||||
let (lb, sb, tr) = match bands {
|
||||
Some((lbp, sbp, trp)) => {
|
||||
let lb = if pm > lbp && m > lbp { lbp.max(nl) } else { nl };
|
||||
let sb = if pm < sbp && m < sbp { sbp.min(ns) } else { ns };
|
||||
let tr = if pm <= sbp && m > sbp {
|
||||
1
|
||||
} else if pm >= lbp && m < lbp {
|
||||
-1
|
||||
} else {
|
||||
trp
|
||||
};
|
||||
(lb, sb, tr)
|
||||
}
|
||||
None => (nl, ns, 1),
|
||||
};
|
||||
bands = Some((lb, sb, tr));
|
||||
Some(QqeOutput {
|
||||
rsi_ma: m,
|
||||
trailing_line: if tr == 1 { lb } else { sb },
|
||||
})
|
||||
})();
|
||||
out.push(v);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_params() {
|
||||
assert!(matches!(Qqe::new(0, 5, 4.236), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Qqe::new(14, 0, 4.236), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
Qqe::new(14, 5, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Qqe::new(14, 5, f64::NAN),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `factor` + `value` and the Indicator-impl
|
||||
/// `name`. `warmup_period` is covered by `first_emission_matches_warmup`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
assert_relative_eq!(qqe.factor(), 4.236, epsilon = 1e-12);
|
||||
assert_eq!(qqe.value(), None);
|
||||
assert_eq!(qqe.name(), "QQE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup() {
|
||||
// A long trend-up-then-down series exercises both trend flips and the
|
||||
// band tighten/reset branches.
|
||||
let prices: Vec<f64> = (0..200)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.06).sin() * 20.0)
|
||||
.collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let out = qqe.batch(&prices);
|
||||
let warmup = qqe.warmup_period();
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(
|
||||
out[warmup - 1].is_some(),
|
||||
"first value at warmup_period - 1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_naive_over_full_cycle() {
|
||||
// Up, range, and down phases so every band/trend branch is traversed.
|
||||
let prices: Vec<f64> = (0..220)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
100.0 + (t * 0.05).sin() * 18.0 + (t * 0.2).cos() * 4.0
|
||||
})
|
||||
.collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let got = qqe.batch(&prices);
|
||||
let want = naive(&prices, 14, 5, 4.236);
|
||||
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
|
||||
assert_eq!(g.is_some(), w.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (g, w) {
|
||||
assert_relative_eq!(a.rsi_ma, b.rsi_ma, epsilon = 1e-9);
|
||||
assert_relative_eq!(a.trailing_line, b.trailing_line, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_line_below_rsi_ma_in_uptrend() {
|
||||
// Sustained rise: trend resolves to +1 and the trailing (long) band sits
|
||||
// below the smoothed RSI.
|
||||
let prices: Vec<f64> = (1..=120).map(f64::from).collect();
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let last = qqe.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last.trailing_line <= last.rsi_ma,
|
||||
"uptrend trailing {} should sit at/below rsi_ma {}",
|
||||
last.trailing_line,
|
||||
last.rsi_ma
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
qqe.batch(
|
||||
&(0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.1).sin() * 8.0)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(qqe.is_ready());
|
||||
qqe.reset();
|
||||
assert!(!qqe.is_ready());
|
||||
assert_eq!(qqe.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (0..150)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.12).sin() * 12.0)
|
||||
.collect();
|
||||
let mut a = Qqe::new(14, 5, 4.236).unwrap();
|
||||
let mut b = Qqe::new(14, 5, 4.236).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Relative Momentum Index (RMI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Relative Momentum Index — RSI generalised to a multi-bar momentum lookback.
|
||||
///
|
||||
/// Wilder's [`Rsi`](crate::Rsi) compares each close to the *previous* close.
|
||||
/// The RMI (Roger Altman, 1993) compares it to the close `momentum` bars ago,
|
||||
/// then applies the same Wilder-smoothed up/down accumulator over `period`:
|
||||
///
|
||||
/// ```text
|
||||
/// change_t = close_t - close_{t-momentum}
|
||||
/// gain = max(change, 0), loss = max(-change, 0)
|
||||
/// avg_gain, avg_loss = Wilder-smoothed over `period`
|
||||
/// RMI = 100 * avg_gain / (avg_gain + avg_loss)
|
||||
/// ```
|
||||
///
|
||||
/// `momentum = 1` reduces the RMI exactly to the RSI. Larger `momentum` makes
|
||||
/// the oscillator smoother and slower to flip, holding overbought/oversold
|
||||
/// readings longer in a trend. Output is bounded in `[0, 100]`; a flat market
|
||||
/// (no gains and no losses) returns the neutral `50`.
|
||||
///
|
||||
/// The first value lands after `momentum + period` inputs: `momentum` to fill
|
||||
/// the lookback, then `period` changes to seed Wilder's averages.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Rmi};
|
||||
///
|
||||
/// let mut indicator = Rmi::new(14, 5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rmi {
|
||||
period: usize,
|
||||
momentum: usize,
|
||||
/// The last `momentum` prices, oldest at the front.
|
||||
window: VecDeque<f64>,
|
||||
seed_gains: Vec<f64>,
|
||||
seed_losses: Vec<f64>,
|
||||
avg_gain: Option<f64>,
|
||||
avg_loss: Option<f64>,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Rmi {
|
||||
/// Construct an RMI with the given smoothing `period` and `momentum`
|
||||
/// lookback.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if either `period` or `momentum` is `0`.
|
||||
pub fn new(period: usize, momentum: usize) -> Result<Self> {
|
||||
if period == 0 || momentum == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
momentum,
|
||||
window: VecDeque::with_capacity(momentum),
|
||||
seed_gains: Vec::with_capacity(period),
|
||||
seed_losses: Vec::with_capacity(period),
|
||||
avg_gain: None,
|
||||
avg_loss: None,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured smoothing period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured momentum lookback.
|
||||
pub const fn momentum(&self) -> usize {
|
||||
self.momentum
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
fn rmi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
|
||||
let denom = avg_gain + avg_loss;
|
||||
if denom == 0.0 {
|
||||
50.0
|
||||
} else {
|
||||
// Ratio first, then scale, so `100 * g / g` cannot round above 100.
|
||||
100.0 * (avg_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Rmi {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
if !input.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
if self.window.len() < self.momentum {
|
||||
// Still filling the momentum lookback; no change to measure yet.
|
||||
self.window.push_back(input);
|
||||
return None;
|
||||
}
|
||||
let past = self.window.pop_front().expect("window full");
|
||||
self.window.push_back(input);
|
||||
|
||||
let change = input - past;
|
||||
let gain = if change > 0.0 { change } else { 0.0 };
|
||||
let loss = if change < 0.0 { -change } else { 0.0 };
|
||||
|
||||
if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) {
|
||||
let n = self.period as f64;
|
||||
let new_ag = (ag * (n - 1.0) + gain) / n;
|
||||
let new_al = (al * (n - 1.0) + loss) / n;
|
||||
self.avg_gain = Some(new_ag);
|
||||
self.avg_loss = Some(new_al);
|
||||
let v = Self::rmi_from_avgs(new_ag, new_al);
|
||||
self.last_value = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
|
||||
self.seed_gains.push(gain);
|
||||
self.seed_losses.push(loss);
|
||||
if self.seed_gains.len() == self.period {
|
||||
let ag = self.seed_gains.iter().sum::<f64>() / self.period as f64;
|
||||
let al = self.seed_losses.iter().sum::<f64>() / self.period as f64;
|
||||
self.avg_gain = Some(ag);
|
||||
self.avg_loss = Some(al);
|
||||
let v = Self::rmi_from_avgs(ag, al);
|
||||
self.last_value = Some(v);
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.seed_gains.clear();
|
||||
self.seed_losses.clear();
|
||||
self.avg_gain = None;
|
||||
self.avg_loss = None;
|
||||
self.last_value = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.momentum + self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last_value.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RMI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::Rsi;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(Rmi::new(0, 5), Err(Error::PeriodZero)));
|
||||
assert!(matches!(Rmi::new(14, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `period` + `momentum` + `value` and the
|
||||
/// Indicator-impl `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rmi = Rmi::new(14, 5).unwrap();
|
||||
assert_eq!(rmi.period(), 14);
|
||||
assert_eq!(rmi.momentum(), 5);
|
||||
assert_eq!(rmi.value(), None);
|
||||
assert_eq!(rmi.warmup_period(), 19);
|
||||
assert_eq!(rmi.name(), "RMI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn momentum_one_equals_rsi() {
|
||||
// With momentum = 1 the RMI is exactly Wilder's RSI.
|
||||
let prices: Vec<f64> = (0..60)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).sin() * 8.0)
|
||||
.collect();
|
||||
let mut rmi = Rmi::new(14, 1).unwrap();
|
||||
let mut rsi = Rsi::new(14).unwrap();
|
||||
for (i, &p) in prices.iter().enumerate() {
|
||||
let got = rmi.update(p);
|
||||
let want = rsi.update(p);
|
||||
assert_eq!(got.is_some(), want.is_some(), "readiness mismatch at {i}");
|
||||
if let (Some(a), Some(b)) = (got, want) {
|
||||
assert_relative_eq!(a, b, epsilon = 1e-9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_emits() {
|
||||
// momentum + period = 3 + 2 = 5 inputs before the first value.
|
||||
let mut rmi = Rmi::new(2, 3).unwrap();
|
||||
let out = rmi.batch(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
for (i, v) in out.iter().enumerate().take(4) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[4].is_some(), "first value at warmup_period - 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_uptrend_is_one_hundred() {
|
||||
// Every momentum-spaced change is positive -> avg_loss 0 -> RMI 100.
|
||||
let prices: Vec<f64> = (1..=40).map(f64::from).collect();
|
||||
let mut rmi = Rmi::new(5, 3).unwrap();
|
||||
let last = rmi.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// No change -> no gains and no losses -> neutral 50.
|
||||
let mut rmi = Rmi::new(3, 2).unwrap();
|
||||
let last = rmi.batch(&[7.0; 20]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut rmi = Rmi::new(2, 2).unwrap();
|
||||
let ready = rmi
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(rmi.update(f64::NAN), Some(ready));
|
||||
assert_eq!(rmi.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rmi = Rmi::new(3, 2).unwrap();
|
||||
rmi.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(rmi.is_ready());
|
||||
rmi.reset();
|
||||
assert!(!rmi.is_ready());
|
||||
assert_eq!(rmi.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=40)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = Rmi::new(14, 5).unwrap();
|
||||
let mut b = Rmi::new(14, 5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//! RSX — Jurik-style smoothed RSI.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// RSX — a noise-free RSI built from Jurik's three-stage smoothing cascade.
|
||||
///
|
||||
/// Where Wilder's [`Rsi`](crate::Rsi) smooths the up/down moves with a single
|
||||
/// EMA, the RSX runs the signed price change *and* its absolute value through
|
||||
/// three cascaded "double-EMA with overshoot" stages (each stage is
|
||||
/// `x = 1.5·a − 0.5·b`, the same lag-cancelling trick as a DEMA), then forms the
|
||||
/// RSI-style ratio from the two smoothed streams:
|
||||
///
|
||||
/// ```text
|
||||
/// f18 = 3 / (length + 2), f20 = 1 - f18
|
||||
/// each stage: a = f20·a + f18·in; b = f18·a + f20·b; out = 1.5·a − 0.5·b
|
||||
/// v14 = stage3(signed change), v1C = stage3(|change|)
|
||||
/// RSX = clamp((v14 / v1C + 1) · 50, 0, 100) (50 when v1C == 0)
|
||||
/// ```
|
||||
///
|
||||
/// The result is an oscillator in `[0, 100]` that tracks the RSI but is far
|
||||
/// smoother for the same responsiveness — it has very little of the RSI's
|
||||
/// bar-to-bar jitter, so threshold crosses and divergences are cleaner. A flat
|
||||
/// market returns the neutral `50`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, Rsx};
|
||||
///
|
||||
/// let mut indicator = Rsx::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + (f64::from(i) * 0.2).sin() * 5.0);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Rsx {
|
||||
length: usize,
|
||||
f18: f64,
|
||||
f20: f64,
|
||||
prev: Option<f64>,
|
||||
count: usize,
|
||||
// Signed-change cascade (three stages: a/b pairs).
|
||||
s_a0: f64,
|
||||
s_b0: f64,
|
||||
s_a1: f64,
|
||||
s_b1: f64,
|
||||
s_a2: f64,
|
||||
s_b2: f64,
|
||||
// Absolute-change cascade.
|
||||
a_a0: f64,
|
||||
a_b0: f64,
|
||||
a_a1: f64,
|
||||
a_b1: f64,
|
||||
a_a2: f64,
|
||||
a_b2: f64,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Rsx {
|
||||
/// Construct an RSX with the given smoothing length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `length == 0`.
|
||||
pub fn new(length: usize) -> Result<Self> {
|
||||
if length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
let f18 = 3.0 / (length as f64 + 2.0);
|
||||
Ok(Self {
|
||||
length,
|
||||
f18,
|
||||
f20: 1.0 - f18,
|
||||
prev: None,
|
||||
count: 0,
|
||||
s_a0: 0.0,
|
||||
s_b0: 0.0,
|
||||
s_a1: 0.0,
|
||||
s_b1: 0.0,
|
||||
s_a2: 0.0,
|
||||
s_b2: 0.0,
|
||||
a_a0: 0.0,
|
||||
a_b0: 0.0,
|
||||
a_a1: 0.0,
|
||||
a_b1: 0.0,
|
||||
a_a2: 0.0,
|
||||
a_b2: 0.0,
|
||||
last_value: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured length.
|
||||
pub const fn length(&self) -> usize {
|
||||
self.length
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// One double-EMA-with-overshoot stage: updates the `(a, b)` pair in place
|
||||
/// and returns `1.5·a − 0.5·b`.
|
||||
fn stage(&self, a: &mut f64, b: &mut f64, input: f64) -> f64 {
|
||||
*a = self.f20 * *a + self.f18 * input;
|
||||
*b = self.f18 * *a + self.f20 * *b;
|
||||
1.5 * *a - 0.5 * *b
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Rsx {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
if !price.is_finite() {
|
||||
return self.last_value;
|
||||
}
|
||||
let Some(prev) = self.prev else {
|
||||
self.prev = Some(price);
|
||||
return None;
|
||||
};
|
||||
self.prev = Some(price);
|
||||
|
||||
let change = price - prev;
|
||||
|
||||
// Signed-change cascade.
|
||||
let (mut sa0, mut sb0) = (self.s_a0, self.s_b0);
|
||||
let v_c = self.stage(&mut sa0, &mut sb0, change);
|
||||
self.s_a0 = sa0;
|
||||
self.s_b0 = sb0;
|
||||
let (mut sa1, mut sb1) = (self.s_a1, self.s_b1);
|
||||
let v_10 = self.stage(&mut sa1, &mut sb1, v_c);
|
||||
self.s_a1 = sa1;
|
||||
self.s_b1 = sb1;
|
||||
let (mut sa2, mut sb2) = (self.s_a2, self.s_b2);
|
||||
let v_14 = self.stage(&mut sa2, &mut sb2, v_10);
|
||||
self.s_a2 = sa2;
|
||||
self.s_b2 = sb2;
|
||||
|
||||
// Absolute-change cascade.
|
||||
let abs = change.abs();
|
||||
let (mut aa0, mut ab0) = (self.a_a0, self.a_b0);
|
||||
let v_c1 = self.stage(&mut aa0, &mut ab0, abs);
|
||||
self.a_a0 = aa0;
|
||||
self.a_b0 = ab0;
|
||||
let (mut aa1, mut ab1) = (self.a_a1, self.a_b1);
|
||||
let v_18 = self.stage(&mut aa1, &mut ab1, v_c1);
|
||||
self.a_a1 = aa1;
|
||||
self.a_b1 = ab1;
|
||||
let (mut aa2, mut ab2) = (self.a_a2, self.a_b2);
|
||||
let v_1c = self.stage(&mut aa2, &mut ab2, v_18);
|
||||
self.a_a2 = aa2;
|
||||
self.a_b2 = ab2;
|
||||
|
||||
let v4 = if v_1c > 0.0 {
|
||||
(v_14 / v_1c + 1.0) * 50.0
|
||||
} else {
|
||||
50.0
|
||||
};
|
||||
let rsx = v4.clamp(0.0, 100.0);
|
||||
|
||||
self.count += 1;
|
||||
self.last_value = Some(rsx);
|
||||
if self.count >= self.length {
|
||||
Some(rsx)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
*self = Self::new(self.length).expect("length already validated");
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// One input to seed `prev`, then `length` changes to settle the cascade.
|
||||
self.length + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.count >= self.length
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RSX"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_length() {
|
||||
assert!(matches!(Rsx::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
/// Cover the const accessors `length` + `value` and the Indicator-impl
|
||||
/// `warmup_period` + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rsx = Rsx::new(14).unwrap();
|
||||
assert_eq!(rsx.length(), 14);
|
||||
assert_eq!(rsx.value(), None);
|
||||
assert_eq!(rsx.warmup_period(), 15);
|
||||
assert_eq!(rsx.name(), "RSX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_emits() {
|
||||
let mut rsx = Rsx::new(3).unwrap();
|
||||
// 1 input seeds prev; then 3 changes settle -> first Some on input 4.
|
||||
assert_eq!(rsx.update(10.0), None);
|
||||
assert_eq!(rsx.update(11.0), None);
|
||||
assert_eq!(rsx.update(12.0), None);
|
||||
assert!(rsx.update(13.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// No movement -> absolute cascade is zero -> neutral 50.
|
||||
let mut rsx = Rsx::new(5).unwrap();
|
||||
let last = rsx.batch(&[7.0; 40]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_stays_in_range() {
|
||||
let prices: Vec<f64> = (0..120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.35).sin() * 12.0)
|
||||
.collect();
|
||||
let mut rsx = Rsx::new(14).unwrap();
|
||||
for v in rsx.batch(&prices).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "RSX {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strong_uptrend_is_high() {
|
||||
// A sustained rise drives RSX well above the neutral 50.
|
||||
let prices: Vec<f64> = (1..=60).map(f64::from).collect();
|
||||
let mut rsx = Rsx::new(14).unwrap();
|
||||
let last = rsx.batch(&prices).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 80.0,
|
||||
"strong uptrend should push RSX high, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut rsx = Rsx::new(3).unwrap();
|
||||
let ready = rsx
|
||||
.batch(&[1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(rsx.update(f64::NAN), Some(ready));
|
||||
assert_eq!(rsx.update(f64::INFINITY), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rsx = Rsx::new(5).unwrap();
|
||||
rsx.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(rsx.is_ready());
|
||||
rsx.reset();
|
||||
assert!(!rsx.is_ready());
|
||||
assert_eq!(rsx.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=60)
|
||||
.map(|i| 50.0 + (f64::from(i) * 0.5).sin() * 10.0)
|
||||
.collect();
|
||||
let mut a = Rsx::new(14).unwrap();
|
||||
let mut b = Rsx::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Stochastic CCI — a stochastic oscillator applied to the CCI.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::cci::Cci;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Stochastic CCI — the stochastic oscillator computed over the
|
||||
/// [`Cci`](crate::Cci) instead of price.
|
||||
///
|
||||
/// The CCI is unbounded and spends most of its time inside `±100`, which makes
|
||||
/// fixed overbought/oversold lines awkward. Running a stochastic over the CCI
|
||||
/// re-scales it to `[0, 100]` relative to its own recent range, turning it into
|
||||
/// a bounded, self-normalising momentum oscillator:
|
||||
///
|
||||
/// ```text
|
||||
/// cci = CCI(typical price, period)
|
||||
/// %K = 100 * (cci - lowest(cci, period)) / (highest(cci, period) - lowest(cci, period))
|
||||
/// ```
|
||||
///
|
||||
/// The same `period` is used for the CCI and the stochastic lookback. When the
|
||||
/// CCI range over the window is zero (a flat market, where the CCI is pinned at
|
||||
/// `0`) the oscillator returns the neutral `50`. The first value lands after
|
||||
/// `2·period − 1` bars: `period` to seed the CCI, then `period` CCI values to
|
||||
/// fill the stochastic window.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, StochasticCci, Indicator};
|
||||
///
|
||||
/// let mut sc = StochasticCci::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 10.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
|
||||
/// last = sc.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StochasticCci {
|
||||
period: usize,
|
||||
cci: Cci,
|
||||
/// The last `period` CCI values.
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl StochasticCci {
|
||||
/// Construct a Stochastic CCI with the given period (shared by the CCI and
|
||||
/// the stochastic lookback).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`crate::Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
cci: Cci::new(period)?,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for StochasticCci {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let cci = self.cci.update(candle)?;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(cci);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let mut lo = f64::MAX;
|
||||
let mut hi = f64::MIN;
|
||||
for &v in &self.window {
|
||||
if v < lo {
|
||||
lo = v;
|
||||
}
|
||||
if v > hi {
|
||||
hi = v;
|
||||
}
|
||||
}
|
||||
let range = hi - lo;
|
||||
if range == 0.0 {
|
||||
return Some(50.0);
|
||||
}
|
||||
// Ratio first, then scale: `100 * x / x` can round to 100.0000…1.
|
||||
Some(100.0 * ((cci - lo) / range))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.cci.reset();
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// CCI seeds at `period`, then `period` CCI values fill the stochastic window.
|
||||
2 * self.period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"StochasticCCI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64) -> Candle {
|
||||
Candle::new(close, high, low, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(StochasticCci::new(0).is_err());
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let sc = StochasticCci::new(14).unwrap();
|
||||
assert_eq!(sc.period(), 14);
|
||||
assert_eq!(sc.warmup_period(), 27);
|
||||
assert_eq!(sc.name(), "StochasticCCI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_matches_warmup_period() {
|
||||
let bars: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.4).sin() * 8.0;
|
||||
candle(base + 1.0, base - 1.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
let out = sc.batch(&bars);
|
||||
let warmup = sc.warmup_period();
|
||||
assert_eq!(warmup, 9);
|
||||
for (i, v) in out.iter().enumerate().take(warmup - 1) {
|
||||
assert!(v.is_none(), "index {i} must be None during warmup");
|
||||
}
|
||||
assert!(out[warmup - 1].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_zero_to_hundred() {
|
||||
let bars: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (f64::from(i) * 0.35).sin() * 12.0;
|
||||
candle(base + 2.0, base - 2.0, base)
|
||||
})
|
||||
.collect();
|
||||
let mut sc = StochasticCci::new(9).unwrap();
|
||||
for v in sc.batch(&bars).into_iter().flatten() {
|
||||
assert!((0.0..=100.0).contains(&v), "%K {v} left [0, 100]");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_is_neutral() {
|
||||
// Constant candles -> CCI pinned at 0 -> zero range -> neutral 50.
|
||||
let mut sc = StochasticCci::new(4).unwrap();
|
||||
let bars = vec![candle(10.0, 10.0, 10.0); 20];
|
||||
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highest_cci_in_window_is_hundred() {
|
||||
// When the latest CCI is the window maximum, %K must be 100.
|
||||
// A long rise then makes the final CCI the highest in its window.
|
||||
let mut bars: Vec<Candle> = (0..20)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect();
|
||||
// Strong final push so the last CCI tops its window.
|
||||
bars.push(candle(100.0, 98.0, 100.0));
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
let last = sc.batch(&bars).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut sc = StochasticCci::new(5).unwrap();
|
||||
sc.batch(
|
||||
&(0..30)
|
||||
.map(|i| candle(f64::from(i) + 1.0, f64::from(i) - 1.0, f64::from(i)))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(sc.is_ready());
|
||||
sc.reset();
|
||||
assert!(!sc.is_ready());
|
||||
assert_eq!(sc.update(candle(2.0, 0.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
let base = 50.0 + (f64::from(i) * 0.5).sin() * 10.0;
|
||||
candle(base + 1.5, base - 1.5, base)
|
||||
})
|
||||
.collect();
|
||||
let mut a = StochasticCci::new(9).unwrap();
|
||||
let mut b = StochasticCci::new(9).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,15 +72,16 @@ pub use indicators::{
|
||||
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, Ehma, ElderImpulse,
|
||||
Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
|
||||
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
|
||||
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
|
||||
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
|
||||
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, DynamicMomentumIndex,
|
||||
EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay,
|
||||
ElderRayOutput, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, Expectancy,
|
||||
FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
|
||||
FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
|
||||
FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
|
||||
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint,
|
||||
FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
|
||||
FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis,
|
||||
FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite,
|
||||
GarmanKlassVolatility, Gartley, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
||||
@@ -90,10 +91,10 @@ pub use indicators::{
|
||||
HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput,
|
||||
HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia,
|
||||
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform,
|
||||
InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama,
|
||||
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
|
||||
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
|
||||
InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio,
|
||||
KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength,
|
||||
Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
|
||||
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
|
||||
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
|
||||
@@ -107,35 +108,35 @@ pub use indicators::{
|
||||
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,
|
||||
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix,
|
||||
TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows,
|
||||
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
|
||||
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
|
||||
VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
|
||||
Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
|
||||
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
|
||||
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
|
||||
Zlema, FAMILIES, T3,
|
||||
ProfitFactor, Psar, Pvi, Qqe, QqeOutput, QuotedSpread, RSquared, RealizedSpread,
|
||||
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
|
||||
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
|
||||
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
|
||||
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
|
||||
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
|
||||
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
|
||||
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
|
||||
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
|
||||
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
|
||||
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
|
||||
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeOfDayReturnProfile,
|
||||
TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput, TradeImbalance, TrendLabel,
|
||||
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, Tsi, Tsv,
|
||||
TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex,
|
||||
UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
|
||||
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
|
||||
VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput,
|
||||
VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput,
|
||||
Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput,
|
||||
Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate,
|
||||
Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
|
||||
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
|
||||
+1
-1
@@ -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 **403 indicators** across
|
||||
- A per-indicator deep dive for every one of the **413 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 &
|
||||
|
||||
Generated
+7
-7
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"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.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"
|
||||
"wickra-darwin-arm64": "0.5.6",
|
||||
"wickra-darwin-x64": "0.5.6",
|
||||
"wickra-linux-arm64-gnu": "0.5.6",
|
||||
"wickra-linux-x64-gnu": "0.5.6",
|
||||
"wickra-win32-arm64-msvc": "0.5.6",
|
||||
"wickra-win32-x64-msvc": "0.5.6"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
|
||||
@@ -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, 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};
|
||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, 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.
|
||||
@@ -67,6 +67,12 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| T3::new(14, 0.7).unwrap(), &data);
|
||||
drive(|| Mom::new(14).unwrap(), &data);
|
||||
drive(|| Cmo::new(14).unwrap(), &data);
|
||||
drive(|| DisparityIndex::new(14).unwrap(), &data);
|
||||
drive(|| FisherRsi::new(14).unwrap(), &data);
|
||||
drive(|| Rsx::new(14).unwrap(), &data);
|
||||
drive(|| DynamicMomentumIndex::new(14).unwrap(), &data);
|
||||
drive(|| Rmi::new(14, 5).unwrap(), &data);
|
||||
drive(|| DerivativeOscillator::new(14, 5, 3, 9).unwrap(), &data);
|
||||
drive(|| Tsi::new(25, 13).unwrap(), &data);
|
||||
drive(|| Pmo::new(35, 20).unwrap(), &data);
|
||||
drive(|| Tii::new(60, 30).unwrap(), &data);
|
||||
@@ -123,6 +129,16 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
let _ = Kst::classic().batch(&data);
|
||||
}
|
||||
|
||||
// QQE is scalar-input but emits `QqeOutput`, so it bypasses the generic
|
||||
// `drive` helper. Streaming + batch are still both exercised.
|
||||
{
|
||||
let mut qqe = Qqe::new(14, 5, 4.236).unwrap();
|
||||
for &x in &data {
|
||||
let _ = qqe.update(x);
|
||||
}
|
||||
let _ = Qqe::new(14, 5, 4.236).unwrap().batch(&data);
|
||||
}
|
||||
|
||||
// Zero-Lag MACD shares MACD's multi-output topology, so it gets the
|
||||
// same hand-rolled streaming + batch drive as classic MACD below.
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! WeightedClose.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
|
||||
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
|
||||
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
|
||||
@@ -105,6 +105,9 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
|
||||
// --- Momentum & Oscillators ---
|
||||
drive(|| Cci::new(20).unwrap(), &candles);
|
||||
drive(|| StochasticCci::new(14).unwrap(), &candles);
|
||||
drive(|| ElderRay::new(13).unwrap(), &candles);
|
||||
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);
|
||||
drive(|| Rvi::new(10).unwrap(), &candles);
|
||||
drive(|| Inertia::new(14, 20).unwrap(), &candles);
|
||||
drive(|| Pgo::new(14).unwrap(), &candles);
|
||||
|
||||
Reference in New Issue
Block a user