Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2bc000892 | |||
| 1f4bf9e3a6 | |||
| d36d514f56 | |||
| 6e0464930e | |||
| 13bc801f89 | |||
| ac8f6acf08 | |||
| 4f81222aed |
+30
-1
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.8] - 2026-06-04
|
||||
- **TSF Oscillator** — the percentage gap of the close to the one-bar-ahead time-series forecast, a close-relative companion to CFO (`TsfOscillator`).
|
||||
- **MACD Histogram** — the standalone macd-minus-signal bar of MACD as a scalar series (`MacdHistogram`).
|
||||
- **PPO Histogram** — the Percentage Price Oscillator with its signal EMA and the resulting zero-centered histogram (`PpoHistogram`).
|
||||
|
||||
## [0.5.7] - 2026-06-04
|
||||
- **Qstick** — Qstick (Chande), the SMA of the candle body (close − open) as a net buying/selling pressure gauge (`QSTICK`).
|
||||
- **TTM Trend** — TTM Trend (John Carter), +1/−1 by whether the close sits above the SMA of recent median prices (`TTM_TREND`).
|
||||
- **Trend Strength Index** — trend strength index, the signed r² of a linear regression of price against time (`TREND_STRENGTH_INDEX`).
|
||||
- **Polarized Fractal Efficiency** — polarized fractal efficiency (Hannula), directional trend efficiency over a fractal lookback (`POLARIZED_FRACTAL_EFFICIENCY`).
|
||||
- **Wave PM** — Wave PM (Kase), a variance-normalised peak-momentum statistic (`WAVE_PM`).
|
||||
- **Gator Oscillator** — Gator Oscillator (Bill Williams), the Alligator convergence/divergence histogram (`GATOR_OSCILLATOR`).
|
||||
- **Kase Permission Stochastic** — Kase Permission Stochastic, a double-smoothed stochastic used as a trade-permission filter (`KASE_PERMISSION_STOCHASTIC`).
|
||||
|
||||
## [0.5.6] - 2026-06-04
|
||||
- **QQE** — quantitative qualitative estimation, a smoothed RSI with an ATR-of-RSI trailing line (`QQE`).
|
||||
- **Intraday Momentum Index** — intraday momentum index (Chande), RSI on the open-to-close body (`IMI`).
|
||||
- **Elder Ray** — Elder Ray bull power and bear power around an EMA of close (`ElderRay`).
|
||||
- **Derivative Oscillator** — derivative oscillator (Constance Brown), a double-smoothed RSI histogram (`DerivativeOscillator`).
|
||||
- **RMI** — relative momentum index (RMI), RSI over a multi-bar momentum lookback (`RMI`).
|
||||
- **Stochastic CCI** — stochastic CCI, a stochastic oscillator over the CCI (`StochasticCCI`).
|
||||
- **Dynamic Momentum Index** — dynamic momentum index (Chande), a volatility-adaptive RSI (`DynamicMomentumIndex`).
|
||||
- **RSX** — RSX, a Jurik-style three-stage smoothed RSI (`RSX`).
|
||||
- **Fisher RSI** — Fisher RSI, the Fisher transform of a normalised RSI (`FisherRSI`).
|
||||
- **Disparity Index** — disparity index, the percent gap between price and its moving average (`DisparityIndex`).
|
||||
|
||||
## [0.5.5] - 2026-06-04
|
||||
- **GD** — generalized DEMA (GD), Tillson's volume-factor double EMA and the building block of T3 (`GD`).
|
||||
- **GMA** — geometric moving average (GMA), the rolling geometric mean of prices (`GMA`).
|
||||
@@ -1247,7 +1273,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.5...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.8...HEAD
|
||||
[0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8
|
||||
[0.5.7]: https://github.com/wickra-lib/wickra/compare/v0.5.6...v0.5.7
|
||||
[0.5.6]: https://github.com/wickra-lib/wickra/compare/v0.5.5...v0.5.6
|
||||
[0.5.5]: https://github.com/wickra-lib/wickra/compare/v0.5.4...v0.5.5
|
||||
[0.5.4]: https://github.com/wickra-lib/wickra/compare/v0.5.3...v0.5.4
|
||||
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.5.5"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.5.5"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.5.5"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.5.5"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.5.5"
|
||||
version = "0.5.8"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.5.5"
|
||||
version = "0.5.8"
|
||||
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.8"
|
||||
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.8" }
|
||||
|
||||
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=423" 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 423 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
|
||||
423 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,9 +144,9 @@ 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) |
|
||||
| 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 |
|
||||
| Momentum Oscillators | RSI (Wilder), Anchored RSI, Stochastic, CCI, ROC, Williams %R, MFI, Awesome Oscillator, MOM, CMO, TSI, PMO, StochRSI, Ultimate Oscillator, RVI, PGO, KST, SMI, Laguerre RSI, Connors RSI, Inertia, ROC Percentage (ROCP), ROC Ratio (ROCR), ROC Ratio 100 (ROCR100), Disparity Index, Fisher RSI, RSX, Dynamic Momentum Index, Stochastic CCI, RMI, Derivative Oscillator, Elder Ray, Intraday Momentum Index, QQE |
|
||||
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
|
||||
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
|
||||
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility |
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
@@ -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 423 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
|
||||
@@ -28,6 +28,18 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
PpoHistogram: () => new wickra.PpoHistogram(3, 6, 3),
|
||||
MacdHistogram: () => new wickra.MacdHistogram(3, 6, 3),
|
||||
TsfOscillator: () => new wickra.TsfOscillator(3),
|
||||
WAVE_PM: () => new wickra.WAVE_PM(32, 3),
|
||||
POLARIZED_FRACTAL_EFFICIENCY: () => new wickra.POLARIZED_FRACTAL_EFFICIENCY(10, 5),
|
||||
TREND_STRENGTH_INDEX: () => new wickra.TREND_STRENGTH_INDEX(20),
|
||||
DerivativeOscillator: () => new wickra.DerivativeOscillator(14, 5, 3, 9),
|
||||
RMI: () => new wickra.RMI(14, 5),
|
||||
DynamicMomentumIndex: () => new wickra.DynamicMomentumIndex(14),
|
||||
RSX: () => new wickra.RSX(14),
|
||||
FisherRSI: () => new wickra.FisherRSI(14),
|
||||
DisparityIndex: () => new wickra.DisparityIndex(14),
|
||||
HoltWinters: () => new wickra.HoltWinters(0.2, 0.1),
|
||||
GD: () => new wickra.GD(5, 0.7),
|
||||
AdaptiveLaguerre: () => new wickra.AdaptiveLaguerre(13),
|
||||
@@ -334,6 +346,10 @@ const candleScalar = {
|
||||
BodySizePct: { make: () => new wickra.BodySizePct(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
WickRatio: { make: () => new wickra.WickRatio(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HighLowRange: { make: () => new wickra.HighLowRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
StochasticCCI: { make: () => new wickra.StochasticCCI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
IMI: { make: () => new wickra.IMI(14), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -416,6 +432,10 @@ const multi = {
|
||||
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ElderRay: { make: () => new wickra.ElderRay(13), fields: ['bullPower', 'bearPower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
QQE: { make: () => new wickra.QQE(14, 5, 4.236), fields: ['rsiMa', 'trailingLine'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
|
||||
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
|
||||
Vendored
+196
@@ -69,6 +69,22 @@ export interface HtPhasorValue {
|
||||
inphase: number
|
||||
quadrature: number
|
||||
}
|
||||
export interface QqeValue {
|
||||
rsiMa: number
|
||||
trailingLine: number
|
||||
}
|
||||
export interface ElderRayValue {
|
||||
bullPower: number
|
||||
bearPower: number
|
||||
}
|
||||
export interface GatorOscillatorValue {
|
||||
upper: number
|
||||
lower: number
|
||||
}
|
||||
export interface KasePermissionStochasticValue {
|
||||
fast: number
|
||||
slow: number
|
||||
}
|
||||
export interface StochValue {
|
||||
k: number
|
||||
d: number
|
||||
@@ -917,6 +933,60 @@ 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 TrendStrengthIndexNode = TREND_STRENGTH_INDEX
|
||||
export declare class TREND_STRENGTH_INDEX {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TsfOscillatorNode = TsfOscillator
|
||||
export declare class TsfOscillator {
|
||||
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 +1484,96 @@ export declare class HighLowRange {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochasticCciNode = StochasticCCI
|
||||
export declare class StochasticCCI {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ImiNode = IMI
|
||||
export declare class IMI {
|
||||
constructor(period: number)
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type QqeNode = QQE
|
||||
export declare class QQE {
|
||||
constructor(rsiPeriod: number, smoothing: number, factor: number)
|
||||
update(value: number): QqeValue | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ElderRayNode = ElderRay
|
||||
export declare class ElderRay {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): ElderRayValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TtmTrendNode = TTM_TREND
|
||||
export declare class TTM_TREND {
|
||||
constructor(period: number)
|
||||
update(high: number, low: number, close: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type QstickNode = Qstick
|
||||
export declare class Qstick {
|
||||
constructor(period: number)
|
||||
update(open: number, close: number): number | null
|
||||
batch(open: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PolarizedFractalEfficiencyNode = POLARIZED_FRACTAL_EFFICIENCY
|
||||
export declare class POLARIZED_FRACTAL_EFFICIENCY {
|
||||
constructor(period: number, smoothing: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type WavePmNode = WAVE_PM
|
||||
export declare class WAVE_PM {
|
||||
constructor(length: number, smoothing: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type GatorOscillatorNode = GatorOscillator
|
||||
export declare class GatorOscillator {
|
||||
constructor(jawPeriod: number, teethPeriod: number, lipsPeriod: number)
|
||||
update(high: number, low: number, close: number): GatorOscillatorValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KasePermissionStochasticNode = KasePermissionStochastic
|
||||
export declare class KasePermissionStochastic {
|
||||
constructor(length: number, smooth: number)
|
||||
update(high: number, low: number, close: number): KasePermissionStochasticValue | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type StochNode = Stochastic
|
||||
export declare class Stochastic {
|
||||
constructor(kPeriod: number, dPeriod: number)
|
||||
@@ -1740,6 +1900,42 @@ 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 MacdHistogramNode = MacdHistogram
|
||||
export declare class MacdHistogram {
|
||||
constructor(fast: number, slow: number, signal: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PpoHistogramNode = PpoHistogram
|
||||
export declare class PpoHistogram {
|
||||
constructor(fast: number, slow: number, signal: 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)
|
||||
|
||||
+21
-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.8",
|
||||
"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.8",
|
||||
"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.8",
|
||||
"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.8",
|
||||
"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.8",
|
||||
"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.8",
|
||||
"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.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.8",
|
||||
"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.8",
|
||||
"wickra-darwin-x64": "0.5.8",
|
||||
"wickra-linux-arm64-gnu": "0.5.8",
|
||||
"wickra-linux-x64-gnu": "0.5.8",
|
||||
"wickra-win32-arm64-msvc": "0.5.8",
|
||||
"wickra-win32-x64-msvc": "0.5.8"
|
||||
}
|
||||
},
|
||||
"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.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.8.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.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.8.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.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.8.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.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.8.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.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.8.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.8",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.8.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.8",
|
||||
"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.8",
|
||||
"wickra-linux-arm64-gnu": "0.5.8",
|
||||
"wickra-darwin-x64": "0.5.8",
|
||||
"wickra-darwin-arm64": "0.5.8",
|
||||
"wickra-win32-x64-msvc": "0.5.8",
|
||||
"wickra-win32-arm64-msvc": "0.5.8"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -206,6 +206,20 @@ 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
|
||||
);
|
||||
node_scalar_indicator!(
|
||||
TrendStrengthIndexNode,
|
||||
"TREND_STRENGTH_INDEX",
|
||||
wc::TrendStrengthIndex
|
||||
);
|
||||
node_scalar_indicator!(TsfOscillatorNode, "TsfOscillator", wc::TsfOscillator);
|
||||
#[napi(js_name = "JumpIndicator")]
|
||||
pub struct JumpIndicatorNode {
|
||||
inner: wc::JumpIndicator,
|
||||
@@ -2051,6 +2065,564 @@ impl HighLowRangeNode {
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "StochasticCCI")]
|
||||
pub struct StochasticCciNode {
|
||||
inner: wc::StochasticCci,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl StochasticCciNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::StochasticCci::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], 0.0)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "IMI")]
|
||||
pub struct ImiNode {
|
||||
inner: wc::IntradayMomentumIndex,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ImiNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::IntradayMomentumIndex::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd4(open, high, low, close)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: Vec<f64>,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if open.len() != high.len() || high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = open.len();
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd4(open[i], high[i], low[i], close[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct QqeValue {
|
||||
pub rsi_ma: f64,
|
||||
pub trailing_line: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "QQE")]
|
||||
pub struct QqeNode {
|
||||
inner: wc::Qqe,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl QqeNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(rsi_period: u32, smoothing: u32, factor: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Qqe::new(rsi_period as usize, smoothing as usize, factor)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<QqeValue> {
|
||||
self.inner.update(value).map(|o| QqeValue {
|
||||
rsi_ma: o.rsi_ma,
|
||||
trailing_line: o.trailing_line,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
let mut out = vec![f64::NAN; prices.len() * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.rsi_ma;
|
||||
out[i * 2 + 1] = o.trailing_line;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct ElderRayValue {
|
||||
pub bull_power: f64,
|
||||
pub bear_power: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "ElderRay")]
|
||||
pub struct ElderRayNode {
|
||||
inner: wc::ElderRay,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ElderRayNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderRay::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<ElderRayValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| ElderRayValue {
|
||||
bull_power: o.bull_power,
|
||||
bear_power: o.bear_power,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 2] = o.bull_power;
|
||||
out[i * 2 + 1] = o.bear_power;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "TTM_TREND")]
|
||||
pub struct TtmTrendNode {
|
||||
inner: wc::TtmTrend,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl TtmTrendNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::TtmTrend::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], 0.0)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "Qstick")]
|
||||
pub struct QstickNode {
|
||||
inner: wc::Qstick,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl QstickNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Qstick::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, open: f64, close: f64) -> napi::Result<Option<f64>> {
|
||||
let hi = open.max(close);
|
||||
let lo = open.min(close);
|
||||
Ok(self.inner.update(cnd4(open, hi, lo, close)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, open: Vec<f64>, close: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if open.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"open, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(open.len());
|
||||
for i in 0..open.len() {
|
||||
let hi = open[i].max(close[i]);
|
||||
let lo = open[i].min(close[i]);
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd4(open[i], hi, lo, close[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "POLARIZED_FRACTAL_EFFICIENCY")]
|
||||
pub struct PolarizedFractalEfficiencyNode {
|
||||
inner: wc::PolarizedFractalEfficiency,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl PolarizedFractalEfficiencyNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, smoothing: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PolarizedFractalEfficiency::new(period as usize, smoothing as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "WAVE_PM")]
|
||||
pub struct WavePmNode {
|
||||
inner: wc::WavePm,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl WavePmNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(length: u32, smoothing: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::WavePm::new(length as usize, smoothing as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct GatorOscillatorValue {
|
||||
pub upper: f64,
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "GatorOscillator")]
|
||||
pub struct GatorOscillatorNode {
|
||||
inner: wc::GatorOscillator,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl GatorOscillatorNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(jaw_period: u32, teeth_period: u32, lips_period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::GatorOscillator::new(
|
||||
jaw_period as usize,
|
||||
teeth_period as usize,
|
||||
lips_period as usize,
|
||||
)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<GatorOscillatorValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| GatorOscillatorValue {
|
||||
upper: o.upper,
|
||||
lower: o.lower,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 2] = o.upper;
|
||||
out[i * 2 + 1] = o.lower;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct KasePermissionStochasticValue {
|
||||
pub fast: f64,
|
||||
pub slow: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "KasePermissionStochastic")]
|
||||
pub struct KasePermissionStochasticNode {
|
||||
inner: wc::KasePermissionStochastic,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl KasePermissionStochasticNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(length: u32, smooth: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::KasePermissionStochastic::new(length as usize, smooth as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> napi::Result<Option<KasePermissionStochasticValue>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(cnd(high, low, close, 0.0)?)
|
||||
.map(|o| KasePermissionStochasticValue {
|
||||
fast: o.fast,
|
||||
slow: o.slow,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update(cnd(high[i], low[i], close[i], 0.0)?) {
|
||||
out[i * 2] = o.fast;
|
||||
out[i * 2 + 1] = o.slow;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct StochValue {
|
||||
pub k: f64,
|
||||
@@ -3877,6 +4449,167 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== MacdHistogram ==============================
|
||||
|
||||
#[napi(js_name = "MacdHistogram")]
|
||||
pub struct MacdHistogramNode {
|
||||
inner: wc::MacdHistogram,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl MacdHistogramNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::MacdHistogram::new(fast as usize, slow as usize, signal 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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== PpoHistogram ==============================
|
||||
|
||||
#[napi(js_name = "PpoHistogram")]
|
||||
pub struct PpoHistogramNode {
|
||||
inner: wc::PpoHistogram,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl PpoHistogramNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::PpoHistogram::new(fast as usize, slow as usize, signal 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.8"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -25,6 +25,26 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
PpoHistogram,
|
||||
MacdHistogram,
|
||||
TsfOscillator,
|
||||
Qstick,
|
||||
GatorOscillator,
|
||||
KasePermissionStochastic,
|
||||
WAVE_PM,
|
||||
POLARIZED_FRACTAL_EFFICIENCY,
|
||||
TREND_STRENGTH_INDEX,
|
||||
TTM_TREND,
|
||||
QQE,
|
||||
IMI,
|
||||
ElderRay,
|
||||
DerivativeOscillator,
|
||||
RMI,
|
||||
StochasticCCI,
|
||||
DynamicMomentumIndex,
|
||||
RSX,
|
||||
FisherRSI,
|
||||
DisparityIndex,
|
||||
HoltWinters,
|
||||
GD,
|
||||
AdaptiveLaguerre,
|
||||
@@ -456,6 +476,26 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PpoHistogram",
|
||||
"MacdHistogram",
|
||||
"TsfOscillator",
|
||||
"Qstick",
|
||||
"GatorOscillator",
|
||||
"KasePermissionStochastic",
|
||||
"WAVE_PM",
|
||||
"POLARIZED_FRACTAL_EFFICIENCY",
|
||||
"TREND_STRENGTH_INDEX",
|
||||
"TTM_TREND",
|
||||
"QQE",
|
||||
"IMI",
|
||||
"ElderRay",
|
||||
"DerivativeOscillator",
|
||||
"RMI",
|
||||
"StochasticCCI",
|
||||
"DynamicMomentumIndex",
|
||||
"RSX",
|
||||
"FisherRSI",
|
||||
"DisparityIndex",
|
||||
"HoltWinters",
|
||||
"GD",
|
||||
"AdaptiveLaguerre",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,18 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.PpoHistogram, (3, 6, 3)),
|
||||
(ta.MacdHistogram, (3, 6, 3)),
|
||||
(ta.TsfOscillator, (3,)),
|
||||
(ta.WAVE_PM, (32, 3)),
|
||||
(ta.POLARIZED_FRACTAL_EFFICIENCY, (10, 5)),
|
||||
(ta.TREND_STRENGTH_INDEX, (20,)),
|
||||
(ta.DerivativeOscillator, (14, 5, 3, 9)),
|
||||
(ta.RMI, (14, 5)),
|
||||
(ta.DynamicMomentumIndex, (14,)),
|
||||
(ta.RSX, (14,)),
|
||||
(ta.FisherRSI, (14,)),
|
||||
(ta.DisparityIndex, (14,)),
|
||||
(ta.HoltWinters, (0.2, 0.1)),
|
||||
(ta.GD, (5, 0.7)),
|
||||
(ta.AdaptiveLaguerre, (13,)),
|
||||
@@ -157,6 +169,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 +361,8 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
# Per-bar OHLC transforms (open matters). The streaming harness feeds
|
||||
# open == close, so batch passes the close column in for open to match.
|
||||
"HighLowRange": (lambda: ta.HighLowRange(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
|
||||
@@ -884,6 +899,21 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"KasePermissionStochastic": (
|
||||
lambda: ta.KasePermissionStochastic(9, 3),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"GatorOscillator": (
|
||||
lambda: ta.GatorOscillator(13, 8, 5),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"ElderRay": (
|
||||
lambda: ta.ElderRay(13),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
2,
|
||||
),
|
||||
"FibFan": (
|
||||
lambda: ta.FibFan(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
@@ -2741,6 +2771,125 @@ def test_spread_ar1_coefficient_reference():
|
||||
out = ta.SpreadAr1Coefficient(20).batch(a, b)
|
||||
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_elder_ray_reference():
|
||||
er = ta.ElderRay(3)
|
||||
high = np.array([11.0, 13.0, 16.0])
|
||||
low = np.array([9.0, 11.0, 13.0])
|
||||
close = np.array([10.0, 12.0, 14.0])
|
||||
out = er.batch(high, low, close)
|
||||
# EMA(3) seeds at the third bar with mean close 12; bar high 16 -> bull 4,
|
||||
# low 13 -> bear 1.
|
||||
assert out[2][0] == pytest.approx(4.0)
|
||||
assert out[2][1] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_imi_reference():
|
||||
imi = ta.IMI(3)
|
||||
open_ = np.array([10.0, 11.0, 10.0])
|
||||
high = np.array([12.0, 12.0, 13.0])
|
||||
low = np.array([9.0, 9.0, 9.0])
|
||||
close = np.array([11.0, 10.0, 12.0])
|
||||
out = imi.batch(open_, high, low, close)
|
||||
# bodies +1, -1, +2 -> gain 3, loss 1 -> 100 * 3 / 4 = 75.
|
||||
assert math.isnan(out[0])
|
||||
assert math.isnan(out[1])
|
||||
assert out[2] == pytest.approx(75.0)
|
||||
|
||||
|
||||
def test_qstick_reference():
|
||||
q = ta.Qstick(3)
|
||||
open_ = np.array([10.0, 10.0, 10.0])
|
||||
close = np.array([11.0, 11.0, 11.0])
|
||||
out = q.batch(open_, close)
|
||||
# Each body is close - open = 1; SMA(3) of [1, 1, 1] = 1.
|
||||
assert math.isnan(out[0])
|
||||
assert math.isnan(out[1])
|
||||
assert out[2] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_ttm_trend_reference():
|
||||
t = ta.TTM_TREND(3)
|
||||
high = np.array([13.0, 13.0, 13.0])
|
||||
low = np.array([9.0, 9.0, 9.0])
|
||||
close = np.array([12.0, 12.0, 12.0])
|
||||
out = t.batch(high, low, close)
|
||||
# Median (13 + 9) / 2 = 11; close 12 is above the SMA(3) reference -> +1.
|
||||
assert math.isnan(out[0])
|
||||
assert out[2] == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_trend_strength_index_reference():
|
||||
tsi = ta.TREND_STRENGTH_INDEX(10)
|
||||
closes = np.arange(10, dtype=float)
|
||||
out = tsi.batch(closes)
|
||||
# A clean ramp is a perfect uptrend -> signed r^2 = +1.
|
||||
assert math.isclose(out[-1], 1.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_polarized_fractal_efficiency_reference():
|
||||
pfe = ta.POLARIZED_FRACTAL_EFFICIENCY(5, 3)
|
||||
closes = np.arange(20, dtype=float)
|
||||
out = pfe.batch(closes)
|
||||
# On a straight ramp the path equals the diagonal -> efficiency 1 -> +100.
|
||||
assert math.isclose(out[-1], 100.0, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_wave_pm_reference():
|
||||
wpm = ta.WAVE_PM(10, 3)
|
||||
closes = np.arange(60, dtype=float) * 5.0
|
||||
out = wpm.batch(closes)
|
||||
# Constant-slope ramp: momentum equals its energy -> 100 * (1 - e^-0.5).
|
||||
baseline = 100.0 * (1.0 - math.exp(-0.5))
|
||||
assert math.isclose(out[-1], baseline, abs_tol=1e-9)
|
||||
|
||||
|
||||
def test_gator_oscillator_reference():
|
||||
g = ta.GatorOscillator(13, 8, 5)
|
||||
n = 40
|
||||
high = np.full(n, 11.0)
|
||||
low = np.full(n, 9.0)
|
||||
close = np.full(n, 10.0)
|
||||
out = g.batch(high, low, close)
|
||||
# Constant median collapses all three Alligator lines -> both bars zero.
|
||||
assert out[-1][0] == pytest.approx(0.0)
|
||||
assert out[-1][1] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_kase_permission_stochastic_reference():
|
||||
k = ta.KasePermissionStochastic(4, 2)
|
||||
n = 20
|
||||
flat = np.full(n, 10.0)
|
||||
out = k.batch(flat, flat, flat)
|
||||
# HH == LL -> raw %K defaults to the neutral 50 -> both lines at 50.
|
||||
assert out[-1][0] == pytest.approx(50.0)
|
||||
assert out[-1][1] == pytest.approx(50.0)
|
||||
|
||||
|
||||
def test_tsf_oscillator_reference():
|
||||
t = ta.TsfOscillator(3)
|
||||
assert t.update(1.0) is None
|
||||
assert t.update(2.0) is None
|
||||
assert t.update(9.0) == pytest.approx(-33.33333333333333)
|
||||
|
||||
|
||||
def test_macd_histogram_reference():
|
||||
# On a constant-slope ramp the MACD line is flat once seeded, so the
|
||||
# signal EMA catches up and the histogram collapses to 0.
|
||||
t = ta.MacdHistogram(3, 6, 3)
|
||||
for i in range(7):
|
||||
assert t.update(100.0 + i * 2.0) is None
|
||||
assert t.update(100.0 + 7 * 2.0) == pytest.approx(0.0, abs=1e-9)
|
||||
|
||||
|
||||
def test_ppo_histogram_reference():
|
||||
# PPO divides the EMA gap by the slow EMA, so on the same ramp the ratio
|
||||
# keeps drifting and the histogram stays non-zero.
|
||||
t = ta.PpoHistogram(3, 6, 3)
|
||||
for i in range(7):
|
||||
assert t.update(100.0 + i * 2.0) is None
|
||||
assert t.update(100.0 + 7 * 2.0) == pytest.approx(-0.052098, abs=1e-6)
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,14 @@ wasm_scalar_indicator!(WasmTrima, "TRIMA", wc::Trima, period: usize);
|
||||
wasm_scalar_indicator!(WasmZlema, "ZLEMA", wc::Zlema, period: usize);
|
||||
wasm_scalar_indicator!(WasmT3, "T3", wc::T3, period: usize, v: f64);
|
||||
wasm_scalar_indicator!(WasmAlma, "ALMA", wc::Alma, period: usize, offset: f64, sigma: f64);
|
||||
wasm_scalar_indicator!(
|
||||
WasmPolarizedFractalEfficiency,
|
||||
"POLARIZED_FRACTAL_EFFICIENCY",
|
||||
wc::PolarizedFractalEfficiency,
|
||||
period: usize,
|
||||
smoothing: usize
|
||||
);
|
||||
wasm_scalar_indicator!(WasmWavePm, "WAVE_PM", wc::WavePm, length: usize, smoothing: usize);
|
||||
wasm_scalar_indicator!(WasmMcGinleyDynamic, "McGinleyDynamic", wc::McGinleyDynamic, period: usize);
|
||||
wasm_scalar_indicator!(WasmFrama, "FRAMA", wc::Frama, period: usize);
|
||||
wasm_scalar_indicator!(WasmVidya, "VIDYA", wc::Vidya, period: usize, cmo_period: usize);
|
||||
@@ -2050,6 +2058,424 @@ impl WasmHighLowRange {
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = StochasticCCI)]
|
||||
pub struct WasmStochasticCci {
|
||||
inner: wc::StochasticCci,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = StochasticCCI)]
|
||||
impl WasmStochasticCci {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmStochasticCci, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::StochasticCci::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = IMI)]
|
||||
pub struct WasmImi {
|
||||
inner: wc::IntradayMomentumIndex,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = IMI)]
|
||||
impl WasmImi {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmImi, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::IntradayMomentumIndex::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Batch over open/high/low/close arrays; `NaN` during warmup.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open: &[f64],
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = open.len();
|
||||
if high.len() != n || low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("open, high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
|
||||
if let Some(v) = self.inner.update(c) {
|
||||
out[i] = v;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update over one candle's open/high/low/close.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open: f64,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle_ohlc(open, high, low, close)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = QQE)]
|
||||
pub struct WasmQqe {
|
||||
inner: wc::Qqe,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = QQE)]
|
||||
impl WasmQqe {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(rsi_period: usize, smoothing: usize, factor: f64) -> Result<WasmQqe, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Qqe::new(rsi_period, smoothing, factor).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[rsiMa0, trailing0, rsiMa1, trailing1, ...]`, length `2 * n`.
|
||||
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
|
||||
let mut out = vec![f64::NAN; prices.len() * 2];
|
||||
for (i, p) in prices.iter().enumerate() {
|
||||
if let Some(o) = self.inner.update(*p) {
|
||||
out[i * 2] = o.rsi_ma;
|
||||
out[i * 2 + 1] = o.trailing_line;
|
||||
}
|
||||
}
|
||||
Float64Array::from(out.as_slice())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ rsiMa, trailingLine }` once warm, else `null`.
|
||||
pub fn update(&mut self, value: f64) -> JsValue {
|
||||
match self.inner.update(value) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"rsiMa".into(), &o.rsi_ma.into()).ok();
|
||||
Reflect::set(&obj, &"trailingLine".into(), &o.trailing_line.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = ElderRay)]
|
||||
pub struct WasmElderRay {
|
||||
inner: wc::ElderRay,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ElderRay)]
|
||||
impl WasmElderRay {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmElderRay, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::ElderRay::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[bull0, bear0, bull1, bear1, ...]`, length `2 * n`.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = high.len();
|
||||
if low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.bull_power;
|
||||
out[i * 2 + 1] = o.bear_power;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ bullPower, bearPower }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"bullPower".into(), &o.bull_power.into()).ok();
|
||||
Reflect::set(&obj, &"bearPower".into(), &o.bear_power.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = TTM_TREND)]
|
||||
pub struct WasmTtmTrend {
|
||||
inner: wc::TtmTrend,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TTM_TREND)]
|
||||
impl WasmTtmTrend {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmTtmTrend, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TtmTrend::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
out.push(self.inner.update(c).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Qstick)]
|
||||
pub struct WasmQstick {
|
||||
inner: wc::Qstick,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Qstick)]
|
||||
impl WasmQstick {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmQstick, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Qstick::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Batch over open/close arrays; `NaN` during warmup.
|
||||
pub fn batch(&mut self, open: &[f64], close: &[f64]) -> Result<Float64Array, JsError> {
|
||||
let n = open.len();
|
||||
if close.len() != n {
|
||||
return Err(JsError::new("open, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n];
|
||||
for i in 0..n {
|
||||
let hi = open[i].max(close[i]);
|
||||
let lo = open[i].min(close[i]);
|
||||
let c = make_candle_ohlc(open[i], hi, lo, close[i])?;
|
||||
if let Some(v) = self.inner.update(c) {
|
||||
out[i] = v;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update over one candle's open and close.
|
||||
pub fn update(&mut self, open: f64, close: f64) -> Result<Option<f64>, JsError> {
|
||||
let hi = open.max(close);
|
||||
let lo = open.min(close);
|
||||
let c = make_candle_ohlc(open, hi, lo, close)?;
|
||||
Ok(self.inner.update(c))
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = GatorOscillator)]
|
||||
pub struct WasmGatorOscillator {
|
||||
inner: wc::GatorOscillator,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = GatorOscillator)]
|
||||
impl WasmGatorOscillator {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(
|
||||
jaw_period: usize,
|
||||
teeth_period: usize,
|
||||
lips_period: usize,
|
||||
) -> Result<WasmGatorOscillator, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::GatorOscillator::new(jaw_period, teeth_period, lips_period)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[upper0, lower0, upper1, lower1, ...]`, length `2 * n`.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = high.len();
|
||||
if low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.upper;
|
||||
out[i * 2 + 1] = o.lower;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ upper, lower }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
|
||||
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = KasePermissionStochastic)]
|
||||
pub struct WasmKasePermissionStochastic {
|
||||
inner: wc::KasePermissionStochastic,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = KasePermissionStochastic)]
|
||||
impl WasmKasePermissionStochastic {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(length: usize, smooth: usize) -> Result<WasmKasePermissionStochastic, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::KasePermissionStochastic::new(length, smooth).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `[fast0, slow0, fast1, slow1, ...]`, length `2 * n`.
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
let n = high.len();
|
||||
if low.len() != n || close.len() != n {
|
||||
return Err(JsError::new("high, low, close must be equal length"));
|
||||
}
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let c = make_candle(high[i], low[i], close[i], 0.0)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.fast;
|
||||
out[i * 2 + 1] = o.slow;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
/// Streaming update. Returns `{ fast, slow }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<JsValue, JsError> {
|
||||
let c = make_candle(high, low, close, 0.0)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"fast".into(), &o.fast.into()).ok();
|
||||
Reflect::set(&obj, &"slow".into(), &o.slow.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = Stochastic)]
|
||||
pub struct WasmStoch {
|
||||
inner: wc::Stochastic,
|
||||
@@ -10220,6 +10646,16 @@ wasm_scalar_indicator!(WasmMedianMa, "MedianMA", wc::MedianMa, period: usize);
|
||||
wasm_scalar_indicator!(WasmAdaptiveLaguerreFilter, "AdaptiveLaguerre", wc::AdaptiveLaguerreFilter, period: usize);
|
||||
wasm_scalar_indicator!(WasmGeneralizedDema, "GD", wc::GeneralizedDema, period: usize, v: f64);
|
||||
wasm_scalar_indicator!(WasmHoltWinters, "HoltWinters", wc::HoltWinters, alpha: f64, beta: f64);
|
||||
wasm_scalar_indicator!(WasmDisparityIndex, "DisparityIndex", wc::DisparityIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmFisherRsi, "FisherRSI", wc::FisherRsi, period: usize);
|
||||
wasm_scalar_indicator!(WasmRsx, "RSX", wc::Rsx, period: usize);
|
||||
wasm_scalar_indicator!(WasmDynamicMomentumIndex, "DynamicMomentumIndex", wc::DynamicMomentumIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmRmi, "RMI", wc::Rmi, period: usize, momentum: usize);
|
||||
wasm_scalar_indicator!(WasmDerivativeOscillator, "DerivativeOscillator", wc::DerivativeOscillator, rsi_period: usize, smooth1: usize, smooth2: usize, signal_period: usize);
|
||||
wasm_scalar_indicator!(WasmTrendStrengthIndex, "TREND_STRENGTH_INDEX", wc::TrendStrengthIndex, period: usize);
|
||||
wasm_scalar_indicator!(WasmTsfOscillator, "TsfOscillator", wc::TsfOscillator, period: usize);
|
||||
wasm_scalar_indicator!(WasmMacdHistogram, "MacdHistogram", wc::MacdHistogram, fast: usize, slow: usize, signal: usize);
|
||||
wasm_scalar_indicator!(WasmPpoHistogram, "PpoHistogram", wc::PpoHistogram, fast: usize, slow: usize, signal: 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,205 @@
|
||||
//! Bill Williams' Gator Oscillator (derived from the Alligator).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::alligator::Alligator;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Gator Oscillator output: the two histogram bars drawn above and below the
|
||||
/// zero line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GatorOscillatorOutput {
|
||||
/// Upper histogram `|jaw - teeth|`, always `>= 0`.
|
||||
pub upper: f64,
|
||||
/// Lower histogram `-|teeth - lips|`, always `<= 0`.
|
||||
pub lower: f64,
|
||||
}
|
||||
|
||||
/// Bill Williams' Gator Oscillator: a convergence/divergence view of the
|
||||
/// [`Alligator`] lines. The upper bar is the absolute gap between Jaw and
|
||||
/// Teeth; the lower bar is the negated absolute gap between Teeth and Lips.
|
||||
///
|
||||
/// ```text
|
||||
/// upper = |jaw - teeth|
|
||||
/// lower = -|teeth - lips |
|
||||
/// ```
|
||||
///
|
||||
/// Widening bars mean the Alligator's mouth is opening (a trending market);
|
||||
/// shrinking bars mean it is closing (consolidation). Warmup matches the
|
||||
/// underlying Alligator — the first value appears once the slowest line (Jaw)
|
||||
/// has warmed up.
|
||||
///
|
||||
/// Reference: Bill Williams, *Trading Chaos*, 1995.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, GatorOscillator, Indicator};
|
||||
///
|
||||
/// let mut indicator = GatorOscillator::classic();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GatorOscillator {
|
||||
alligator: Alligator,
|
||||
}
|
||||
|
||||
impl GatorOscillator {
|
||||
/// Construct a Gator Oscillator from explicit Alligator periods
|
||||
/// `(jaw, teeth, lips)`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if any period is zero.
|
||||
pub fn new(jaw_period: usize, teeth_period: usize, lips_period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
alligator: Alligator::new(jaw_period, teeth_period, lips_period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bill Williams' classic parameters: `(jaw = 13, teeth = 8, lips = 5)`.
|
||||
pub fn classic() -> Self {
|
||||
Self {
|
||||
alligator: Alligator::classic(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured `(jaw_period, teeth_period, lips_period)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
self.alligator.periods()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GatorOscillator {
|
||||
type Input = Candle;
|
||||
type Output = GatorOscillatorOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<GatorOscillatorOutput> {
|
||||
let lines = self.alligator.update(candle)?;
|
||||
Some(GatorOscillatorOutput {
|
||||
upper: (lines.jaw - lines.teeth).abs(),
|
||||
lower: -(lines.teeth - lines.lips).abs(),
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.alligator.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.alligator.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.alligator.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GatorOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, ts: i64) -> Candle {
|
||||
let close = f64::midpoint(high, low);
|
||||
Candle::new(close, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
GatorOscillator::new(0, 8, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
GatorOscillator::new(13, 0, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
GatorOscillator::new(13, 8, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let g = GatorOscillator::classic();
|
||||
assert_eq!(g.periods(), (13, 8, 5));
|
||||
assert_eq!(g.warmup_period(), 13);
|
||||
assert_eq!(g.name(), "GatorOscillator");
|
||||
assert!(!g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_collapses_both_bars() {
|
||||
// All three Alligator lines equal the constant median -> zero spread.
|
||||
let mut g = GatorOscillator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = g.batch(&candles);
|
||||
let last = out.last().unwrap().unwrap();
|
||||
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
|
||||
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trending_series_opens_the_mouth() {
|
||||
// On a clean trend the lines separate -> upper > 0, lower < 0.
|
||||
let mut g = GatorOscillator::classic();
|
||||
let candles: Vec<Candle> = (0_i64..80)
|
||||
.map(|i| candle(10.0 + i as f64, 9.0 + i as f64, i))
|
||||
.collect();
|
||||
let last = g.batch(&candles).last().unwrap().unwrap();
|
||||
assert!(last.upper > 0.0, "upper {} should be positive", last.upper);
|
||||
assert!(last.lower < 0.0, "lower {} should be negative", last.lower);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_longest_period() {
|
||||
let mut g = GatorOscillator::new(5, 3, 2).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
let out = g.batch(&candles);
|
||||
for v in out.iter().take(4) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut g = GatorOscillator::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, i)).collect();
|
||||
g.batch(&candles);
|
||||
assert!(g.is_ready());
|
||||
g.reset();
|
||||
assert!(!g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
|
||||
candle(base + 1.0, base - 1.0, i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = GatorOscillator::classic();
|
||||
let mut b = GatorOscillator::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
//! Intraday Momentum Index (IMI).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Momentum Index — Tushar Chande's RSI built from the open-to-close
|
||||
/// move instead of the close-to-close move.
|
||||
///
|
||||
/// For each bar the body is an up-move when `close > open` and a down-move
|
||||
/// otherwise; the IMI sums those bodies over `period` bars and forms the
|
||||
/// RSI-style ratio:
|
||||
///
|
||||
/// ```text
|
||||
/// gain = max(close - open, 0), loss = max(open - close, 0)
|
||||
/// IMI = 100 * Σ gain / (Σ gain + Σ loss) over the last `period` bars
|
||||
/// ```
|
||||
///
|
||||
/// Because it measures *intraday* (body) momentum rather than the gap-inclusive
|
||||
/// close-to-close change, the IMI is a candle-pattern-flavoured overbought /
|
||||
/// oversold gauge: persistent white bodies push it up, black bodies down. It is
|
||||
/// bounded in `[0, 100]`; a window of doji-like bars (no net bodies) returns the
|
||||
/// neutral `50`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, IntradayMomentumIndex, Indicator};
|
||||
///
|
||||
/// let mut imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = imi.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntradayMomentumIndex {
|
||||
period: usize,
|
||||
/// Per-bar `(gain, loss)` bodies, oldest at the front.
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_gain: f64,
|
||||
sum_loss: f64,
|
||||
}
|
||||
|
||||
impl IntradayMomentumIndex {
|
||||
/// Construct an IMI over `period` bars.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_gain: 0.0,
|
||||
sum_loss: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if the window is full.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.window.len() != self.period {
|
||||
return None;
|
||||
}
|
||||
let denom = self.sum_gain + self.sum_loss;
|
||||
if denom == 0.0 {
|
||||
Some(50.0)
|
||||
} else {
|
||||
Some(100.0 * self.sum_gain / denom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayMomentumIndex {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let body = candle.close - candle.open;
|
||||
let gain = if body > 0.0 { body } else { 0.0 };
|
||||
let loss = if body < 0.0 { -body } else { 0.0 };
|
||||
|
||||
if self.window.len() == self.period {
|
||||
let (old_g, old_l) = self.window.pop_front().expect("window full");
|
||||
self.sum_gain -= old_g;
|
||||
self.sum_loss -= old_l;
|
||||
}
|
||||
self.window.push_back((gain, loss));
|
||||
self.sum_gain += gain;
|
||||
self.sum_loss += loss;
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_gain = 0.0;
|
||||
self.sum_loss = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IMI"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, close: f64) -> Candle {
|
||||
let hi = open.max(close) + 1.0;
|
||||
let lo = open.min(close) - 1.0;
|
||||
Candle::new(open, hi, lo, close, 1.0, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
IntradayMomentumIndex::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
/// Cover the const accessor `period` and the Indicator-impl `warmup_period`
|
||||
/// + `name`.
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let imi = IntradayMomentumIndex::new(14).unwrap();
|
||||
assert_eq!(imi.period(), 14);
|
||||
assert_eq!(imi.warmup_period(), 14);
|
||||
assert_eq!(imi.name(), "IMI");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_up_bodies_is_one_hundred() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 13.0), candle(13.0, 14.0)];
|
||||
let out = imi.batch(&bars);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), 100.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_down_bodies_is_zero() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(14.0, 13.0), candle(13.0, 11.0), candle(11.0, 10.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_value_mixed_bodies() {
|
||||
// bodies: +1, -1, +2 -> sum_gain = 3, sum_loss = 1 -> 100*3/4 = 75.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 11.0), candle(11.0, 10.0), candle(10.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 75.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doji_window_is_neutral() {
|
||||
// close == open every bar -> no bodies -> neutral 50.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [candle(10.0, 10.0), candle(11.0, 11.0), candle(12.0, 12.0)];
|
||||
assert_relative_eq!(imi.batch(&bars)[2].unwrap(), 50.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_window() {
|
||||
// After [+1,-1,+2] (75) add +0 body window -> [-1,+2,0]: gain 2, loss 1 -> 66.67.
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
let bars = [
|
||||
candle(10.0, 11.0),
|
||||
candle(11.0, 10.0),
|
||||
candle(10.0, 12.0),
|
||||
candle(12.0, 12.0),
|
||||
];
|
||||
let out = imi.batch(&bars);
|
||||
assert_relative_eq!(out[3].unwrap(), 100.0 * 2.0 / 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut imi = IntradayMomentumIndex::new(3).unwrap();
|
||||
imi.batch(&[candle(10.0, 11.0), candle(11.0, 12.0), candle(12.0, 13.0)]);
|
||||
assert!(imi.is_ready());
|
||||
imi.reset();
|
||||
assert!(!imi.is_ready());
|
||||
assert_eq!(imi.update(candle(1.0, 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let bars: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
let base = 100.0 + f64::from(i);
|
||||
candle(base, base + (f64::from(i) * 0.5).sin())
|
||||
})
|
||||
.collect();
|
||||
let mut a = IntradayMomentumIndex::new(7).unwrap();
|
||||
let mut b = IntradayMomentumIndex::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&bars),
|
||||
bars.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Kase Permission Stochastic — a double-smoothed stochastic used as a
|
||||
//! trade-permission filter.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Kase Permission Stochastic output: a fast and a slow line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct KasePermissionStochasticOutput {
|
||||
/// Fast line: EMA of the raw `%K` over the smoothing period.
|
||||
pub fast: f64,
|
||||
/// Slow line: EMA of the fast line over the smoothing period.
|
||||
pub slow: f64,
|
||||
}
|
||||
|
||||
/// Cynthia Kase's Permission Stochastic: a stochastic oscillator smoothed twice,
|
||||
/// whose fast/slow relationship grants or denies "permission" to trade in the
|
||||
/// direction of a higher-timeframe signal.
|
||||
///
|
||||
/// ```text
|
||||
/// raw%K = 100 * (close - LL) / (HH - LL) over `length` (50 when HH == LL)
|
||||
/// fast = EMA(raw%K, smooth)
|
||||
/// slow = EMA(fast, smooth)
|
||||
/// ```
|
||||
///
|
||||
/// The raw stochastic is the usual `%K`, then an EMA produces the *fast* line
|
||||
/// and a second EMA of that produces the *slow* line. Kase uses the pair as a
|
||||
/// gate: a fast line above the slow line (and rising) gives permission for
|
||||
/// longs, the reverse for shorts. When the lookback window is perfectly flat
|
||||
/// (`HH == LL`), the raw stochastic is undefined and defaults to the neutral
|
||||
/// `50`.
|
||||
///
|
||||
/// Reference: Cynthia Kase, *Trading with the Odds*, 1996.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, KasePermissionStochastic};
|
||||
///
|
||||
/// let mut indicator = KasePermissionStochastic::new(9, 3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KasePermissionStochastic {
|
||||
length: usize,
|
||||
smooth: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
fast_ema: Ema,
|
||||
slow_ema: Ema,
|
||||
}
|
||||
|
||||
impl KasePermissionStochastic {
|
||||
/// Construct with the stochastic `length` and the EMA `smooth` period
|
||||
/// applied twice.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `length == 0` or `smooth == 0`.
|
||||
pub fn new(length: usize, smooth: usize) -> Result<Self> {
|
||||
if length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
length,
|
||||
smooth,
|
||||
window: VecDeque::with_capacity(length),
|
||||
fast_ema: Ema::new(smooth)?,
|
||||
slow_ema: Ema::new(smooth)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Cynthia Kase's classic parameters: `length = 9`, `smooth = 3`.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(9, 3).expect("classic Kase Permission Stochastic parameters are valid")
|
||||
}
|
||||
|
||||
/// Configured `(length, smooth)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.length, self.smooth)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KasePermissionStochastic {
|
||||
type Input = Candle;
|
||||
type Output = KasePermissionStochasticOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<KasePermissionStochasticOutput> {
|
||||
self.window.push_back((candle.high, candle.low));
|
||||
if self.window.len() > self.length {
|
||||
self.window.pop_front();
|
||||
}
|
||||
if self.window.len() < self.length {
|
||||
return None;
|
||||
}
|
||||
|
||||
let highest = self.window.iter().map(|w| w.0).fold(f64::MIN, f64::max);
|
||||
let lowest = self.window.iter().map(|w| w.1).fold(f64::MAX, f64::min);
|
||||
let raw_k = if highest > lowest {
|
||||
100.0 * (candle.close - lowest) / (highest - lowest)
|
||||
} else {
|
||||
50.0
|
||||
};
|
||||
|
||||
let fast = self.fast_ema.update(raw_k)?;
|
||||
let slow = self.slow_ema.update(fast)?;
|
||||
Some(KasePermissionStochasticOutput { fast, slow })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.fast_ema.reset();
|
||||
self.slow_ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// raw%K ready after `length` bars; each EMA seeds over `smooth` values.
|
||||
self.length + 2 * self.smooth - 2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.slow_ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KasePermissionStochastic"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
KasePermissionStochastic::new(0, 3),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
KasePermissionStochastic::new(9, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KasePermissionStochastic::classic();
|
||||
assert_eq!(k.periods(), (9, 3));
|
||||
// 9 + 2*3 - 2 = 13.
|
||||
assert_eq!(k.warmup_period(), 13);
|
||||
assert_eq!(k.name(), "KasePermissionStochastic");
|
||||
assert!(!k.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_at_expected_bar() {
|
||||
let mut k = KasePermissionStochastic::new(3, 2).unwrap();
|
||||
// warmup = 3 + 2*2 - 2 = 5 -> first value at input 5 (index 4).
|
||||
let candles: Vec<Candle> = (0..8).map(|i| candle(11.0, 9.0, 10.5, i)).collect();
|
||||
let out = k.batch(&candles);
|
||||
assert!(out[3].is_none());
|
||||
assert!(out[4].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_of_range_is_high() {
|
||||
// Close pinned at the top of a rising range -> raw%K near 100, both
|
||||
// smoothed lines high.
|
||||
let mut k = KasePermissionStochastic::new(5, 3).unwrap();
|
||||
let candles: Vec<Candle> = (0_i64..40)
|
||||
.map(|i| {
|
||||
let base = 100.0 + i as f64;
|
||||
candle(base + 2.0, base - 2.0, base + 2.0, i)
|
||||
})
|
||||
.collect();
|
||||
let last = k.batch(&candles).last().unwrap().unwrap();
|
||||
assert!(last.fast > 80.0, "fast {} should be high", last.fast);
|
||||
assert!(last.slow > 80.0, "slow {} should be high", last.slow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_defaults_to_neutral() {
|
||||
// Constant high/low/close -> HH == LL -> raw%K defaults to 50, so both
|
||||
// EMAs converge to 50.
|
||||
let mut k = KasePermissionStochastic::new(4, 2).unwrap();
|
||||
let candles: Vec<Candle> = (0..20).map(|i| candle(10.0, 10.0, 10.0, i)).collect();
|
||||
let last = k.batch(&candles).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last.fast, 50.0, epsilon = 1e-9);
|
||||
assert_relative_eq!(last.slow, 50.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KasePermissionStochastic::classic();
|
||||
let candles: Vec<Candle> = (0..40).map(|i| candle(11.0, 9.0, 10.5, i)).collect();
|
||||
k.batch(&candles);
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.2).sin() * 5.0;
|
||||
candle(base + 2.0, base - 2.0, base + (i as f64 * 0.3).cos(), i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = KasePermissionStochastic::classic();
|
||||
let mut b = KasePermissionStochastic::classic();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! MACD Histogram (standalone).
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::macd::MacdIndicator;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// MACD Histogram — the `macd − signal` bar of [`MacdIndicator`] as a
|
||||
/// standalone scalar indicator.
|
||||
///
|
||||
/// ```text
|
||||
/// macd = EMA(fast) − EMA(slow)
|
||||
/// signal = EMA(macd, signal)
|
||||
/// histogram = macd − signal
|
||||
/// ```
|
||||
///
|
||||
/// The histogram is the most actively traded part of MACD: it crosses zero
|
||||
/// exactly when the MACD line crosses its signal, and its slope measures
|
||||
/// whether that momentum is accelerating or fading. This wrapper exposes just
|
||||
/// that series for pipelines that want a plain `f64` stream rather than the
|
||||
/// full [`MacdOutput`](crate::MacdOutput); for the line and signal alongside
|
||||
/// it, use [`MacdIndicator`](crate::MacdIndicator) directly.
|
||||
///
|
||||
/// Standard parameters are `fast = 12`, `slow = 26`, `signal = 9`, so the
|
||||
/// first value lands after `slow + signal − 1` inputs — exactly when
|
||||
/// [`MacdIndicator`] emits its first full output.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, MacdHistogram};
|
||||
///
|
||||
/// let mut indicator = MacdHistogram::new(12, 26, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MacdHistogram {
|
||||
macd: MacdIndicator,
|
||||
}
|
||||
|
||||
impl MacdHistogram {
|
||||
/// Construct a MACD histogram with the given periods.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is zero, and
|
||||
/// [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
macd: MacdIndicator::new(fast, slow, signal)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default `(12, 26, 9)` configuration, matching every classical chart package.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(12, 26, 9).expect("classic MACD periods are valid")
|
||||
}
|
||||
|
||||
/// Configured periods as `(fast, slow, signal)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
self.macd.periods()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MacdHistogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
self.macd.update(input).map(|out| out.histogram)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.macd.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.macd.warmup_period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.macd.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"MacdHistogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
MacdHistogram::new(0, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
MacdHistogram::new(12, 26, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
MacdHistogram::new(26, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let osc = MacdHistogram::classic();
|
||||
assert_eq!(osc.periods(), (12, 26, 9));
|
||||
assert_eq!(osc.name(), "MacdHistogram");
|
||||
assert_eq!(osc.warmup_period(), 26 + 9 - 1);
|
||||
assert!(!osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_macd_histogram_field() {
|
||||
// The standalone series must be exactly MacdIndicator's histogram bar.
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.25).sin() * 8.0)
|
||||
.collect();
|
||||
let hist = MacdHistogram::classic().batch(&prices);
|
||||
let full = MacdIndicator::classic().batch(&prices);
|
||||
assert_eq!(hist.len(), full.len());
|
||||
for (h, m) in hist.iter().zip(full.iter()) {
|
||||
assert_eq!(h.is_some(), m.is_some());
|
||||
if let (Some(h), Some(m)) = (h, m) {
|
||||
assert_relative_eq!(*h, m.histogram, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut osc = MacdHistogram::new(3, 6, 3).unwrap();
|
||||
let warmup = osc.warmup_period();
|
||||
assert_eq!(warmup, 6 + 3 - 1);
|
||||
for i in 1..warmup {
|
||||
assert!(osc.update(100.0 + i as f64).is_none());
|
||||
}
|
||||
assert!(osc.update(100.0 + warmup as f64).is_some());
|
||||
assert!(osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
let mut osc = MacdHistogram::classic();
|
||||
let out = osc.batch(&[100.0_f64; 200]);
|
||||
let last = out.iter().rev().flatten().next().expect("emits a value");
|
||||
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=100)
|
||||
.map(|i| (f64::from(i) * 0.4).cos() * 10.0)
|
||||
.collect();
|
||||
let mut a = MacdHistogram::classic();
|
||||
let mut b = MacdHistogram::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = MacdHistogram::classic();
|
||||
osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -140,6 +145,7 @@ mod gain_loss_ratio;
|
||||
mod gap_side_by_side_white;
|
||||
mod garman_klass;
|
||||
mod gartley;
|
||||
mod gator_oscillator;
|
||||
mod generalized_dema;
|
||||
mod geometric_ma;
|
||||
mod golden_pocket;
|
||||
@@ -173,6 +179,7 @@ mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod intraday_momentum_index;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
@@ -181,6 +188,7 @@ mod jump_indicator;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
mod kase_permission_stochastic;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
mod kicking;
|
||||
@@ -206,6 +214,7 @@ mod ma_envelope;
|
||||
mod macd;
|
||||
mod macd_ext;
|
||||
mod macd_fix;
|
||||
mod macd_histogram;
|
||||
mod mama;
|
||||
mod market_facilitation_index;
|
||||
mod marubozu;
|
||||
@@ -260,10 +269,14 @@ mod plus_di;
|
||||
mod plus_dm;
|
||||
mod pmo;
|
||||
mod point_and_figure_bars;
|
||||
mod polarized_fractal_efficiency;
|
||||
mod ppo;
|
||||
mod ppo_histogram;
|
||||
mod profit_factor;
|
||||
mod psar;
|
||||
mod pvi;
|
||||
mod qqe;
|
||||
mod qstick;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
@@ -276,6 +289,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 +303,7 @@ mod rolling_percentile_rank;
|
||||
mod rolling_quantile;
|
||||
mod roofing_filter;
|
||||
mod rsi;
|
||||
mod rsx;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
@@ -325,6 +340,7 @@ mod step_trailing_stop;
|
||||
mod stick_sandwich;
|
||||
mod stoch_rsi;
|
||||
mod stochastic;
|
||||
mod stochastic_cci;
|
||||
mod super_smoother;
|
||||
mod super_trend;
|
||||
mod t3;
|
||||
@@ -358,6 +374,7 @@ mod time_of_day_return_profile;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod trend_label;
|
||||
mod trend_strength_index;
|
||||
mod treynor_ratio;
|
||||
mod triangle;
|
||||
mod trima;
|
||||
@@ -366,9 +383,11 @@ mod triple_top_bottom;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
mod tsf;
|
||||
mod tsf_oscillator;
|
||||
mod tsi;
|
||||
mod tsv;
|
||||
mod ttm_squeeze;
|
||||
mod ttm_trend;
|
||||
mod turn_of_month;
|
||||
mod tweezer;
|
||||
mod two_crows;
|
||||
@@ -396,6 +415,7 @@ mod vwap;
|
||||
mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod vzo;
|
||||
mod wave_pm;
|
||||
mod wave_trend;
|
||||
mod wedge;
|
||||
mod weighted_close;
|
||||
@@ -494,7 +514,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 +529,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 +553,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};
|
||||
@@ -543,6 +568,7 @@ pub use gain_loss_ratio::GainLossRatio;
|
||||
pub use gap_side_by_side_white::GapSideBySideWhite;
|
||||
pub use garman_klass::GarmanKlassVolatility;
|
||||
pub use gartley::Gartley;
|
||||
pub use gator_oscillator::{GatorOscillator, GatorOscillatorOutput};
|
||||
pub use generalized_dema::GeneralizedDema;
|
||||
pub use geometric_ma::GeometricMa;
|
||||
pub use golden_pocket::{GoldenPocket, GoldenPocketOutput};
|
||||
@@ -576,6 +602,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;
|
||||
@@ -584,6 +611,7 @@ pub use jump_indicator::JumpIndicator;
|
||||
pub use kagi_bars::{KagiBar, KagiBars};
|
||||
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
|
||||
pub use kama::Kama;
|
||||
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
|
||||
pub use kelly_criterion::KellyCriterion;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use kicking::Kicking;
|
||||
@@ -609,6 +637,7 @@ pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
|
||||
pub use macd::{MacdIndicator, MacdOutput};
|
||||
pub use macd_ext::{MaType, MacdExt};
|
||||
pub use macd_fix::MacdFix;
|
||||
pub use macd_histogram::MacdHistogram;
|
||||
pub use mama::{Mama, MamaOutput};
|
||||
pub use market_facilitation_index::MarketFacilitationIndex;
|
||||
pub use marubozu::Marubozu;
|
||||
@@ -663,10 +692,14 @@ pub use plus_di::PlusDi;
|
||||
pub use plus_dm::PlusDm;
|
||||
pub use pmo::Pmo;
|
||||
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
|
||||
pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
|
||||
pub use ppo::Ppo;
|
||||
pub use ppo_histogram::PpoHistogram;
|
||||
pub use profit_factor::ProfitFactor;
|
||||
pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use qqe::{Qqe, QqeOutput};
|
||||
pub use qstick::Qstick;
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
@@ -679,6 +712,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 +726,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 +763,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;
|
||||
@@ -761,6 +797,7 @@ pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProf
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use trend_label::TrendLabel;
|
||||
pub use trend_strength_index::TrendStrengthIndex;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
pub use triangle::Triangle;
|
||||
pub use trima::Trima;
|
||||
@@ -769,9 +806,11 @@ pub use triple_top_bottom::TripleTopBottom;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
pub use tsf::Tsf;
|
||||
pub use tsf_oscillator::TsfOscillator;
|
||||
pub use tsi::Tsi;
|
||||
pub use tsv::Tsv;
|
||||
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
|
||||
pub use ttm_trend::TtmTrend;
|
||||
pub use turn_of_month::TurnOfMonth;
|
||||
pub use tweezer::Tweezer;
|
||||
pub use two_crows::TwoCrows;
|
||||
@@ -799,6 +838,7 @@ pub use vwap::{RollingVwap, Vwap};
|
||||
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
|
||||
pub use vwma::Vwma;
|
||||
pub use vzo::Vzo;
|
||||
pub use wave_pm::WavePm;
|
||||
pub use wave_trend::{WaveTrend, WaveTrendOutput};
|
||||
pub use wedge::Wedge;
|
||||
pub use weighted_close::WeightedClose;
|
||||
@@ -880,6 +920,16 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Rocp",
|
||||
"Rocr",
|
||||
"Rocr100",
|
||||
"DisparityIndex",
|
||||
"FisherRsi",
|
||||
"Rsx",
|
||||
"DynamicMomentumIndex",
|
||||
"StochasticCci",
|
||||
"Rmi",
|
||||
"DerivativeOscillator",
|
||||
"ElderRay",
|
||||
"IntradayMomentumIndex",
|
||||
"Qqe",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -906,6 +956,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"MinusDi",
|
||||
"Dx",
|
||||
"TrendLabel",
|
||||
"TtmTrend",
|
||||
"TrendStrengthIndex",
|
||||
"Qstick",
|
||||
"PolarizedFractalEfficiency",
|
||||
"WavePm",
|
||||
"GatorOscillator",
|
||||
"KasePermissionStochastic",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -922,6 +979,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"ZeroLagMacd",
|
||||
"ElderImpulse",
|
||||
"Stc",
|
||||
"TsfOscillator",
|
||||
"MacdHistogram",
|
||||
"PpoHistogram",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1363,6 +1423,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, 423, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Polarized Fractal Efficiency (PFE).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Polarized Fractal Efficiency: how efficiently price travelled over the last
|
||||
/// `period` bars, signed by direction and smoothed by an EMA.
|
||||
///
|
||||
/// ```text
|
||||
/// straight = sqrt((C_t - C_{t-n})^2 + n^2) (direct distance over n bars)
|
||||
/// path = Σ_{i=1..n} sqrt((C_{t-i+1} - C_{t-i})^2 + 1) (sum of single-bar steps)
|
||||
/// raw = 100 * sign(C_t - C_{t-n}) * straight / path
|
||||
/// PFE = EMA(raw, smoothing)
|
||||
/// ```
|
||||
///
|
||||
/// The ratio `straight / path` is the fractal efficiency: it is `1` when price
|
||||
/// moved in a perfectly straight line and falls toward `0` as the path becomes
|
||||
/// jagged. Polarizing it by the sign of the net move pushes the reading to
|
||||
/// `+100` for an efficient up-move and `-100` for an efficient down-move, with
|
||||
/// choppy markets oscillating near zero. Because each single-bar step and the
|
||||
/// `n`-bar diagonal both carry the bar count on the x-axis (`+1` and `+n^2`),
|
||||
/// the path length is always `>= n`, so the denominator can never be zero.
|
||||
///
|
||||
/// Reference: Hans Hannula, *Stocks & Commodities*, 1994.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PolarizedFractalEfficiency};
|
||||
///
|
||||
/// let mut indicator = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PolarizedFractalEfficiency {
|
||||
period: usize,
|
||||
smoothing: usize,
|
||||
closes: VecDeque<f64>,
|
||||
prev_close: Option<f64>,
|
||||
segments: VecDeque<f64>,
|
||||
segment_sum: f64,
|
||||
ema: Ema,
|
||||
}
|
||||
|
||||
impl PolarizedFractalEfficiency {
|
||||
/// Construct a PFE with the fractal lookback `period` and the EMA
|
||||
/// `smoothing` period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0` or `smoothing == 0`.
|
||||
pub fn new(period: usize, smoothing: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
smoothing,
|
||||
closes: VecDeque::with_capacity(period + 1),
|
||||
prev_close: None,
|
||||
segments: VecDeque::with_capacity(period),
|
||||
segment_sum: 0.0,
|
||||
ema: Ema::new(smoothing)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, smoothing)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.period, self.smoothing)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PolarizedFractalEfficiency {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, close: f64) -> Option<f64> {
|
||||
if let Some(prev) = self.prev_close {
|
||||
let diff = close - prev;
|
||||
let segment = diff.mul_add(diff, 1.0).sqrt();
|
||||
self.segment_sum += segment;
|
||||
self.segments.push_back(segment);
|
||||
if self.segments.len() > self.period {
|
||||
self.segment_sum -= self.segments.pop_front().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
self.prev_close = Some(close);
|
||||
|
||||
self.closes.push_back(close);
|
||||
if self.closes.len() > self.period + 1 {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
if self.closes.len() <= self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let oldest = *self.closes.front().unwrap_or(&close);
|
||||
let net = close - oldest;
|
||||
let direction = if net > 0.0 {
|
||||
1.0
|
||||
} else if net < 0.0 {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let span = self.period as f64;
|
||||
let straight = net.mul_add(net, span * span).sqrt();
|
||||
let raw = 100.0 * direction * straight / self.segment_sum;
|
||||
self.ema.update(raw)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.prev_close = None;
|
||||
self.segments.clear();
|
||||
self.segment_sum = 0.0;
|
||||
self.ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + self.smoothing
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PolarizedFractalEfficiency"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
PolarizedFractalEfficiency::new(0, 5),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PolarizedFractalEfficiency::new(10, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let pfe = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
assert_eq!(pfe.periods(), (10, 5));
|
||||
assert_eq!(pfe.warmup_period(), 15);
|
||||
assert_eq!(pfe.name(), "PolarizedFractalEfficiency");
|
||||
assert!(!pfe.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_after_period_plus_smoothing() {
|
||||
let mut pfe = PolarizedFractalEfficiency::new(4, 2).unwrap();
|
||||
// raw needs period+1 = 5 closes; EMA(2) needs 2 raws -> first value at
|
||||
// input 6 (index 5).
|
||||
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
|
||||
let out = pfe.batch(&inputs);
|
||||
assert!(out[4].is_none());
|
||||
assert!(out[5].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_uptrend_is_strongly_positive() {
|
||||
// A straight ramp: every step is +1, the diagonal is maximally
|
||||
// efficient, so PFE saturates near +100.
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..30).map(f64::from).collect();
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(last > 99.0, "pfe {last} should be near +100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_downtrend_is_strongly_negative() {
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..30).map(|i| -f64::from(i)).collect();
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(last < -99.0, "pfe {last} should be near -100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_returns_zero() {
|
||||
// No net move over the window -> direction 0 -> raw 0 -> PFE 0.
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs = [10.0; 20];
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choppy_market_is_inefficient() {
|
||||
// A sawtooth whip: the net move is tiny relative to the jagged path, so
|
||||
// efficiency stays well below the +-100 saturation of a clean trend.
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..40)
|
||||
.map(|i| if i % 2 == 0 { 100.0 } else { 102.0 })
|
||||
.collect();
|
||||
let last = pfe.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(
|
||||
last.abs() < 60.0,
|
||||
"choppy pfe {last} should be far from +-100"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pfe = PolarizedFractalEfficiency::new(5, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..30).map(f64::from).collect();
|
||||
pfe.batch(&inputs);
|
||||
assert!(pfe.is_ready());
|
||||
pfe.reset();
|
||||
assert!(!pfe.is_ready());
|
||||
assert_eq!(pfe.periods(), (5, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
let mut b = PolarizedFractalEfficiency::new(10, 5).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&inputs),
|
||||
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Percentage Price Oscillator Histogram.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::indicators::ppo::Ppo;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// PPO Histogram — the `ppo − signal` bar of the Percentage Price Oscillator.
|
||||
///
|
||||
/// ```text
|
||||
/// ppo = 100 · (EMA_fast − EMA_slow) / EMA_slow
|
||||
/// signal = EMA(ppo, signal_period)
|
||||
/// histogram = ppo − signal
|
||||
/// ```
|
||||
///
|
||||
/// [`Ppo`](crate::Ppo) itself only emits the percentage line; this indicator
|
||||
/// adds the classic 9-period signal EMA on top and reports the resulting
|
||||
/// zero-centered histogram. Because PPO is scale-free (the EMA gap is divided
|
||||
/// by the slow EMA), the histogram is **comparable across instruments** — a
|
||||
/// PPO histogram of `0.4` means the same relative momentum on any asset, unlike
|
||||
/// the price-unit [`MacdHistogram`](crate::MacdHistogram).
|
||||
///
|
||||
/// With Appel's defaults `fast = 12`, `slow = 26`, `signal = 9`, the first
|
||||
/// value lands after `slow + signal − 1` inputs — the point at which the slow
|
||||
/// EMA and then the signal EMA are both seeded.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, PpoHistogram};
|
||||
///
|
||||
/// let mut indicator = PpoHistogram::new(12, 26, 9).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..80 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PpoHistogram {
|
||||
ppo: Ppo,
|
||||
signal_ema: Ema,
|
||||
signal_period: usize,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl PpoHistogram {
|
||||
/// Construct a PPO histogram with the `fast`/`slow` EMA periods and the
|
||||
/// `signal` EMA period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if any period is `0`, or
|
||||
/// [`Error::InvalidPeriod`] if `fast >= slow`.
|
||||
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<Self> {
|
||||
if signal == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
ppo: Ppo::new(fast, slow)?,
|
||||
signal_ema: Ema::new(signal)?,
|
||||
signal_period: signal,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Default `(12, 26, 9)` configuration.
|
||||
pub fn classic() -> Self {
|
||||
Self::new(12, 26, 9).expect("classic PPO periods are valid")
|
||||
}
|
||||
|
||||
/// Configured periods as `(fast, slow, signal)`.
|
||||
pub const fn periods(&self) -> (usize, usize, usize) {
|
||||
let (fast, slow) = self.ppo.periods();
|
||||
(fast, slow, self.signal_period)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PpoHistogram {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
// Guard before touching either stage so a non-finite input never
|
||||
// advances the signal EMA on a stale, re-fed PPO value.
|
||||
if !input.is_finite() {
|
||||
return self.current;
|
||||
}
|
||||
let ppo = self.ppo.update(input)?;
|
||||
let signal = self.signal_ema.update(ppo)?;
|
||||
let histogram = ppo - signal;
|
||||
self.current = Some(histogram);
|
||||
Some(histogram)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ppo.reset();
|
||||
self.signal_ema.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Slow EMA seeds the PPO, then the signal EMA needs `signal − 1` more.
|
||||
self.ppo.warmup_period() + self.signal_period - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PpoHistogram"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_periods() {
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(0, 26, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(12, 0, 9),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(12, 26, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
PpoHistogram::new(26, 12, 9),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let osc = PpoHistogram::classic();
|
||||
assert_eq!(osc.periods(), (12, 26, 9));
|
||||
assert_eq!(osc.name(), "PpoHistogram");
|
||||
assert_eq!(osc.warmup_period(), 26 + 9 - 1);
|
||||
assert_eq!(osc.value(), None);
|
||||
assert!(!osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_ppo_minus_signal_ema() {
|
||||
// The histogram must equal PPO minus an EMA(signal) composed by hand.
|
||||
let prices: Vec<f64> = (1..=120)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
|
||||
.collect();
|
||||
let got = PpoHistogram::new(12, 26, 9).unwrap().batch(&prices);
|
||||
|
||||
let mut ppo = Ppo::new(12, 26).unwrap();
|
||||
let mut sig = Ema::new(9).unwrap();
|
||||
let mut expected = Vec::with_capacity(prices.len());
|
||||
for p in &prices {
|
||||
let out = ppo
|
||||
.update(*p)
|
||||
.and_then(|line| sig.update(line).map(|signal| line - signal));
|
||||
expected.push(out);
|
||||
}
|
||||
assert_eq!(got, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_warmup_period() {
|
||||
let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
|
||||
let warmup = osc.warmup_period();
|
||||
assert_eq!(warmup, 6 + 3 - 1);
|
||||
for i in 1..warmup {
|
||||
assert!(osc.update(100.0 + i as f64).is_none());
|
||||
}
|
||||
assert!(osc.update(100.0 + warmup as f64).is_some());
|
||||
assert!(osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_converges_to_zero() {
|
||||
let mut osc = PpoHistogram::classic();
|
||||
let out = osc.batch(&[100.0_f64; 200]);
|
||||
let last = out.iter().rev().flatten().next().expect("emits a value");
|
||||
assert_relative_eq!(*last, 0.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut osc = PpoHistogram::new(3, 6, 3).unwrap();
|
||||
let out = osc.batch(&(1..=40).map(f64::from).collect::<Vec<_>>());
|
||||
let before = *out.last().unwrap();
|
||||
assert!(before.is_some());
|
||||
assert_eq!(osc.update(f64::NAN), before);
|
||||
assert_eq!(osc.update(f64::INFINITY), before);
|
||||
assert_eq!(osc.value(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=100)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.4).cos() * 10.0)
|
||||
.collect();
|
||||
let mut a = PpoHistogram::classic();
|
||||
let mut b = PpoHistogram::classic();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = PpoHistogram::classic();
|
||||
osc.batch(&(1..=80).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
}
|
||||
}
|
||||
@@ -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,168 @@
|
||||
//! Qstick — Tushar Chande's measure of buying vs. selling pressure.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Qstick: the simple moving average of the body `close - open` over `period`
|
||||
/// bars.
|
||||
///
|
||||
/// Positive values indicate a run of bars that closed above their open (net
|
||||
/// buying pressure); negative values indicate net selling pressure. A zero
|
||||
/// crossing is read as a shift in short-term sentiment.
|
||||
///
|
||||
/// ```text
|
||||
/// Qstick = SMA(close - open, period)
|
||||
/// ```
|
||||
///
|
||||
/// Reference: Tushar Chande, *The New Technical Trader*, 1994.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, Qstick};
|
||||
///
|
||||
/// let mut indicator = Qstick::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 2.0, base - 1.0, base + 1.0, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Qstick {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl Qstick {
|
||||
/// Construct a Qstick with the given averaging period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured averaging period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Qstick {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.sma.update(candle.close - candle.open)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Qstick"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn candle(open: f64, close: f64, ts: i64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(Qstick::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let q = Qstick::new(5).unwrap();
|
||||
assert_eq!(q.period(), 5);
|
||||
assert_eq!(q.warmup_period(), 5);
|
||||
assert_eq!(q.name(), "Qstick");
|
||||
assert!(!q.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut q = Qstick::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..3).map(|i| candle(10.0, 11.0, i)).collect();
|
||||
let out = q.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_bodies_yield_the_body() {
|
||||
// Every bar closes 1.5 above its open -> Qstick converges to 1.5.
|
||||
let mut q = Qstick::new(4).unwrap();
|
||||
let candles: Vec<Candle> = (0..10).map(|i| candle(10.0, 11.5, i)).collect();
|
||||
let out = q.batch(&candles);
|
||||
assert_relative_eq!(out.last().unwrap().unwrap(), 1.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selling_pressure_is_negative() {
|
||||
let mut q = Qstick::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 10.0, i)).collect();
|
||||
let last = q.batch(&candles).last().unwrap().unwrap();
|
||||
assert!(last < 0.0, "qstick {last} should be negative");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut q = Qstick::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(10.0, 11.0, i)).collect();
|
||||
q.batch(&candles);
|
||||
assert!(q.is_ready());
|
||||
q.reset();
|
||||
assert!(!q.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40_i64)
|
||||
.map(|i| {
|
||||
candle(
|
||||
100.0 + (i as f64 * 0.3).sin(),
|
||||
100.0 + (i as f64 * 0.4).cos(),
|
||||
i,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Qstick::new(7).unwrap();
|
||||
let mut b = Qstick::new(7).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Trend Strength Index — the signed coefficient of determination of a linear
|
||||
//! regression of price against time.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Trend Strength Index: fits an ordinary-least-squares line to the last
|
||||
/// `period` prices against their bar index and reports the coefficient of
|
||||
/// determination `r^2`, signed by the slope of the fit.
|
||||
///
|
||||
/// ```text
|
||||
/// regress y = close on x = 0..period-1
|
||||
/// r^2 = (n·Σxy − Σx·Σy)^2 / [ (n·Σx² − (Σx)²)(n·Σy² − (Σy)²) ]
|
||||
/// TSI = sign(slope) · r^2 (slope sign = sign of n·Σxy − Σx·Σy)
|
||||
/// ```
|
||||
///
|
||||
/// `r^2` in `[0, 1]` measures how well a straight line explains the price over
|
||||
/// the window — how *trendy* the segment is, regardless of direction. Carrying
|
||||
/// the slope sign turns it into a directional reading in `[-1, 1]`: values near
|
||||
/// `+1` are a strong, clean uptrend; near `-1` a strong downtrend; near `0` a
|
||||
/// flat or noisy market with no linear structure. A window of constant prices
|
||||
/// (zero variance in `y`) has no defined trend and returns `0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, TrendStrengthIndex};
|
||||
///
|
||||
/// let mut indicator = TrendStrengthIndex::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// // A clean ramp is a perfect uptrend -> r^2 = 1.
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TrendStrengthIndex {
|
||||
period: usize,
|
||||
buf: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl TrendStrengthIndex {
|
||||
/// Construct a Trend Strength Index over the given window.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`, or [`Error::InvalidPeriod`]
|
||||
/// if `period == 1` (a regression needs at least two points).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if period == 1 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "period must be >= 2 for a regression",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
buf: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TrendStrengthIndex {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, price: f64) -> Option<f64> {
|
||||
self.buf.push_back(price);
|
||||
if self.buf.len() > self.period {
|
||||
self.buf.pop_front();
|
||||
}
|
||||
if self.buf.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
|
||||
let count = self.period as f64;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_yy = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
for (idx, &price) in self.buf.iter().enumerate() {
|
||||
let x = idx as f64;
|
||||
sum_x += x;
|
||||
sum_xx += x * x;
|
||||
sum_y += price;
|
||||
sum_yy += price * price;
|
||||
sum_xy += x * price;
|
||||
}
|
||||
|
||||
let cov = count.mul_add(sum_xy, -(sum_x * sum_y));
|
||||
let var_x = count.mul_add(sum_xx, -(sum_x * sum_x));
|
||||
let var_y = count.mul_add(sum_yy, -(sum_y * sum_y));
|
||||
if var_y <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let r2 = (cov * cov) / (var_x * var_y);
|
||||
Some(if cov >= 0.0 { r2 } else { -r2 })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.buf.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.buf.len() >= self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TrendStrengthIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_period() {
|
||||
assert!(matches!(TrendStrengthIndex::new(0), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
TrendStrengthIndex::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let tsi = TrendStrengthIndex::new(20).unwrap();
|
||||
assert_eq!(tsi.period(), 20);
|
||||
assert_eq!(tsi.warmup_period(), 20);
|
||||
assert_eq!(tsi.name(), "TrendStrengthIndex");
|
||||
assert!(!tsi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_at_period() {
|
||||
let mut tsi = TrendStrengthIndex::new(4).unwrap();
|
||||
let inputs: Vec<f64> = (0..6).map(f64::from).collect();
|
||||
let out = tsi.batch(&inputs);
|
||||
assert!(out[2].is_none());
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_uptrend_is_plus_one() {
|
||||
let mut tsi = TrendStrengthIndex::new(10).unwrap();
|
||||
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_downtrend_is_minus_one() {
|
||||
let mut tsi = TrendStrengthIndex::new(10).unwrap();
|
||||
let inputs: Vec<f64> = (0..10).map(|i| 100.0 - f64::from(i)).collect();
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_returns_zero() {
|
||||
let mut tsi = TrendStrengthIndex::new(8).unwrap();
|
||||
let inputs = [42.0; 12];
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noisy_trend_is_between() {
|
||||
// An upward drift with noise: positive but not a perfect fit.
|
||||
let mut tsi = TrendStrengthIndex::new(12).unwrap();
|
||||
let inputs: Vec<f64> = (0..12)
|
||||
.map(|i| f64::from(i) + if i % 2 == 0 { 0.0 } else { 3.0 })
|
||||
.collect();
|
||||
let last = tsi.batch(&inputs).last().unwrap().unwrap();
|
||||
assert!(last > 0.0 && last < 1.0, "tsi {last} should be in (0, 1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tsi = TrendStrengthIndex::new(10).unwrap();
|
||||
let inputs: Vec<f64> = (0..10).map(f64::from).collect();
|
||||
tsi.batch(&inputs);
|
||||
assert!(tsi.is_ready());
|
||||
tsi.reset();
|
||||
assert!(!tsi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = TrendStrengthIndex::new(15).unwrap();
|
||||
let mut b = TrendStrengthIndex::new(15).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&inputs),
|
||||
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Time Series Forecast Oscillator (TSF Oscillator).
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::tsf::Tsf;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Time Series Forecast Oscillator — the percentage gap between the close and
|
||||
/// the **one-bar-ahead** time-series forecast of the close.
|
||||
///
|
||||
/// ```text
|
||||
/// TSFOsc_t = 100 · (close_t − TSF(close, period)_t) / close_t
|
||||
/// ```
|
||||
///
|
||||
/// where [`Tsf`](crate::Tsf) projects the rolling least-squares line one bar
|
||||
/// past the window (`a + b·period`). It is the close-relative companion to
|
||||
/// [`Cfo`](crate::Cfo), which measures the same percentage gap against the
|
||||
/// regression value at the *current* bar (`a + b·(period − 1)`). Because `TSF`
|
||||
/// advances one bar further than `LinearRegression`, the two differ by exactly
|
||||
/// the slope term `100·b/close`: on a trending series `TSFOsc` reads more
|
||||
/// negative in an uptrend (the forecast has already stepped above price) and
|
||||
/// more positive in a downtrend.
|
||||
///
|
||||
/// Positive readings mean the close sits *above* its forward forecast (price
|
||||
/// has overshot the projected trend); negative readings mean it sits below.
|
||||
/// Wraps the existing `Tsf` so the warmup matches.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, TsfOscillator};
|
||||
///
|
||||
/// let mut indicator = TsfOscillator::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TsfOscillator {
|
||||
period: usize,
|
||||
tsf: Tsf,
|
||||
current: Option<f64>,
|
||||
}
|
||||
|
||||
impl TsfOscillator {
|
||||
/// Construct a new TSF oscillator over `period` inputs.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
|
||||
/// undefined for fewer than two points.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "TSF oscillator needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
tsf: Tsf::new(period)?,
|
||||
current: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TsfOscillator {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: f64) -> Option<f64> {
|
||||
let forecast = self.tsf.update(input)?;
|
||||
// Hold the previous value if the close is zero — the percentage form
|
||||
// is undefined and a return of inf would propagate badly.
|
||||
if input == 0.0 {
|
||||
return self.current;
|
||||
}
|
||||
let value = 100.0 * (input - forecast) / input;
|
||||
self.current = Some(value);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.tsf.reset();
|
||||
self.current = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.current.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TsfOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_short_period() {
|
||||
assert!(matches!(
|
||||
TsfOscillator::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TsfOscillator::new(0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let osc = TsfOscillator::new(14).unwrap();
|
||||
assert_eq!(osc.period(), 14);
|
||||
assert_eq!(osc.warmup_period(), 14);
|
||||
assert_eq!(osc.name(), "TsfOscillator");
|
||||
assert!(!osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// period 3 over [1, 2, 9]: fit y = 0 + 4x, one-bar-ahead TSF at x = 3
|
||||
// is 12. With close = 9, TSFOsc = 100·(9 − 12)/9 = −33.3333…%.
|
||||
let mut osc = TsfOscillator::new(3).unwrap();
|
||||
let out = osc.batch(&[1.0_f64, 2.0, 9.0]);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert_relative_eq!(out[2].unwrap(), -100.0 / 3.0, epsilon = 1e-9);
|
||||
assert!(osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_series_yields_zero() {
|
||||
// On a flat series the regression slope is 0, so the one-bar-ahead TSF
|
||||
// equals the constant and close − forecast is exactly 0.
|
||||
let mut osc = TsfOscillator::new(5).unwrap();
|
||||
let out = osc.batch(&[42.0_f64; 30]);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_uptrend_reads_negative() {
|
||||
// Unlike CFO (evaluated at the current bar), the forecast steps one bar
|
||||
// ahead, so on a rising line the projection sits above the close and the
|
||||
// oscillator is negative: TSFOsc = −100·slope/close.
|
||||
let mut osc = TsfOscillator::new(5).unwrap();
|
||||
let prices: Vec<f64> = (1..=20).map(|i| f64::from(i) * 2.0).collect();
|
||||
let out = osc.batch(&prices);
|
||||
for v in out.iter().skip(4).flatten() {
|
||||
assert!(*v < 0.0, "uptrend forecast overshoots close, got {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_first_value_at_period() {
|
||||
let mut osc = TsfOscillator::new(3).unwrap();
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
assert_eq!(osc.update(2.0), None);
|
||||
assert!(osc.update(3.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let prices: Vec<f64> = (1..=80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = TsfOscillator::new(14).unwrap();
|
||||
let mut b = TsfOscillator::new(14).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&prices),
|
||||
prices.iter().map(|p| b.update(*p)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = TsfOscillator::new(5).unwrap();
|
||||
osc.batch(&(1..=20).map(f64::from).collect::<Vec<_>>());
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
assert_eq!(osc.update(1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_close_holds_value() {
|
||||
let mut osc = TsfOscillator::new(3).unwrap();
|
||||
osc.batch(&[1.0_f64, 2.0, 3.0]);
|
||||
let before = osc.current;
|
||||
assert_eq!(osc.update(0.0), before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! TTM Trend — John Carter's bar-coloring trend filter.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::indicators::sma::Sma;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// TTM Trend: compares the current close to the simple moving average of the
|
||||
/// recent median prices `(high + low) / 2`. A close above that reference colors
|
||||
/// the bar as an uptrend (`+1.0`); a close at or below it as a downtrend
|
||||
/// (`-1.0`).
|
||||
///
|
||||
/// ```text
|
||||
/// reference = SMA((high + low) / 2, period)
|
||||
/// TTM Trend = +1 if close > reference
|
||||
/// -1 otherwise
|
||||
/// ```
|
||||
///
|
||||
/// The classic TTM Trend uses the trailing six bars. The signal is a regime
|
||||
/// label rather than a level: it stays `None` during warmup and then emits
|
||||
/// `±1.0` on every bar.
|
||||
///
|
||||
/// Reference: John Carter, *Mastering the Trade*, 2005.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, TtmTrend};
|
||||
///
|
||||
/// let mut indicator = TtmTrend::new(6).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let base = 100.0 + f64::from(i);
|
||||
/// let candle =
|
||||
/// Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1.0, i64::from(i)).unwrap();
|
||||
/// last = indicator.update(candle);
|
||||
/// }
|
||||
/// assert_eq!(last, Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TtmTrend {
|
||||
period: usize,
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl TtmTrend {
|
||||
/// Construct a TTM Trend over the given lookback.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::error::Error::PeriodZero) if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
period,
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for TtmTrend {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let median = f64::midpoint(candle.high, candle.low);
|
||||
let reference = self.sma.update(median)?;
|
||||
Some(if candle.close > reference { 1.0 } else { -1.0 })
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"TtmTrend"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
|
||||
Candle::new(f64::midpoint(high, low), high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(TtmTrend::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let t = TtmTrend::new(6).unwrap();
|
||||
assert_eq!(t.period(), 6);
|
||||
assert_eq!(t.warmup_period(), 6);
|
||||
assert_eq!(t.name(), "TtmTrend");
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_then_emits() {
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..3).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
|
||||
let out = t.batch(&candles);
|
||||
assert!(out[0].is_none());
|
||||
assert!(out[1].is_none());
|
||||
assert!(out[2].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_above_reference_is_uptrend() {
|
||||
// Close (12) sits above the median reference (13 + 9) / 2 = 11 -> +1.
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
|
||||
assert_eq!(t.batch(&candles).last().unwrap().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_at_or_below_reference_is_downtrend() {
|
||||
// Constant median 10, close equal to the reference -> not strictly above -> -1.
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(11.0, 9.0, 10.0, i)).collect();
|
||||
assert_eq!(t.batch(&candles).last().unwrap().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut t = TtmTrend::new(3).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|i| candle(13.0, 9.0, 12.0, i)).collect();
|
||||
t.batch(&candles);
|
||||
assert!(t.is_ready());
|
||||
t.reset();
|
||||
assert!(!t.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..40_i64)
|
||||
.map(|i| {
|
||||
let base = 100.0 + (i as f64 * 0.25).sin() * 4.0;
|
||||
candle(base + 1.0, base - 1.0, base + (i as f64 * 0.5).cos(), i)
|
||||
})
|
||||
.collect();
|
||||
let mut a = TtmTrend::new(6).unwrap();
|
||||
let mut b = TtmTrend::new(6).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|c| b.update(*c)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! Wave PM — Cynthia Kase's peak-momentum statistic (Wickra reconstruction).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::indicators::ema::Ema;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Wave PM (Peak Momentum): a `0..100` statistic that rises when the current
|
||||
/// `length`-bar momentum is large relative to its own recent energy — Cynthia
|
||||
/// Kase's gauge of how "peaked" the move is.
|
||||
///
|
||||
/// ```text
|
||||
/// m = close_t - close_{t-length} (length-bar momentum)
|
||||
/// energy = EMA(m^2, length) (mean squared momentum)
|
||||
/// raw = 1 - exp( -m^2 / (2 * energy) ) (0 if energy == 0)
|
||||
/// WavePM = 100 * EMA(raw, smoothing)
|
||||
/// ```
|
||||
///
|
||||
/// The momentum `m` is normalised by its recent variance (`energy`): a move that
|
||||
/// merely matches its typical energy sits at the baseline
|
||||
/// `100·(1 − e^{−1/2}) ≈ 39.35`, while a momentum *spike* that exceeds recent
|
||||
/// energy drives the reading toward `100`. A flat market (`m = 0`) reads `0`.
|
||||
/// High readings mark a peaking, possibly exhausted move rather than a fresh one.
|
||||
///
|
||||
/// Kase's published `WavePM` is platform-specific; this is Wickra's faithful
|
||||
/// reconstruction of its variance-normalised peak-momentum form. The exact
|
||||
/// constants differ from any single vendor implementation, but the shape — flat
|
||||
/// at zero, a fixed baseline on a steady trend, and saturation on an
|
||||
/// acceleration — matches the indicator's intent.
|
||||
///
|
||||
/// Reference: Cynthia Kase, *Trading with the Odds*, 1996 (Wickra reconstruction).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, WavePm};
|
||||
///
|
||||
/// let mut indicator = WavePm::new(10, 3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(100.0 + f64::from(i));
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WavePm {
|
||||
length: usize,
|
||||
smoothing: usize,
|
||||
closes: VecDeque<f64>,
|
||||
energy_ema: Ema,
|
||||
smooth_ema: Ema,
|
||||
}
|
||||
|
||||
impl WavePm {
|
||||
/// Construct a Wave PM with the momentum `length` and the output `smoothing`
|
||||
/// period.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `length == 0` or `smoothing == 0`.
|
||||
pub fn new(length: usize, smoothing: usize) -> Result<Self> {
|
||||
if length == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
length,
|
||||
smoothing,
|
||||
closes: VecDeque::with_capacity(length + 1),
|
||||
energy_ema: Ema::new(length)?,
|
||||
smooth_ema: Ema::new(smoothing)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(length, smoothing)`.
|
||||
pub const fn periods(&self) -> (usize, usize) {
|
||||
(self.length, self.smoothing)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for WavePm {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, close: f64) -> Option<f64> {
|
||||
self.closes.push_back(close);
|
||||
if self.closes.len() > self.length + 1 {
|
||||
self.closes.pop_front();
|
||||
}
|
||||
if self.closes.len() <= self.length {
|
||||
return None;
|
||||
}
|
||||
|
||||
let oldest = *self.closes.front().unwrap_or(&close);
|
||||
let momentum = close - oldest;
|
||||
let energy = self.energy_ema.update(momentum * momentum)?;
|
||||
let raw = if energy <= 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
1.0 - (-(momentum * momentum) / (2.0 * energy)).exp()
|
||||
};
|
||||
self.smooth_ema.update(raw).map(|v| v * 100.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.closes.clear();
|
||||
self.energy_ema.reset();
|
||||
self.smooth_ema.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2 * self.length + self.smoothing - 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.smooth_ema.is_ready()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"WavePm"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(WavePm::new(0, 3), Err(Error::PeriodZero)));
|
||||
assert!(matches!(WavePm::new(10, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let w = WavePm::new(10, 3).unwrap();
|
||||
assert_eq!(w.periods(), (10, 3));
|
||||
// 2*10 + 3 - 1 = 22.
|
||||
assert_eq!(w.warmup_period(), 22);
|
||||
assert_eq!(w.name(), "WavePm");
|
||||
assert!(!w.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_emits_at_expected_bar() {
|
||||
let mut w = WavePm::new(3, 2).unwrap();
|
||||
// warmup = 2*3 + 2 - 1 = 7 -> first value at input 7 (index 6).
|
||||
let inputs: Vec<f64> = (0..12).map(f64::from).collect();
|
||||
let out = w.batch(&inputs);
|
||||
assert!(out[5].is_none());
|
||||
assert!(out[6].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_market_reads_zero() {
|
||||
let mut w = WavePm::new(4, 2).unwrap();
|
||||
let inputs = [50.0; 20];
|
||||
let last = w.batch(&inputs).last().unwrap().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steady_trend_reads_baseline() {
|
||||
// Constant-slope ramp: momentum equals its own energy every bar, so the
|
||||
// reading pins to the baseline 100*(1 - e^-0.5).
|
||||
let mut w = WavePm::new(10, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
|
||||
let last = w.batch(&inputs).last().unwrap().unwrap();
|
||||
let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
|
||||
assert_relative_eq!(last, baseline, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acceleration_reads_above_baseline() {
|
||||
// A quadratic path: momentum keeps outrunning its lagged energy, so the
|
||||
// reading sits above the steady-trend baseline.
|
||||
let mut w = WavePm::new(10, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i * i) * 0.1).collect();
|
||||
let last = w.batch(&inputs).last().unwrap().unwrap();
|
||||
let baseline = 100.0 * (1.0 - (-0.5_f64).exp());
|
||||
assert!(
|
||||
last > baseline,
|
||||
"accelerating wpm {last} should exceed {baseline}"
|
||||
);
|
||||
assert!(last <= 100.0, "wpm {last} must stay <= 100");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut w = WavePm::new(10, 3).unwrap();
|
||||
let inputs: Vec<f64> = (0..60).map(|i| f64::from(i) * 5.0).collect();
|
||||
w.batch(&inputs);
|
||||
assert!(w.is_ready());
|
||||
w.reset();
|
||||
assert!(!w.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let inputs: Vec<f64> = (0..80)
|
||||
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 5.0)
|
||||
.collect();
|
||||
let mut a = WavePm::new(10, 3).unwrap();
|
||||
let mut b = WavePm::new(10, 3).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&inputs),
|
||||
inputs.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,70 +72,73 @@ 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,
|
||||
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi,
|
||||
HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave, Hikkake,
|
||||
HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon,
|
||||
HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput,
|
||||
HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia,
|
||||
InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform,
|
||||
InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama,
|
||||
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
|
||||
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
|
||||
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
|
||||
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
|
||||
LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt,
|
||||
MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu,
|
||||
MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
|
||||
McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MedianPrice, Mfi, Microprice, MidPoint,
|
||||
MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows,
|
||||
Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta,
|
||||
OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
|
||||
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap,
|
||||
OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore,
|
||||
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo,
|
||||
ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
|
||||
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
|
||||
HangingMan, Harami, HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HighLowIndex, HighLowRange, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle,
|
||||
HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput,
|
||||
HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
|
||||
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||
InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
|
||||
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KasePermissionStochastic,
|
||||
KasePermissionStochasticOutput, KellyCriterion, Keltner, KeltnerOutput, Kicking,
|
||||
KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi,
|
||||
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
|
||||
LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures,
|
||||
LiquidationFeaturesOutput, LogReturn, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope,
|
||||
MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
|
||||
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
|
||||
MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom,
|
||||
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted,
|
||||
Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
|
||||
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
|
||||
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
|
||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
|
||||
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
|
||||
PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi,
|
||||
Qqe, QqeOutput, Qstick, QuotedSpread, RSquared, RealizedSpread, RealizedVolatility,
|
||||
RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100,
|
||||
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi, 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,
|
||||
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, StochasticOutput, SuperSmoother, SuperTrend,
|
||||
SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker,
|
||||
TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
|
||||
StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput, SuperSmoother,
|
||||
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
|
||||
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
|
||||
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
|
||||
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
|
||||
ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex,
|
||||
Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
|
||||
TradeImbalance, TrendLabel, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix,
|
||||
TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows,
|
||||
TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio,
|
||||
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
|
||||
VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
|
||||
Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals,
|
||||
WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput,
|
||||
YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
|
||||
Zlema, FAMILIES, T3,
|
||||
TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin,
|
||||
TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput,
|
||||
TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
|
||||
VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
|
||||
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose,
|
||||
WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots,
|
||||
WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
|
||||
ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
|
||||
+1
-1
@@ -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 **423 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.8",
|
||||
"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.8",
|
||||
"wickra-darwin-x64": "0.5.8",
|
||||
"wickra-linux-arm64-gnu": "0.5.8",
|
||||
"wickra-linux-x64-gnu": "0.5.8",
|
||||
"wickra-win32-arm64-msvc": "0.5.8",
|
||||
"wickra-win32-x64-msvc": "0.5.8"
|
||||
}
|
||||
},
|
||||
"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, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
|
||||
/// Drive a single streaming + batch run through one scalar indicator. Marked
|
||||
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
|
||||
@@ -67,6 +67,15 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| T3::new(14, 0.7).unwrap(), &data);
|
||||
drive(|| Mom::new(14).unwrap(), &data);
|
||||
drive(|| Cmo::new(14).unwrap(), &data);
|
||||
drive(|| DisparityIndex::new(14).unwrap(), &data);
|
||||
drive(|| FisherRsi::new(14).unwrap(), &data);
|
||||
drive(|| Rsx::new(14).unwrap(), &data);
|
||||
drive(|| DynamicMomentumIndex::new(14).unwrap(), &data);
|
||||
drive(|| Rmi::new(14, 5).unwrap(), &data);
|
||||
drive(|| DerivativeOscillator::new(14, 5, 3, 9).unwrap(), &data);
|
||||
drive(|| TrendStrengthIndex::new(20).unwrap(), &data);
|
||||
drive(|| PolarizedFractalEfficiency::new(10, 5).unwrap(), &data);
|
||||
drive(|| WavePm::new(32, 3).unwrap(), &data);
|
||||
drive(|| Tsi::new(25, 13).unwrap(), &data);
|
||||
drive(|| Pmo::new(35, 20).unwrap(), &data);
|
||||
drive(|| Tii::new(60, 30).unwrap(), &data);
|
||||
@@ -75,6 +84,9 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| Ppo::new(12, 26).unwrap(), &data);
|
||||
drive(|| Apo::new(12, 26).unwrap(), &data);
|
||||
drive(|| Cfo::new(14).unwrap(), &data);
|
||||
drive(|| TsfOscillator::new(14).unwrap(), &data);
|
||||
drive(|| MacdHistogram::new(12, 26, 9).unwrap(), &data);
|
||||
drive(|| PpoHistogram::new(12, 26, 9).unwrap(), &data);
|
||||
drive(|| ElderImpulse::classic(), &data);
|
||||
drive(|| Stc::classic(), &data);
|
||||
drive(|| Coppock::new(14, 11, 10).unwrap(), &data);
|
||||
@@ -123,6 +135,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, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
|
||||
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
|
||||
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
|
||||
@@ -87,6 +87,10 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles);
|
||||
|
||||
// --- Trend & Directional ---
|
||||
drive(|| KasePermissionStochastic::new(9, 3).unwrap(), &candles);
|
||||
drive(|| GatorOscillator::new(13, 8, 5).unwrap(), &candles);
|
||||
drive(|| Qstick::new(10).unwrap(), &candles);
|
||||
drive(|| TtmTrend::new(6).unwrap(), &candles);
|
||||
drive(|| Adx::new(14).unwrap(), &candles);
|
||||
drive(|| Adxr::new(14).unwrap(), &candles);
|
||||
drive(|| PlusDm::new(14).unwrap(), &candles);
|
||||
@@ -105,6 +109,9 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
|
||||
// --- Momentum & Oscillators ---
|
||||
drive(|| Cci::new(20).unwrap(), &candles);
|
||||
drive(|| StochasticCci::new(14).unwrap(), &candles);
|
||||
drive(|| ElderRay::new(13).unwrap(), &candles);
|
||||
drive(|| IntradayMomentumIndex::new(14).unwrap(), &candles);
|
||||
drive(|| Rvi::new(10).unwrap(), &candles);
|
||||
drive(|| Inertia::new(14, 20).unwrap(), &candles);
|
||||
drive(|| Pgo::new(14).unwrap(), &candles);
|
||||
|
||||
Reference in New Issue
Block a user