Compare commits

..

4 Commits

Author SHA1 Message Date
kingchenc 81406e7a1b release: bump 0.6.2 -> 0.6.3 (#196)
Version bump for the **v0.6.3** release shipping the **B8 Volume** family (#195): 440 -> 447 indicators. Bumps workspace + Python/Node/WASM package versions, lockfiles and CHANGELOG. No code changes.
2026-06-07 02:39:49 +02:00
kingchenc c78b84e186 Add B8 Volume family deepening (7 indicators) (#195)
Deepens the **Volume** family (B8) with seven indicators (440 -> 447):

- **VolumeRsi** — Wilder RSI computed on signed volume flow.
- **WilliamsAd** — Williams Accumulation/Distribution cumulative line (distinct from Chaikin A/D).
- **TwiggsMoneyFlow** — true-range volume accumulation with Wilder smoothing (distinct from CMF).
- **TradeVolumeIndex** — tick-direction volume accumulation past a min-tick threshold (distinct from TSV).
- **IntradayIntensity** — volume weighted by close position within the bar range.
- **BetterVolume** — VSA volume-vs-spread effort/result classifier.
- **VolumeWeightedMacd** — MACD computed on VWMA with signal line and histogram (struct output).

("Up/Down Volume Ratio" already ships from A2.) All Candle input; the six scalar stops emit f64, VolumeWeightedMacd a {macd, signal, histogram} struct. Hand-written Python/Node/WASM bindings for the volume signature. Verified locally: 3620 core lib + 405 doc tests, clippy clean, 522 node tests, 865 pytest, counter 447.
2026-06-07 02:30:56 +02:00
kingchenc fc6f3d80c2 release: bump 0.6.1 -> 0.6.2 (#194)
Version bump for the **v0.6.2** release shipping the **B7 Trailing Stops** family (#193): 434 -> 440 indicators.

Bumps workspace + Python/Node/WASM package versions, lockfiles and CHANGELOG (cuts the `[0.6.2]` section). No code changes.
2026-06-07 01:45:23 +02:00
kingchenc 2991ba411d Add B7 Trailing Stops family (6 indicators) (#193)
Adds the **Trailing Stops** family deepening (B7), six new indicators (434 -> 440):

- **KaseDevStop** — Cynthia Kase's volatility stop on the standard deviation of the two-bar true range.
- **ElderSafeZone** — Alexander Elder's stop offset by a multiple of average market noise.
- **AtrRatchet** — Kaufman ATR ratchet that tightens its multiple by a per-bar increment.
- **Nrtr** — Nick Rypock Trailing Reverse (percentage band).
- **TimeBasedStop** — exits after a fixed number of bars (scalar fraction of elapsed life).
- **ModifiedMaStop** — moving-average based trailing stop.

("Wilder Volatility System" is intentionally skipped — it overlaps the existing VoltyStop/Psar/SarExt.)

Each takes Candle input; the five band/structure stops emit a {value, direction} struct, TimeBasedStop a scalar. Wired across core, Python/Node/WASM bindings, fuzz target and tests. Verified locally: 3560 core lib + 398 doc tests, clippy clean, 515 node tests, 852 pytest, counter 440.
2026-06-07 01:32:15 +02:00
39 changed files with 6423 additions and 117 deletions
+20 -1
View File
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.6.3] - 2026-06-07
- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`).
- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`).
- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`).
- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`).
- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`).
- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`).
- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`).
## [0.6.2] - 2026-06-07
- **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`).
- **Time-Based Stop** — Time-Based Stop — bar-count timer that fires after a fixed holding period (`TIME_BASED_STOP`).
- **NRTR** — NRTR (Nick Rypock Trailing Reverse) — percentage trailing-reverse stop (`NRTR`).
- **ATR Ratchet** — ATR Ratchet — Kaufman per-bar tightening volatility trailing stop (`ATR_RATCHET`).
- **Elder SafeZone** — Elder SafeZone Stop — average noise-penetration trailing stop with directional flip (`ELDER_SAFE_ZONE`).
- **Kase DevStop** — Kase DevStop volatility trailing stop using standard-deviation of two-bar true range (`KASE_DEV_STOP`).
## [0.6.1] - 2026-06-07
- **Projection Oscillator** — Widner projection oscillator: close position inside the projection bands, scaled 0..100 (`ProjectionOscillator`).
- **Projection Bands** — Widner projection bands: forward-projected high/low regression envelope (`ProjectionBands`).
@@ -1308,7 +1325,9 @@ 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.6.1...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.3...HEAD
[0.6.3]: https://github.com/wickra-lib/wickra/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/wickra-lib/wickra/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/wickra-lib/wickra/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/wickra-lib/wickra/compare/v0.5.9...v0.6.0
[0.5.9]: https://github.com/wickra-lib/wickra/compare/v0.5.8...v0.5.9
Generated
+8 -8
View File
@@ -1944,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"approx",
"criterion",
@@ -1955,7 +1955,7 @@ dependencies = [
[[package]]
name = "wickra-bench"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"criterion",
"kand",
@@ -1967,7 +1967,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"approx",
"proptest",
@@ -1977,7 +1977,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"approx",
"csv",
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"serde_json",
"tokio",
@@ -2004,7 +2004,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"napi",
"napi-build",
@@ -2014,7 +2014,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"numpy",
"pyo3",
@@ -2023,7 +2023,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.6.1"
version = "0.6.3"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -13,7 +13,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.6.1"
version = "0.6.3"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -25,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.6.1" }
wickra-core = { path = "crates/wickra-core", version = "0.6.3" }
thiserror = "2"
rayon = "1.10"
+7 -7
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=434" 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=447" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 434 indicators; start at the
every one of the 447 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),
@@ -79,7 +79,7 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not:
| finta | clean | no | Python | ~80 | stale |
| talipp | clean | yes | Python | ~40 | yes |
Wickra's edge is **breadth with reach**: 434 indicators that all update in O(1)
Wickra's edge is **breadth with reach**: 447 indicators that all update in O(1)
per tick and ship natively to Python, Node.js, WebAssembly and Rust from a
single engine.
@@ -188,7 +188,7 @@ python -m benchmarks.compare_libraries
## Indicators
434 streaming-first indicators across twenty-four families. Every one passes the
447 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).
@@ -201,8 +201,8 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| 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, Volatility Cone |
| 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, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator |
| 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 |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
| 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, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop |
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD |
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient |
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
@@ -297,7 +297,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 434 indicators
│ ├── wickra-core/ core engine + all 447 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
@@ -356,6 +356,13 @@ const candleScalar = {
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -447,6 +454,12 @@ const multi = {
BomarBands: { make: () => new wickra.BomarBands(4, 0.85), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MedianChannel: { make: () => new wickra.MedianChannel(5, 2.0), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
ProjectionBands: { make: () => new wickra.ProjectionBands(3), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
KaseDevStop: { make: () => new wickra.KaseDevStop(3, 1.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ElderSafeZone: { make: () => new wickra.ElderSafeZone(14, 2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
};
for (const [name, d] of Object.entries(multi)) {
+166
View File
@@ -149,6 +149,26 @@ export interface DonchianStopValue {
stopLong: number
stopShort: number
}
export interface KaseDevStopValue {
value: number
direction: number
}
export interface ElderSafeZoneValue {
value: number
direction: number
}
export interface AtrRatchetValue {
value: number
direction: number
}
export interface NrtrValue {
value: number
direction: number
}
export interface ModifiedMaStopValue {
value: number
direction: number
}
/** Vortex Indicator pair: `VI+` and `VI-`. */
export interface VortexValue {
plus: number
@@ -469,6 +489,11 @@ export interface FibTimeZonesValue {
onZone: number
barsToNext: number
}
export interface VolumeWeightedMacdValue {
macd: number
signal: number
histogram: number
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -1668,6 +1693,15 @@ export declare class ProjectionOscillator {
isReady(): boolean
warmupPeriod(): number
}
export type TimeBasedStopNode = TimeBasedStop
export declare class TimeBasedStop {
constructor(maxBars: 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 StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -2328,6 +2362,71 @@ export declare class RenkoTrailingStop {
isReady(): boolean
warmupPeriod(): number
}
export type KaseDevStopNode = KaseDevStop
export declare class KaseDevStop {
constructor(period: number, dev: number)
update(high: number, low: number, close: number): KaseDevStopValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ElderSafeZoneNode = ElderSafeZone
export declare class ElderSafeZone {
constructor(period: number, coeff: number)
update(high: number, low: number, close: number): ElderSafeZoneValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AtrRatchetNode = AtrRatchet
export declare class AtrRatchet {
constructor(atrPeriod: number, startMult: number, increment: number)
update(high: number, low: number, close: number): AtrRatchetValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type NrtrNode = Nrtr
export declare class Nrtr {
constructor(pct: number)
update(high: number, low: number, close: number): NrtrValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ModifiedMaStopNode = ModifiedMaStop
export declare class ModifiedMaStop {
constructor(period: number)
update(high: number, low: number, close: number): ModifiedMaStopValue | null
/**
* Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
* Warmup positions are `NaN`.
*/
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TypicalPriceNode = TypicalPrice
export declare class TypicalPrice {
constructor()
@@ -4546,3 +4645,70 @@ export declare class FibTimeZones {
isReady(): boolean
warmupPeriod(): number
}
export type VolumeRsiNode = VolumeRsi
export declare class VolumeRsi {
constructor(period: number)
update(close: number, volume: number): number | null
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type WadNode = Wad
export declare class Wad {
constructor()
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 TwiggsMoneyFlowNode = TwiggsMoneyFlow
export declare class TwiggsMoneyFlow {
constructor(period: number)
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TradeVolumeIndexNode = TradeVolumeIndex
export declare class TradeVolumeIndex {
constructor(minTick: number)
update(close: number, volume: number): number | null
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type IntradayIntensityNode = IntradayIntensity
export declare class IntradayIntensity {
constructor()
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BetterVolumeNode = BetterVolume
export declare class BetterVolume {
constructor(period: number)
update(high: number, low: number, close: number, volume: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolumeWeightedMacdNode = VolumeWeightedMacd
export declare class VolumeWeightedMacd {
constructor(fast: number, slow: number, signal: number)
update(close: number, volume: number): VolumeWeightedMacdValue | null
/**
* Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
* Warmup positions are `NaN`.
*/
batch(close: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
+14 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.6.1",
"version": "0.6.3",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.6.1",
"version": "0.6.3",
"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.6.1",
"version": "0.6.3",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.6.1",
"version": "0.6.3",
"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.6.1",
"version": "0.6.3",
"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.6.1",
"version": "0.6.3",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.6.1",
"version": "0.6.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.6.1",
"version": "0.6.3",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.6.1",
"wickra-darwin-x64": "0.6.1",
"wickra-linux-arm64-gnu": "0.6.1",
"wickra-linux-x64-gnu": "0.6.1",
"wickra-win32-arm64-msvc": "0.6.1",
"wickra-win32-x64-msvc": "0.6.1"
"wickra-darwin-arm64": "0.6.3",
"wickra-darwin-x64": "0.6.3",
"wickra-linux-arm64-gnu": "0.6.3",
"wickra-linux-x64-gnu": "0.6.3",
"wickra-win32-arm64-msvc": "0.6.3",
"wickra-win32-x64-msvc": "0.6.3"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.1.tgz",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.3.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.1.tgz",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.3.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.1.tgz",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.3.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.1.tgz",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.3.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.1.tgz",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.3.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.1.tgz",
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.3.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.6.1",
"version": "0.6.3",
"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.6.1",
"wickra-linux-arm64-gnu": "0.6.1",
"wickra-darwin-x64": "0.6.1",
"wickra-darwin-arm64": "0.6.1",
"wickra-win32-x64-msvc": "0.6.1",
"wickra-win32-arm64-msvc": "0.6.1"
"wickra-linux-x64-gnu": "0.6.3",
"wickra-linux-arm64-gnu": "0.6.3",
"wickra-darwin-x64": "0.6.3",
"wickra-darwin-arm64": "0.6.3",
"wickra-win32-x64-msvc": "0.6.3",
"wickra-win32-arm64-msvc": "0.6.3"
},
"scripts": {
"build": "napi build --platform --release",
+846
View File
@@ -2922,6 +2922,59 @@ impl ProjectionOscillatorNode {
}
}
#[napi(js_name = "TimeBasedStop")]
pub struct TimeBasedStopNode {
inner: wc::TimeBasedStop,
}
#[napi]
impl TimeBasedStopNode {
#[napi(constructor)]
pub fn new(max_bars: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TimeBasedStop::new(max_bars 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(object)]
pub struct StochValue {
pub k: f64,
@@ -6591,6 +6644,372 @@ impl RenkoTrailingStopNode {
}
}
// ============================== Kase DevStop ==============================
#[napi(object)]
pub struct KaseDevStopValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "KaseDevStop")]
pub struct KaseDevStopNode {
inner: wc::KaseDevStop,
}
#[napi]
impl KaseDevStopNode {
#[napi(constructor)]
pub fn new(period: u32, dev: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::KaseDevStop::new(period as usize, dev).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<KaseDevStopValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| KaseDevStopValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[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.value;
out[i * 2 + 1] = o.direction;
}
}
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
}
}
// ============================== Elder SafeZone ==============================
#[napi(object)]
pub struct ElderSafeZoneValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "ElderSafeZone")]
pub struct ElderSafeZoneNode {
inner: wc::ElderSafeZone,
}
#[napi]
impl ElderSafeZoneNode {
#[napi(constructor)]
pub fn new(period: u32, coeff: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::ElderSafeZone::new(period as usize, coeff).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<ElderSafeZoneValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| ElderSafeZoneValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[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.value;
out[i * 2 + 1] = o.direction;
}
}
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
}
}
// ============================== ATR Ratchet ==============================
#[napi(object)]
pub struct AtrRatchetValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "AtrRatchet")]
pub struct AtrRatchetNode {
inner: wc::AtrRatchet,
}
#[napi]
impl AtrRatchetNode {
#[napi(constructor)]
pub fn new(atr_period: u32, start_mult: f64, increment: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::AtrRatchet::new(atr_period as usize, start_mult, increment)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<AtrRatchetValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| AtrRatchetValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[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.value;
out[i * 2 + 1] = o.direction;
}
}
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
}
}
// ============================== NRTR ==============================
#[napi(object)]
pub struct NrtrValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "Nrtr")]
pub struct NrtrNode {
inner: wc::Nrtr,
}
#[napi]
impl NrtrNode {
#[napi(constructor)]
pub fn new(pct: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::Nrtr::new(pct).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<NrtrValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| NrtrValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[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.value;
out[i * 2 + 1] = o.direction;
}
}
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
}
}
// ============================== Modified MA Stop ==============================
#[napi(object)]
pub struct ModifiedMaStopValue {
pub value: f64,
pub direction: f64,
}
#[napi(js_name = "ModifiedMaStop")]
pub struct ModifiedMaStopNode {
inner: wc::ModifiedMaStop,
}
#[napi]
impl ModifiedMaStopNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ModifiedMaStop::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<ModifiedMaStopValue>> {
Ok(self
.inner
.update(cnd(high, low, close, 0.0)?)
.map(|o| ModifiedMaStopValue {
value: o.value,
direction: o.direction,
}))
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup positions are `NaN`.
#[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.value;
out[i * 2 + 1] = o.direction;
}
}
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
}
}
// ============================== Typical Price ==============================
#[napi(js_name = "TypicalPrice")]
@@ -16691,3 +17110,430 @@ impl Default for FibTimeZonesNode {
Self::new()
}
}
// ============================== Volume RSI ==============================
#[napi(js_name = "VolumeRsi")]
pub struct VolumeRsiNode {
inner: wc::VolumeRsi,
}
#[napi]
impl VolumeRsiNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolumeRsi::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, close: f64, volume: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(close, close, close, volume)?))
}
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(close[i], close[i], close[i], volume[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
}
}
// ============================== Williams A/D ==============================
#[napi(js_name = "Wad")]
pub struct WadNode {
inner: wc::Wad,
}
#[napi]
impl WadNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::Wad::new(),
}
}
#[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(close.len());
for i in 0..close.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
}
}
impl Default for WadNode {
fn default() -> Self {
Self::new()
}
}
// ============================== Twiggs Money Flow ==============================
#[napi(js_name = "TwiggsMoneyFlow")]
pub struct TwiggsMoneyFlowNode {
inner: wc::TwiggsMoneyFlow,
}
#[napi]
impl TwiggsMoneyFlowNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TwiggsMoneyFlow::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[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
}
}
// ============================== Trade Volume Index ==============================
#[napi(js_name = "TradeVolumeIndex")]
pub struct TradeVolumeIndexNode {
inner: wc::TradeVolumeIndex,
}
#[napi]
impl TradeVolumeIndexNode {
#[napi(constructor)]
pub fn new(min_tick: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, close: f64, volume: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(close, close, close, volume)?))
}
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(close[i], close[i], close[i], volume[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
}
}
// ============================== Intraday Intensity ==============================
#[napi(js_name = "IntradayIntensity")]
pub struct IntradayIntensityNode {
inner: wc::IntradayIntensity,
}
#[napi]
impl IntradayIntensityNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::IntradayIntensity::new(),
}
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[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
}
}
impl Default for IntradayIntensityNode {
fn default() -> Self {
Self::new()
}
}
// ============================== Better Volume ==============================
#[napi(js_name = "BetterVolume")]
pub struct BetterVolumeNode {
inner: wc::BetterVolume,
}
#[napi]
impl BetterVolumeNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::BetterVolume::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, close, volume must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], volume[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
}
}
// ============================== Volume-Weighted MACD ==============================
#[napi(object)]
pub struct VolumeWeightedMacdValue {
pub macd: f64,
pub signal: f64,
pub histogram: f64,
}
#[napi(js_name = "VolumeWeightedMacd")]
pub struct VolumeWeightedMacdNode {
inner: wc::VolumeWeightedMacd,
}
#[napi]
impl VolumeWeightedMacdNode {
#[napi(constructor)]
pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolumeWeightedMacd::new(fast as usize, slow as usize, signal as usize)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
close: f64,
volume: f64,
) -> napi::Result<Option<VolumeWeightedMacdValue>> {
Ok(self
.inner
.update(cnd(close, close, close, volume)?)
.map(|o| VolumeWeightedMacdValue {
macd: o.macd,
signal: o.signal,
histogram: o.histogram,
}))
}
/// Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
/// Warmup positions are `NaN`.
#[napi]
pub fn batch(&mut self, close: Vec<f64>, volume: Vec<f64>) -> napi::Result<Vec<f64>> {
if close.len() != volume.len() {
return Err(NapiError::from_reason(
"close and volume must be equal length".to_string(),
));
}
let mut out = vec![f64::NAN; close.len() * 3];
for i in 0..close.len() {
if let Some(o) = self
.inner
.update(cnd(close[i], close[i], close[i], volume[i])?)
{
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
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
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.6.1"
version = "0.6.3"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+28
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
from ._wickra import (
__version__,
TimeBasedStop,
ProjectionOscillator,
VolatilityCone,
VolatilityRatio,
@@ -168,6 +169,11 @@ from ._wickra import (
HistoricalVolatility,
BollingerBandwidth,
PercentB,
# Trailing Stops
ModifiedMaStop,
Nrtr,
AtrRatchet,
ElderSafeZone,
SuperTrend,
ChandelierExit,
ChandeKrollStop,
@@ -179,6 +185,7 @@ from ._wickra import (
PercentageTrailingStop,
StepTrailingStop,
RenkoTrailingStop,
KaseDevStop,
TrueRange,
ChaikinVolatility,
RVIVolatility,
@@ -187,6 +194,13 @@ from ._wickra import (
RogersSatchellVolatility,
YangZhangVolatility,
# Volume
VolumeWeightedMacd,
BetterVolume,
IntradayIntensity,
TradeVolumeIndex,
TwiggsMoneyFlow,
Wad,
VolumeRsi,
OBV,
VWAP,
RollingVWAP,
@@ -487,6 +501,7 @@ from ._wickra import (
)
__all__ = [
"TimeBasedStop",
"ProjectionOscillator",
"VolatilityCone",
"VolatilityRatio",
@@ -631,6 +646,11 @@ __all__ = [
"HistoricalVolatility",
"BollingerBandwidth",
"PercentB",
# Trailing Stops
"ModifiedMaStop",
"Nrtr",
"AtrRatchet",
"ElderSafeZone",
"SuperTrend",
"ChandelierExit",
"ChandeKrollStop",
@@ -642,6 +662,7 @@ __all__ = [
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"KaseDevStop",
"TrueRange",
"ChaikinVolatility",
"RVIVolatility",
@@ -650,6 +671,13 @@ __all__ = [
"RogersSatchellVolatility",
"YangZhangVolatility",
# Volume
"VolumeWeightedMacd",
"BetterVolume",
"IntradayIntensity",
"TradeVolumeIndex",
"TwiggsMoneyFlow",
"Wad",
"VolumeRsi",
"OBV",
"VWAP",
"RollingVWAP",
+960
View File
@@ -3631,6 +3631,75 @@ impl PyProjectionOscillator {
}
}
// ============================== TimeBasedStop ==============================
#[pyclass(name = "TimeBasedStop", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTimeBasedStop {
inner: wc::TimeBasedStop,
}
#[pymethods]
impl PyTimeBasedStop {
#[new]
#[pyo3(signature = (max_bars=5))]
fn new(max_bars: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TimeBasedStop::new(max_bars).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy columns: high, low, close (all 1-D, equal length).
/// Ignores price; counts bars. Returns progress in `[0, 1]`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn max_bars(&self) -> usize {
self.inner.max_bars()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TimeBasedStop(max_bars={})", self.inner.max_bars())
}
}
// ============================== Stochastic ==============================
#[pyclass(name = "IMI", module = "wickra._wickra", skip_from_py_object)]
@@ -9813,6 +9882,390 @@ impl PyRenkoTrailingStop {
}
}
// ============================== Kase DevStop ==============================
#[pyclass(name = "KaseDevStop", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyKaseDevStop {
inner: wc::KaseDevStop,
}
#[pymethods]
impl PyKaseDevStop {
#[new]
#[pyo3(signature = (period=30, dev=1.0))]
fn new(period: usize, dev: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::KaseDevStop::new(period, dev).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.value, o.direction)))
}
/// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with
/// columns `[value, direction]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, f64) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (period, dev) = self.inner.params();
format!("KaseDevStop(period={period}, dev={dev})")
}
}
// ============================== Elder SafeZone ==============================
#[pyclass(name = "ElderSafeZone", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyElderSafeZone {
inner: wc::ElderSafeZone,
}
#[pymethods]
impl PyElderSafeZone {
#[new]
#[pyo3(signature = (period=14, coeff=2.0))]
fn new(period: usize, coeff: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::ElderSafeZone::new(period, coeff).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.value, o.direction)))
}
/// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with
/// columns `[value, direction]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, f64) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (period, coeff) = self.inner.params();
format!("ElderSafeZone(period={period}, coeff={coeff})")
}
}
// ============================== ATR Ratchet ==============================
#[pyclass(name = "AtrRatchet", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyAtrRatchet {
inner: wc::AtrRatchet,
}
#[pymethods]
impl PyAtrRatchet {
#[new]
#[pyo3(signature = (atr_period=14, start_mult=4.0, increment=0.1))]
fn new(atr_period: usize, start_mult: f64, increment: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::AtrRatchet::new(atr_period, start_mult, increment).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.value, o.direction)))
}
/// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with
/// columns `[value, direction]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, f64, f64) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (atr_period, start_mult, increment) = self.inner.params();
format!(
"AtrRatchet(atr_period={atr_period}, start_mult={start_mult}, increment={increment})"
)
}
}
// ============================== NRTR ==============================
#[pyclass(name = "Nrtr", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyNrtr {
inner: wc::Nrtr,
}
#[pymethods]
impl PyNrtr {
#[new]
#[pyo3(signature = (pct=2.0))]
fn new(pct: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::Nrtr::new(pct).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.value, o.direction)))
}
/// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with
/// columns `[value, direction]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn pct(&self) -> f64 {
self.inner.pct()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("Nrtr(pct={})", self.inner.pct())
}
}
// ============================== Modified MA Stop ==============================
#[pyclass(
name = "ModifiedMaStop",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyModifiedMaStop {
inner: wc::ModifiedMaStop,
}
#[pymethods]
impl PyModifiedMaStop {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ModifiedMaStop::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.value, o.direction)))
}
/// Batch over numpy columns high, low, close. Returns shape `(n, 2)` with
/// columns `[value, direction]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 2];
for i in 0..n {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 2] = o.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("ModifiedMaStop(period={})", self.inner.period())
}
}
// ============================== Typical Price ==============================
#[pyclass(name = "TypicalPrice", module = "wickra._wickra", skip_from_py_object)]
@@ -21788,6 +22241,500 @@ impl PyVolatilityCone {
}
}
// ============================== Volume RSI ==============================
#[pyclass(name = "VolumeRsi", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyVolumeRsi {
inner: wc::VolumeRsi,
}
#[pymethods]
impl PyVolumeRsi {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolumeRsi::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy close + volume arrays (both 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("VolumeRsi(period={})", self.inner.period())
}
}
// ============================== Williams A/D ==============================
#[pyclass(name = "Wad", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyWad {
inner: wc::Wad,
}
#[pymethods]
impl PyWad {
#[new]
fn new() -> Self {
Self {
inner: wc::Wad::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy high, low, close arrays (all 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
"Wad()".to_string()
}
}
// ============================== Twiggs Money Flow ==============================
#[pyclass(
name = "TwiggsMoneyFlow",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyTwiggsMoneyFlow {
inner: wc::TwiggsMoneyFlow,
}
#[pymethods]
impl PyTwiggsMoneyFlow {
#[new]
#[pyo3(signature = (period=21))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TwiggsMoneyFlow::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy high, low, close, volume arrays (all 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let vol = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TwiggsMoneyFlow(period={})", self.inner.period())
}
}
// ============================== Trade Volume Index ==============================
#[pyclass(
name = "TradeVolumeIndex",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyTradeVolumeIndex {
inner: wc::TradeVolumeIndex,
}
#[pymethods]
impl PyTradeVolumeIndex {
#[new]
#[pyo3(signature = (min_tick=0.25))]
fn new(min_tick: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy close + volume arrays (both 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn min_tick(&self) -> f64 {
self.inner.min_tick()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("TradeVolumeIndex(min_tick={})", self.inner.min_tick())
}
}
// ============================== Intraday Intensity ==============================
#[pyclass(
name = "IntradayIntensity",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyIntradayIntensity {
inner: wc::IntradayIntensity,
}
#[pymethods]
impl PyIntradayIntensity {
#[new]
fn new() -> Self {
Self {
inner: wc::IntradayIntensity::new(),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy high, low, close, volume arrays (all 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let vol = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
"IntradayIntensity()".to_string()
}
}
// ============================== Better Volume ==============================
#[pyclass(name = "BetterVolume", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyBetterVolume {
inner: wc::BetterVolume,
}
#[pymethods]
impl PyBetterVolume {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::BetterVolume::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy high, low, close, volume arrays (all 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let vol = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() {
return Err(PyValueError::new_err(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(c.len());
for i in 0..c.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("BetterVolume(period={})", self.inner.period())
}
}
// ============================== Volume-Weighted MACD ==============================
#[pyclass(
name = "VolumeWeightedMacd",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyVolumeWeightedMacd {
inner: wc::VolumeWeightedMacd,
}
#[pymethods]
impl PyVolumeWeightedMacd {
#[new]
#[pyo3(signature = (fast=12, slow=26, signal=9))]
fn new(fast: usize, slow: usize, signal: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolumeWeightedMacd::new(fast, slow, signal).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.macd, o.signal, o.histogram)))
}
/// Batch over numpy close + volume arrays. Returns shape `(n, 3)` with
/// columns `[macd, signal, histogram]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if c.len() != v.len() {
return Err(PyValueError::new_err(
"close and volume must be equal length",
));
}
let n = c.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn periods(&self) -> (usize, usize, usize) {
self.inner.periods()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (fast, slow, signal) = self.inner.periods();
format!("VolumeWeightedMacd(fast={fast}, slow={slow}, signal={signal})")
}
}
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -21894,6 +22841,11 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPercentageTrailingStop>()?;
m.add_class::<PyStepTrailingStop>()?;
m.add_class::<PyRenkoTrailingStop>()?;
m.add_class::<PyKaseDevStop>()?;
m.add_class::<PyElderSafeZone>()?;
m.add_class::<PyAtrRatchet>()?;
m.add_class::<PyNrtr>()?;
m.add_class::<PyModifiedMaStop>()?;
m.add_class::<PyTypicalPrice>()?;
m.add_class::<PyMedianPrice>()?;
m.add_class::<PyWeightedClose>()?;
@@ -22238,5 +23190,13 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyVolatilityOfVolatility>()?;
m.add_class::<PyVolatilityCone>()?;
m.add_class::<PyProjectionOscillator>()?;
m.add_class::<PyTimeBasedStop>()?;
m.add_class::<PyVolumeRsi>()?;
m.add_class::<PyWad>()?;
m.add_class::<PyTwiggsMoneyFlow>()?;
m.add_class::<PyTradeVolumeIndex>()?;
m.add_class::<PyIntradayIntensity>()?;
m.add_class::<PyBetterVolume>()?;
m.add_class::<PyVolumeWeightedMacd>()?;
Ok(())
}
+127 -1
View File
@@ -368,6 +368,31 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"BetterVolume": (
lambda: ta.BetterVolume(14),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"IntradayIntensity": (
lambda: ta.IntradayIntensity(),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"TradeVolumeIndex": (
lambda: ta.TradeVolumeIndex(0.25),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TwiggsMoneyFlow": (
lambda: ta.TwiggsMoneyFlow(21),
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
),
"Wad": (
lambda: ta.Wad(),
lambda ind, h, l, c, v: ind.batch(h, l, c),
),
"VolumeRsi": (
lambda: ta.VolumeRsi(14),
lambda ind, h, l, c, v: ind.batch(c, v),
),
"TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"TTM_TREND": (lambda: ta.TTM_TREND(6), lambda ind, h, l, c, v: ind.batch(h, l, c)),
@@ -908,6 +933,36 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"VolumeWeightedMacd": (
lambda: ta.VolumeWeightedMacd(12, 26, 9),
lambda ind, h, l, c, v: ind.batch(c, v),
3,
),
"ModifiedMaStop": (
lambda: ta.ModifiedMaStop(14),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"Nrtr": (
lambda: ta.Nrtr(2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"AtrRatchet": (
lambda: ta.AtrRatchet(14, 4.0, 0.1),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ElderSafeZone": (
lambda: ta.ElderSafeZone(14, 2.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"KaseDevStop": (
lambda: ta.KaseDevStop(3, 1.0),
lambda ind, h, l, c, v: ind.batch(h, l, c),
2,
),
"ProjectionBands": (
lambda: ta.ProjectionBands(3),
lambda ind, h, l, c, v: ind.batch(h, l),
@@ -1554,7 +1609,7 @@ def test_kvo_constant_series_is_zero():
assert v == pytest.approx(0.0, abs=1e-12)
def test_williams_ad_reference():
def test_wad_reference():
# bar 0 seeds prev_close = 10.
# bar 1: prev=10, today high=13, low=8, close=12 (up day).
# TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4.
@@ -2967,6 +3022,77 @@ def test_projection_oscillator_reference():
assert t.update((9.0, 12.0, 9.0, 11.0, 1.0, 1)) is None
assert t.update((10.0, 11.0, 10.0, 11.0, 1.0, 2)) == pytest.approx(40.0)
def test_kase_devstop_reference():
t = ta.KaseDevStop(3, 1.0)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) is None
assert t.update((101.0, 102.0, 100.0, 101.0, 1.0, 1)) is None
assert t.update((102.0, 103.0, 101.0, 102.0, 1.0, 2)) is None
assert t.update((102.5, 104.0, 102.0, 103.0, 1.0, 3)) == pytest.approx((101.0, 1.0))
def _stop_candles(n):
# Gently rising, valid OHLC: high >= open/close, low <= open/close.
return [(100.0 + i, 101.5 + i, 98.5 + i, 100.5 + i, 1.0, i) for i in range(n)]
def test_elder_safezone_reference():
t = ta.ElderSafeZone(14, 2.0)
candles = _stop_candles(15)
for c in candles[:14]:
assert t.update(c) is None
assert t.update(candles[14]) == pytest.approx((112.5, 1.0))
def test_atr_ratchet_reference():
t = ta.AtrRatchet(14, 4.0, 0.1)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((101.5, 1.0))
def test_nrtr_reference():
t = ta.Nrtr(2.0)
assert t.update((100.0, 100.0, 100.0, 100.0, 1.0, 0)) == pytest.approx((98.0, 1.0))
def test_time_based_stop_reference():
t = ta.TimeBasedStop(5)
assert t.update((100.0, 101.0, 99.0, 100.0, 1.0, 0)) == pytest.approx(0.2)
def test_modified_ma_stop_reference():
t = ta.ModifiedMaStop(14)
candles = _stop_candles(14)
for c in candles[:13]:
assert t.update(c) is None
assert t.update(candles[13]) == pytest.approx((107.0, 1.0))
def test_volume_rsi_reference():
t = ta.VolumeRsi(14)
def test_twiggs_money_flow_reference():
t = ta.TwiggsMoneyFlow(21)
def test_trade_volume_index_reference():
t = ta.TradeVolumeIndex(0.25)
def test_intraday_intensity_reference():
t = ta.IntradayIntensity()
def test_better_volume_reference():
t = ta.BetterVolume(14)
def test_volume_weighted_macd_reference():
t = ta.VolumeWeightedMacd(12, 26, 9)
# --- Lifecycle ------------------------------------------------------------
+683
View File
@@ -2552,6 +2552,44 @@ impl WasmProjectionOscillator {
}
}
#[wasm_bindgen(js_name = TimeBasedStop)]
pub struct WasmTimeBasedStop {
inner: wc::TimeBasedStop,
}
#[wasm_bindgen(js_class = TimeBasedStop)]
impl WasmTimeBasedStop {
#[wasm_bindgen(constructor)]
pub fn new(max_bars: usize) -> Result<WasmTimeBasedStop, JsError> {
Ok(Self {
inner: wc::TimeBasedStop::new(max_bars).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 = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -3833,6 +3871,275 @@ impl WasmRenkoTrailingStop {
}
}
#[wasm_bindgen(js_name = KaseDevStop)]
pub struct WasmKaseDevStop {
inner: wc::KaseDevStop,
}
#[wasm_bindgen(js_class = KaseDevStop)]
impl WasmKaseDevStop {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, dev: f64) -> Result<WasmKaseDevStop, JsError> {
Ok(Self {
inner: wc::KaseDevStop::new(period, dev).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` 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, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
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.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ElderSafeZone)]
pub struct WasmElderSafeZone {
inner: wc::ElderSafeZone,
}
#[wasm_bindgen(js_class = ElderSafeZone)]
impl WasmElderSafeZone {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, coeff: f64) -> Result<WasmElderSafeZone, JsError> {
Ok(Self {
inner: wc::ElderSafeZone::new(period, coeff).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` 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, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
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.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = AtrRatchet)]
pub struct WasmAtrRatchet {
inner: wc::AtrRatchet,
}
#[wasm_bindgen(js_class = AtrRatchet)]
impl WasmAtrRatchet {
#[wasm_bindgen(constructor)]
pub fn new(
atr_period: usize,
start_mult: f64,
increment: f64,
) -> Result<WasmAtrRatchet, JsError> {
Ok(Self {
inner: wc::AtrRatchet::new(atr_period, start_mult, increment).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` 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, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
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.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = Nrtr)]
pub struct WasmNrtr {
inner: wc::Nrtr,
}
#[wasm_bindgen(js_class = Nrtr)]
impl WasmNrtr {
#[wasm_bindgen(constructor)]
pub fn new(pct: f64) -> Result<WasmNrtr, JsError> {
Ok(Self {
inner: wc::Nrtr::new(pct).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` 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, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
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.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = ModifiedMaStop)]
pub struct WasmModifiedMaStop {
inner: wc::ModifiedMaStop,
}
#[wasm_bindgen(js_class = ModifiedMaStop)]
impl WasmModifiedMaStop {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmModifiedMaStop, JsError> {
Ok(Self {
inner: wc::ModifiedMaStop::new(period).map_err(map_err)?,
})
}
/// Returns `{ value, direction }` 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, &"value".into(), &o.value.into()).ok();
Reflect::set(&obj, &"direction".into(), &o.direction.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[value0, direction0, value1, direction1, ...]`, length `2 * n`.
/// Warmup is NaN.
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.value;
out[i * 2 + 1] = o.direction;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = TypicalPrice)]
pub struct WasmTypicalPrice {
inner: wc::TypicalPrice,
@@ -12405,3 +12712,379 @@ impl WasmFibTimeZones {
self.inner.warmup_period()
}
}
// ============================== Volume RSI ==============================
#[wasm_bindgen(js_name = VolumeRsi)]
pub struct WasmVolumeRsi {
inner: wc::VolumeRsi,
}
#[wasm_bindgen(js_class = VolumeRsi)]
impl WasmVolumeRsi {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmVolumeRsi, JsError> {
Ok(Self {
inner: wc::VolumeRsi::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[i])?;
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 = 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()
}
}
// ============================== Williams A/D ==============================
#[wasm_bindgen(js_name = Wad)]
pub struct WasmWad {
inner: wc::Wad,
}
#[wasm_bindgen(js_class = Wad)]
impl WasmWad {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmWad {
Self {
inner: wc::Wad::new(),
}
}
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(close.len());
for i in 0..close.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 = 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()
}
}
// ============================== Twiggs Money Flow ==============================
#[wasm_bindgen(js_name = TwiggsMoneyFlow)]
pub struct WasmTwiggsMoneyFlow {
inner: wc::TwiggsMoneyFlow,
}
#[wasm_bindgen(js_class = TwiggsMoneyFlow)]
impl WasmTwiggsMoneyFlow {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmTwiggsMoneyFlow, JsError> {
Ok(Self {
inner: wc::TwiggsMoneyFlow::new(period).map_err(map_err)?,
})
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
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 = 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()
}
}
// ============================== Trade Volume Index ==============================
#[wasm_bindgen(js_name = TradeVolumeIndex)]
pub struct WasmTradeVolumeIndex {
inner: wc::TradeVolumeIndex,
}
#[wasm_bindgen(js_class = TradeVolumeIndex)]
impl WasmTradeVolumeIndex {
#[wasm_bindgen(constructor)]
pub fn new(min_tick: f64) -> Result<WasmTradeVolumeIndex, JsError> {
Ok(Self {
inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?,
})
}
pub fn update(&mut self, close: f64, volume: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[i])?;
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 = 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()
}
}
// ============================== Intraday Intensity ==============================
#[wasm_bindgen(js_name = IntradayIntensity)]
pub struct WasmIntradayIntensity {
inner: wc::IntradayIntensity,
}
#[wasm_bindgen(js_class = IntradayIntensity)]
impl WasmIntradayIntensity {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmIntradayIntensity {
Self {
inner: wc::IntradayIntensity::new(),
}
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
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 = 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()
}
}
// ============================== Better Volume ==============================
#[wasm_bindgen(js_name = BetterVolume)]
pub struct WasmBetterVolume {
inner: wc::BetterVolume,
}
#[wasm_bindgen(js_class = BetterVolume)]
impl WasmBetterVolume {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmBetterVolume, JsError> {
Ok(Self {
inner: wc::BetterVolume::new(period).map_err(map_err)?,
})
}
pub fn update(
&mut self,
high: f64,
low: f64,
close: f64,
volume: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, volume)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
return Err(JsError::new(
"high, low, close, volume must be equal length",
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle(high[i], low[i], close[i], volume[i])?;
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 = 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()
}
}
// ============================== Volume-Weighted MACD ==============================
#[wasm_bindgen(js_name = VolumeWeightedMacd)]
pub struct WasmVolumeWeightedMacd {
inner: wc::VolumeWeightedMacd,
}
#[wasm_bindgen(js_class = VolumeWeightedMacd)]
impl WasmVolumeWeightedMacd {
#[wasm_bindgen(constructor)]
pub fn new(fast: usize, slow: usize, signal: usize) -> Result<WasmVolumeWeightedMacd, JsError> {
Ok(Self {
inner: wc::VolumeWeightedMacd::new(fast, slow, signal).map_err(map_err)?,
})
}
/// Returns `{ macd, signal, histogram }` once warm, else `null`.
pub fn update(&mut self, close: f64, volume: f64) -> Result<JsValue, JsError> {
let c = make_candle(close, close, close, volume)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"macd".into(), &o.macd.into()).ok();
Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok();
Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
/// Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`.
/// Warmup is NaN.
pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result<Float64Array, JsError> {
if close.len() != volume.len() {
return Err(JsError::new("close and volume must be equal length"));
}
let mut out = vec![f64::NAN; close.len() * 3];
for i in 0..close.len() {
let c = make_candle(close[i], close[i], close[i], volume[i])?;
if let Some(o) = self.inner.update(c) {
out[i * 3] = o.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[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()
}
}
impl Default for WasmWad {
fn default() -> Self {
Self::new()
}
}
impl Default for WasmIntradayIntensity {
fn default() -> Self {
Self::new()
}
}
@@ -0,0 +1,279 @@
//! ATR Ratchet (Kaufman) — a trailing stop that creeps toward price each bar.
use crate::error::{Error, Result};
use crate::indicators::atr::Atr;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`AtrRatchet`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AtrRatchetOutput {
/// The ratchet stop level — below price when long, above price when short.
pub value: f64,
/// Trend direction: `+1.0` long, `-1.0` short.
pub direction: f64,
}
/// ATR Ratchet — Perry Kaufman's time-based volatility stop that tightens by a
/// fixed fraction of ATR **every bar**, whether or not price moves.
///
/// ```text
/// on entry (long): stop = close start_mult · ATR
/// each later bar: stop = stop + increment · ATR (ratchets toward price)
/// flip to short when close < stop, reseeding stop = close + start_mult · ATR
/// ```
///
/// Most trailing stops only move when price makes a new extreme. Kaufman's ratchet
/// instead advances the stop a little each bar — `increment · ATR` — so a trade
/// that stalls is squeezed out over time even in a flat market. The initial
/// distance (`start_mult · ATR`) gives the position room to breathe; the per-bar
/// `increment` controls how aggressively the leash shortens. When price closes
/// through the stop the system reverses and reseeds at the full initial distance.
///
/// The first stop lands once ATR is ready (`atr_period` inputs). Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AtrRatchet};
///
/// let mut indicator = AtrRatchet::new(14, 4.0, 0.1).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct AtrRatchet {
atr: Atr,
atr_period: usize,
start_mult: f64,
increment: f64,
direction: f64,
stop: f64,
last: Option<AtrRatchetOutput>,
}
impl AtrRatchet {
/// Construct an ATR Ratchet stop.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `atr_period == 0` and
/// [`Error::NonPositiveMultiplier`] if `start_mult` or `increment` is not
/// finite and positive.
pub fn new(atr_period: usize, start_mult: f64, increment: f64) -> Result<Self> {
if !start_mult.is_finite()
|| start_mult <= 0.0
|| !increment.is_finite()
|| increment <= 0.0
{
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
atr: Atr::new(atr_period)?,
atr_period,
start_mult,
increment,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(atr_period, start_mult, increment)`.
pub const fn params(&self) -> (usize, f64, f64) {
(self.atr_period, self.start_mult, self.increment)
}
/// Current value if available.
pub const fn value(&self) -> Option<AtrRatchetOutput> {
self.last
}
}
impl Indicator for AtrRatchet {
type Input = Candle;
type Output = AtrRatchetOutput;
fn update(&mut self, candle: Candle) -> Option<AtrRatchetOutput> {
let atr = self.atr.update(candle)?;
let close = candle.close;
if self.direction == 0.0 {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
} else if self.direction > 0.0 {
self.stop += self.increment * atr;
if close < self.stop {
self.direction = -1.0;
self.stop = close + self.start_mult * atr;
}
} else {
self.stop -= self.increment * atr;
if close > self.stop {
self.direction = 1.0;
self.stop = close - self.start_mult * atr;
}
}
let out = AtrRatchetOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.atr.reset();
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.atr_period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"AtrRatchet"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(
AtrRatchet::new(0, 4.0, 0.1),
Err(Error::PeriodZero)
));
assert!(matches!(
AtrRatchet::new(14, 0.0, 0.1),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
AtrRatchet::new(14, 4.0, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let r = AtrRatchet::new(14, 4.0, 0.1).unwrap();
assert_eq!(r.params(), (14, 4.0, 0.1));
assert_eq!(r.warmup_period(), 14);
assert_eq!(r.name(), "AtrRatchet");
assert!(!r.is_ready());
assert_eq!(r.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = r.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut r = AtrRatchet::new(5, 4.0, 0.05).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in r.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
}
#[test]
fn stall_eventually_triggers_flip() {
// A long trend then a long flat stretch: the ratchet creeps up each bar
// and eventually overtakes the flat close, flipping to short.
let mut r = AtrRatchet::new(5, 2.0, 0.5).unwrap();
let mut candles: Vec<Candle> = (0..20)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
// Flat stretch at the last price.
candles.extend((0..40).map(|_| c(120.6, 118.6, 119.5)));
let dirs: Vec<f64> = r
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(
dirs.iter().any(|&d| d < 0.0),
"the ratchet should eventually flip short"
);
}
#[test]
fn reset_clears_state() {
let mut r = AtrRatchet::new(5, 4.0, 0.1).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
r.batch(&candles);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.value(), None);
assert_eq!(r.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = AtrRatchet::new(14, 4.0, 0.1).unwrap().batch(&candles);
let mut b = AtrRatchet::new(14, 4.0, 0.1).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,254 @@
//! Better Volume (VSA) — a streaming effort-versus-result oscillator.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result"
/// oscillator: how much volume (effort) a bar spent relative to the price range
/// (result) it achieved, both normalised against their own recent averages.
///
/// ```text
/// range_t = high_t low_t
/// rel_vol = volume_t / SMA(volume, period)
/// rel_range = range_t / SMA(range, period)
/// BetterVol = rel_vol rel_range
/// ```
///
/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets
/// through the relationship between **effort** (volume) and **result** (the bar's
/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while
/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort
/// produced little movement, the hallmark of absorption (supply meeting demand at
/// a top, or vice versa at a bottom). A bar that travels far on light volume —
/// negative oscillator — shows *ease of movement*, a trend meeting no resistance.
///
/// Both legs are normalised by their `period` simple moving averages (including
/// the current bar), so the output is centred near `0` and self-scales to the
/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing
/// by zero. The first value lands after `period` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, BetterVolume};
///
/// let mut indicator = BetterVolume::new(20).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BetterVolume {
period: usize,
volumes: VecDeque<f64>,
ranges: VecDeque<f64>,
vol_sum: f64,
range_sum: f64,
last: Option<f64>,
}
impl BetterVolume {
/// Construct a new Better Volume oscillator with the given averaging `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
volumes: VecDeque::with_capacity(period),
ranges: VecDeque::with_capacity(period),
vol_sum: 0.0,
range_sum: 0.0,
last: None,
})
}
/// Configured averaging period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for BetterVolume {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
if self.volumes.len() == self.period {
self.vol_sum -= self.volumes.pop_front().expect("non-empty");
self.range_sum -= self.ranges.pop_front().expect("non-empty");
}
self.volumes.push_back(candle.volume);
self.ranges.push_back(range);
self.vol_sum += candle.volume;
self.range_sum += range;
if self.volumes.len() < self.period {
return None;
}
let n = self.period as f64;
let sma_vol = self.vol_sum / n;
let sma_range = self.range_sum / n;
let rel_vol = if sma_vol > 0.0 {
candle.volume / sma_vol
} else {
0.0
};
let rel_range = if sma_range > 0.0 {
range / sma_range
} else {
0.0
};
let out = rel_vol - rel_range;
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.volumes.clear();
self.ranges.clear();
self.vol_sum = 0.0;
self.range_sum = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"BetterVolume"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, high, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let bv = BetterVolume::new(20).unwrap();
assert_eq!(bv.period(), 20);
assert_eq!(bv.warmup_period(), 20);
assert_eq!(bv.name(), "BetterVolume");
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let out = bv.batch(&candles);
for v in out.iter().take(2) {
assert!(v.is_none());
}
assert!(out[2].is_some());
}
#[test]
fn steady_bars_are_neutral() {
// Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0.
let mut bv = BetterVolume::new(4).unwrap();
let candles: Vec<Candle> = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect();
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn churn_bar_is_positive() {
// Three normal bars, then a high-volume narrow-range bar -> positive.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect();
candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(last > 0.0, "churn bar should be positive, got {last}");
}
#[test]
fn ease_of_movement_bar_is_negative() {
// Three normal bars, then a wide-range light-volume bar -> negative.
let mut bv = BetterVolume::new(4).unwrap();
let mut candles: Vec<Candle> = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect();
candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume
let last = bv.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < 0.0,
"ease-of-movement bar should be negative, got {last}"
);
}
#[test]
fn zero_everything_is_zero() {
// Zero volume and zero range -> both legs guarded to 0.
let mut bv = BetterVolume::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect();
for v in bv.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn reset_clears_state() {
let mut bv = BetterVolume::new(3).unwrap();
bv.batch(
&(0..6)
.map(|_| candle(102.0, 100.0, 1_000.0))
.collect::<Vec<_>>(),
);
assert!(bv.is_ready());
bv.reset();
assert!(!bv.is_ready());
assert_eq!(bv.value(), None);
assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(
base + 2.0,
base - 1.5,
1_000.0 + (f64::from(i) * 0.5).cos() * 400.0,
)
})
.collect();
let batch = BetterVolume::new(20).unwrap().batch(&candles);
let mut b = BetterVolume::new(20).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,360 @@
//! Elder `SafeZone` Stop — a trailing stop set by the average noise penetration.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`ElderSafeZone`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ElderSafeZoneOutput {
/// The `SafeZone` stop level — below price when long, above price when short.
pub value: f64,
/// Trend direction: `+1.0` long, `-1.0` short.
pub direction: f64,
}
/// Elder `SafeZone` Stop — Alexander Elder's stop placed a multiple of the
/// **average market noise** away from price.
///
/// ```text
/// long market noise = average downside penetration = mean( prev_low low | low < prev_low )
/// short market noise = average upside penetration = mean( high prev_high | high > prev_high )
/// long stop = ratchet_up( low_t coeff · avg_down_penetration )
/// short stop = ratchet_down( high_t + coeff · avg_up_penetration )
/// ```
///
/// Elder defines *noise* in an uptrend as the part of each bar that pokes below
/// the previous bar's low (a "downside penetration"). Averaging those
/// penetrations over a lookback and placing the stop `coeff` multiples below the
/// current low keeps the stop just outside normal pullbacks while still exiting on
/// a genuine reversal. The stop trails in the trend's favour and flips when price
/// closes through it. The average uses only the bars that actually penetrated
/// (Elder's definition), so a noiseless trend gives a tight stop at the bar's
/// extreme.
///
/// The first bar seeds the prior candle; the next `period` bars accumulate the
/// penetration statistics, so the first stop lands after `period + 1` inputs.
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ElderSafeZone};
///
/// let mut indicator = ElderSafeZone::new(14, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ElderSafeZone {
period: usize,
coeff: f64,
prev: Option<Candle>,
down_pen: VecDeque<f64>,
up_pen: VecDeque<f64>,
down_sum: f64,
up_sum: f64,
down_count: usize,
up_count: usize,
direction: f64,
stop: f64,
last: Option<ElderSafeZoneOutput>,
}
impl ElderSafeZone {
/// Construct an Elder `SafeZone` stop with the given averaging `period` and
/// noise `coeff`icient.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0` and
/// [`Error::NonPositiveMultiplier`] if `coeff` is not finite and positive.
pub fn new(period: usize, coeff: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coeff.is_finite() || coeff <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
coeff,
prev: None,
down_pen: VecDeque::with_capacity(period),
up_pen: VecDeque::with_capacity(period),
down_sum: 0.0,
up_sum: 0.0,
down_count: 0,
up_count: 0,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(period, coeff)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.coeff)
}
/// Current value if available.
pub const fn value(&self) -> Option<ElderSafeZoneOutput> {
self.last
}
fn push(window: &mut VecDeque<f64>, sum: &mut f64, count: &mut usize, period: usize, pen: f64) {
if window.len() == period {
let old = window.pop_front().expect("non-empty");
*sum -= old;
if old > 0.0 {
*count -= 1;
}
}
window.push_back(pen);
*sum += pen;
if pen > 0.0 {
*count += 1;
}
}
fn avg(sum: f64, count: usize) -> f64 {
if count == 0 {
0.0
} else {
sum / count as f64
}
}
}
impl Indicator for ElderSafeZone {
type Input = Candle;
type Output = ElderSafeZoneOutput;
fn update(&mut self, candle: Candle) -> Option<ElderSafeZoneOutput> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
let dp = (prev.low - candle.low).max(0.0);
let up = (candle.high - prev.high).max(0.0);
self.prev = Some(candle);
Self::push(
&mut self.down_pen,
&mut self.down_sum,
&mut self.down_count,
self.period,
dp,
);
Self::push(
&mut self.up_pen,
&mut self.up_sum,
&mut self.up_count,
self.period,
up,
);
if self.down_pen.len() < self.period {
return None;
}
let avg_down = Self::avg(self.down_sum, self.down_count);
let avg_up = Self::avg(self.up_sum, self.up_count);
if self.direction == 0.0 {
self.direction = 1.0;
self.stop = candle.low - self.coeff * avg_down;
} else if self.direction > 0.0 {
let raw = candle.low - self.coeff * avg_down;
self.stop = self.stop.max(raw);
if candle.close < self.stop {
self.direction = -1.0;
self.stop = candle.high + self.coeff * avg_up;
}
} else {
let raw = candle.high + self.coeff * avg_up;
self.stop = self.stop.min(raw);
if candle.close > self.stop {
self.direction = 1.0;
self.stop = candle.low - self.coeff * avg_down;
}
}
let out = ElderSafeZoneOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
self.down_pen.clear();
self.up_pen.clear();
self.down_sum = 0.0;
self.up_sum = 0.0;
self.down_count = 0;
self.up_count = 0;
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ElderSafeZone"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(ElderSafeZone::new(0, 2.0), Err(Error::PeriodZero)));
assert!(matches!(
ElderSafeZone::new(14, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
ElderSafeZone::new(14, -1.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let e = ElderSafeZone::new(14, 2.0).unwrap();
assert_eq!(e.params(), (14, 2.0));
assert_eq!(e.warmup_period(), 15);
assert_eq!(e.name(), "ElderSafeZone");
assert!(!e.is_ready());
assert_eq!(e.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = e.batch(&candles);
let warmup = e.warmup_period(); // 4
assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in e.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value <= candle.close);
}
}
}
#[test]
fn noiseless_trend_stop_sits_at_low() {
// Every bar makes a higher low -> no downside penetration -> avg 0 ->
// the stop sits exactly at the bar's low.
let mut e = ElderSafeZone::new(3, 2.0).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
let out = e.batch(&candles);
let last_candle = candles.last().unwrap();
let last = out.last().unwrap().unwrap();
assert!((last.value - last_candle.low).abs() < 1e-9);
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - f64::from(i);
c(base + 1.0, base - 1.0, base - 0.5)
}));
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let dirs: Vec<f64> = e
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut e = ElderSafeZone::new(5, 2.0).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
e.batch(&candles);
assert!(e.is_ready());
e.reset();
assert!(!e.is_ready());
assert_eq!(e.value(), None);
assert_eq!(e.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = ElderSafeZone::new(14, 2.0).unwrap().batch(&candles);
let mut b = ElderSafeZone::new(14, 2.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,185 @@
//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Intensity Index — David Bostian's cumulative line that weights each
/// bar's volume by where the close lands inside the bar's range.
///
/// ```text
/// II_t = volume * (2*close high low) / (high low) (0 if high == low)
/// III_t = III_{t1} + II_t
/// ```
///
/// The fraction `(2*close high low) / (high low)` is `+1` when the bar
/// closes on its high, `1` when it closes on its low, and `0` at the midpoint.
/// Scaling it by volume and accumulating produces a running measure of how
/// aggressively the close is being pushed toward the extremes — Bostian's proxy
/// for institutional accumulation (rising line) or distribution (falling line).
///
/// This is the **cumulative** Intraday Intensity (the original index), not the
/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II`
/// by a windowed sum of volume and is mathematically identical to
/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is
/// arbitrary; only its slope and divergences against price matter. A doji whose
/// `high == low` contributes nothing. Each `update` is O(1) and the first bar
/// already emits a value.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, IntradayIntensity};
///
/// let mut indicator = IntradayIntensity::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct IntradayIntensity {
iii: f64,
last: Option<f64>,
}
impl IntradayIntensity {
/// Construct a new Intraday Intensity Index. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for IntradayIntensity {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let range = candle.high - candle.low;
let ii = if range > 0.0 {
candle.volume * (2.0 * candle.close - candle.high - candle.low) / range
} else {
0.0
};
self.iii += ii;
self.last = Some(self.iii);
Some(self.iii)
}
fn reset(&mut self) {
self.iii = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"IntradayIntensity"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn accessors_and_metadata() {
let iii = IntradayIntensity::new();
assert_eq!(iii.warmup_period(), 1);
assert_eq!(iii.name(), "IntradayIntensity");
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn first_bar_emits() {
// close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0.
let mut iii = IntradayIntensity::new();
// close on the high -> +1 * volume.
let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap();
assert_relative_eq!(v, 500.0, epsilon = 1e-9);
}
#[test]
fn close_on_high_adds_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 1_000.0, epsilon = 1e-9);
}
#[test]
fn close_on_low_subtracts_full_volume() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, -1_000.0, epsilon = 1e-9);
}
#[test]
fn close_at_midpoint_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn zero_range_adds_nothing() {
let mut iii = IntradayIntensity::new();
let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn accumulates_across_bars() {
let mut iii = IntradayIntensity::new();
iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000
let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600
assert_relative_eq!(v, 600.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut iii = IntradayIntensity::new();
iii.batch(&[
candle(110.0, 100.0, 108.0, 1.0),
candle(110.0, 100.0, 102.0, 1.0),
]);
assert!(iii.is_ready());
iii.reset();
assert!(!iii.is_ready());
assert_eq!(iii.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0;
candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i))
})
.collect();
let batch = IntradayIntensity::new().batch(&candles);
let mut b = IntradayIntensity::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,341 @@
//! Kase `DevStop` — a volatility trailing stop on the standard deviation of the
//! two-bar true range.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`KaseDevStop`]: the active trailing-stop level and the trend
/// direction it protects.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KaseDevStopOutput {
/// The `DevStop` level — below price in an uptrend, above price in a downtrend.
pub value: f64,
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
pub direction: f64,
}
/// Sample standard deviation from a running `(sum, sum_of_squares, count)`.
fn sample_stddev(sum: f64, sum_sq: f64, count: usize) -> f64 {
let n = count as f64;
let mean = sum / n;
(((sum_sq - n * mean * mean) / (n - 1.0)).max(0.0)).sqrt()
}
/// Kase `DevStop` — Cynthia Kase's volatility stop, built on the **standard
/// deviation of the two-bar true range** rather than a single-bar ATR.
///
/// ```text
/// DTR_t = max(high_t, high_{t1}) min(low_t, low_{t1}) (two-bar range)
/// band = mean(DTR, period) + dev · stddev(DTR, period)
/// long stop = ratchet_up( highest_high_since_flip band )
/// short stop = ratchet_down( lowest_low_since_flip + band )
/// ```
///
/// Kase observed that range expansion is better captured by a two-bar range than
/// a one-bar one, and that subtracting a *standard-deviation* band (not a fixed
/// ATR multiple) adapts the stop to changing volatility. The stop trails the
/// extreme reached since the last reversal — ratcheting only in the trend's favour
/// — and flips sides when price closes through it. `dev` selects which `DevStop`
/// line to follow (`1`, `2` or `3` standard deviations are Kase's warning lines).
///
/// The first bar seeds the prior candle; the next `period` two-bar ranges seed the
/// mean and standard deviation, so the first stop lands after `period + 1` inputs.
/// Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, KaseDevStop};
///
/// let mut indicator = KaseDevStop::new(30, 1.0).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct KaseDevStop {
period: usize,
dev: f64,
prev: Option<Candle>,
window: VecDeque<f64>,
sum: f64,
sum_sq: f64,
direction: f64,
extreme: f64,
stop: f64,
last: Option<KaseDevStopOutput>,
}
impl KaseDevStop {
/// Construct a Kase `DevStop` with the given lookback `period` and
/// standard-deviation multiplier `dev`.
///
/// # Errors
///
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a standard deviation
/// needs at least two samples) and [`Error::NonPositiveMultiplier`] if `dev`
/// is not finite and positive.
pub fn new(period: usize, dev: f64) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "Kase DevStop period must be >= 2",
});
}
if !dev.is_finite() || dev <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
dev,
prev: None,
window: VecDeque::with_capacity(period),
sum: 0.0,
sum_sq: 0.0,
direction: 0.0,
extreme: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured `(period, dev)`.
pub const fn params(&self) -> (usize, f64) {
(self.period, self.dev)
}
/// Current value if available.
pub const fn value(&self) -> Option<KaseDevStopOutput> {
self.last
}
}
impl Indicator for KaseDevStop {
type Input = Candle;
type Output = KaseDevStopOutput;
fn update(&mut self, candle: Candle) -> Option<KaseDevStopOutput> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
let dtr = candle.high.max(prev.high) - candle.low.min(prev.low);
self.prev = Some(candle);
if self.window.len() == self.period {
let old = self.window.pop_front().expect("non-empty");
self.sum -= old;
self.sum_sq -= old * old;
}
self.window.push_back(dtr);
self.sum += dtr;
self.sum_sq += dtr * dtr;
if self.window.len() < self.period {
return None;
}
let mean = self.sum / self.period as f64;
let band = mean + self.dev * sample_stddev(self.sum, self.sum_sq, self.period);
if self.direction == 0.0 {
// Seed the trend as long off the first fully-warmed bar.
self.direction = 1.0;
self.extreme = candle.high;
self.stop = candle.high - band;
} else if self.direction > 0.0 {
self.extreme = self.extreme.max(candle.high);
let raw = self.extreme - band;
self.stop = self.stop.max(raw);
if candle.close < self.stop {
self.direction = -1.0;
self.extreme = candle.low;
self.stop = candle.low + band;
}
} else {
self.extreme = self.extreme.min(candle.low);
let raw = self.extreme + band;
self.stop = self.stop.min(raw);
if candle.close > self.stop {
self.direction = 1.0;
self.extreme = candle.high;
self.stop = candle.high - band;
}
}
let out = KaseDevStopOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.prev = None;
self.window.clear();
self.sum = 0.0;
self.sum_sq = 0.0;
self.direction = 0.0;
self.extreme = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"KaseDevStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, close: f64) -> Candle {
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(
KaseDevStop::new(1, 1.0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
KaseDevStop::new(30, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
KaseDevStop::new(30, -1.0),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let k = KaseDevStop::new(30, 1.0).unwrap();
assert_eq!(k.params(), (30, 1.0));
assert_eq!(k.warmup_period(), 31);
assert_eq!(k.name(), "KaseDevStop");
assert!(!k.is_ready());
assert_eq!(k.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut k = KaseDevStop::new(3, 1.0).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base)
})
.collect();
let out = k.batch(&candles);
let warmup = k.warmup_period(); // 4
assert_eq!(warmup, 4);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
for (o, candle) in k.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
assert!(o.value < candle.close, "stop below price");
}
}
}
#[test]
fn stop_ratchets_up_in_uptrend() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
let base = 100.0 + 2.0 * f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
let mut prev = f64::NEG_INFINITY;
for o in k.batch(&candles).into_iter().flatten() {
assert!(o.value >= prev, "long stop must not fall");
prev = o.value;
}
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
candles.extend((0..40).map(|i| {
let base = 140.0 - f64::from(i);
c(base + 1.0, base - 1.0, base - 0.5)
}));
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let dirs: Vec<f64> = k
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut k = KaseDevStop::new(5, 1.0).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 1.0, base - 1.0, base + 0.5)
})
.collect();
k.batch(&candles);
assert!(k.is_ready());
k.reset();
assert!(!k.is_ready());
assert_eq!(k.value(), None);
assert_eq!(k.update(candles[0]), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
c(base + 2.0, base - 1.5, base + 0.5)
})
.collect();
let batch = KaseDevStop::new(20, 2.0).unwrap().batch(&candles);
let mut b = KaseDevStop::new(20, 2.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+40 -1
View File
@@ -35,6 +35,7 @@ mod aroon;
mod aroon_oscillator;
mod atr;
mod atr_bands;
mod atr_ratchet;
mod atr_trailing_stop;
mod auto_fib;
mod autocorrelation;
@@ -48,6 +49,7 @@ mod bat;
mod belt_hold;
mod beta;
mod beta_neutral_spread;
mod better_volume;
mod bipower_variation;
mod body_size_pct;
mod bollinger;
@@ -115,6 +117,7 @@ mod ehlers_stochastic;
mod ehma;
mod elder_impulse;
mod elder_ray;
mod elder_safezone;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
@@ -183,6 +186,7 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_intensity;
mod intraday_momentum_index;
mod intraday_volatility_profile;
mod inverse_fisher_transform;
@@ -192,6 +196,7 @@ mod jump_indicator;
mod kagi_bars;
mod kalman_hedge_ratio;
mod kama;
mod kase_devstop;
mod kase_permission_stochastic;
mod kelly_criterion;
mod keltner;
@@ -239,11 +244,13 @@ mod mid_point;
mod mid_price;
mod minus_di;
mod minus_dm;
mod modified_ma_stop;
mod mom;
mod morning_doji_star;
mod morning_evening_star;
mod natr;
mod new_highs_new_lows;
mod nrtr;
mod nvi;
mod ob_imbalance_full;
mod ob_imbalance_top1;
@@ -378,9 +385,11 @@ mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
mod time_based_stop;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod trade_volume_index;
mod trend_label;
mod trend_strength_index;
mod treynor_ratio;
@@ -398,6 +407,7 @@ mod ttm_squeeze;
mod ttm_trend;
mod turn_of_month;
mod tweezer;
mod twiggs_money_flow;
mod two_crows;
mod typical_price;
mod ulcer_index;
@@ -419,6 +429,8 @@ mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod volume_rsi;
mod volume_weighted_macd;
mod vortex;
mod vpin;
mod vpt;
@@ -426,6 +438,7 @@ mod vwap;
mod vwap_stddev_bands;
mod vwma;
mod vzo;
mod wad;
mod wave_pm;
mod wave_trend;
mod wedge;
@@ -469,6 +482,7 @@ pub use aroon::{Aroon, AroonOutput};
pub use aroon_oscillator::AroonOscillator;
pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_ratchet::{AtrRatchet, AtrRatchetOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use auto_fib::{AutoFib, AutoFibOutput};
pub use autocorrelation::Autocorrelation;
@@ -482,6 +496,7 @@ pub use bat::Bat;
pub use belt_hold::BeltHold;
pub use beta::Beta;
pub use beta_neutral_spread::BetaNeutralSpread;
pub use better_volume::BetterVolume;
pub use bipower_variation::BipowerVariation;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
@@ -549,6 +564,7 @@ pub use ehlers_stochastic::EhlersStochastic;
pub use ehma::Ehma;
pub use elder_impulse::ElderImpulse;
pub use elder_ray::{ElderRay, ElderRayOutput};
pub use elder_safezone::{ElderSafeZone, ElderSafeZoneOutput};
pub use ema::Ema;
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
pub use engulfing::Engulfing;
@@ -617,6 +633,7 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_intensity::IntradayIntensity;
pub use intraday_momentum_index::IntradayMomentumIndex;
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
@@ -626,6 +643,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_devstop::{KaseDevStop, KaseDevStopOutput};
pub use kase_permission_stochastic::{KasePermissionStochastic, KasePermissionStochasticOutput};
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
@@ -673,11 +691,13 @@ pub use mid_point::MidPoint;
pub use mid_price::MidPrice;
pub use minus_di::MinusDi;
pub use minus_dm::MinusDm;
pub use modified_ma_stop::{ModifiedMaStop, ModifiedMaStopOutput};
pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
pub use natr::Natr;
pub use new_highs_new_lows::NewHighsNewLows;
pub use nrtr::{Nrtr, NrtrOutput};
pub use nvi::Nvi;
pub use ob_imbalance_full::OrderBookImbalanceFull;
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
@@ -812,9 +832,11 @@ pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use time_based_stop::TimeBasedStop;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use trade_volume_index::TradeVolumeIndex;
pub use trend_label::TrendLabel;
pub use trend_strength_index::TrendStrengthIndex;
pub use treynor_ratio::TreynorRatio;
@@ -832,6 +854,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use ttm_trend::TtmTrend;
pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use twiggs_money_flow::TwiggsMoneyFlow;
pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
pub use ulcer_index::UlcerIndex;
@@ -853,6 +876,8 @@ pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use volume_rsi::VolumeRsi;
pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput};
pub use vortex::{Vortex, VortexOutput};
pub use vpin::Vpin;
pub use vpt::VolumePriceTrend;
@@ -860,6 +885,7 @@ pub use vwap::{RollingVwap, Vwap};
pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
pub use vwma::Vwma;
pub use vzo::Vzo;
pub use wad::Wad;
pub use wave_pm::WavePm;
pub use wave_trend::{WaveTrend, WaveTrendOutput};
pub use wedge::Wedge;
@@ -1073,6 +1099,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"StepTrailingStop",
"RenkoTrailingStop",
"SarExt",
"KaseDevStop",
"ElderSafeZone",
"AtrRatchet",
"Nrtr",
"TimeBasedStop",
"ModifiedMaStop",
],
),
(
@@ -1097,6 +1129,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Tsv",
"Vzo",
"MarketFacilitationIndex",
"VolumeRsi",
"Wad",
"TwiggsMoneyFlow",
"TradeVolumeIndex",
"IntradayIntensity",
"BetterVolume",
"VolumeWeightedMacd",
],
),
(
@@ -1456,6 +1495,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, 434, "FAMILIES total drifted from indicator count");
assert_eq!(total, 447, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,238 @@
//! Modified-MA Stop — a trailing stop riding the Modified Moving Average (SMMA).
use crate::error::{Error, Result};
use crate::indicators::smma::Smma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`ModifiedMaStop`]: the active stop level and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ModifiedMaStopOutput {
/// The stop level (a directionally-ratcheted Modified Moving Average).
pub value: f64,
/// Trend direction: `+1.0` long (stop below price), `-1.0` short.
pub direction: f64,
}
/// Modified-MA Stop — a trailing stop whose line is the **Modified Moving
/// Average** (SMMA / Wilder's RMA) of price, allowed to move only in the trend's
/// favour.
///
/// ```text
/// ma = SMMA(close, period) (Modified Moving Average)
/// long: stop = max(prev_stop, ma); flip short when close < stop
/// short: stop = min(prev_stop, ma); flip long when close > stop
/// ```
///
/// The Modified Moving Average (also called the smoothed or running moving
/// average) is the slow, low-lag average Wilder used throughout his systems. Using
/// it directly as a trailing line — but **ratcheting** so the long stop never
/// falls and the short stop never rises — turns the smooth average into a stop
/// that hugs price in a trend and flips when price decisively crosses it. Because
/// the SMMA lags, the stop gives trends room while still exiting clean reversals.
///
/// The first stop lands once the SMMA is ready (`period` inputs). Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ModifiedMaStop};
///
/// let mut indicator = ModifiedMaStop::new(14).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ModifiedMaStop {
smma: Smma,
period: usize,
direction: f64,
stop: f64,
last: Option<ModifiedMaStopOutput>,
}
impl ModifiedMaStop {
/// Construct a Modified-MA stop with the given SMMA `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
smma: Smma::new(period)?,
period,
direction: 0.0,
stop: 0.0,
last: None,
})
}
/// Configured SMMA period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<ModifiedMaStopOutput> {
self.last
}
}
impl Indicator for ModifiedMaStop {
type Input = Candle;
type Output = ModifiedMaStopOutput;
fn update(&mut self, candle: Candle) -> Option<ModifiedMaStopOutput> {
let ma = self.smma.update(candle.close)?;
let close = candle.close;
if self.direction == 0.0 {
self.direction = if close >= ma { 1.0 } else { -1.0 };
self.stop = ma;
} else if self.direction > 0.0 {
self.stop = self.stop.max(ma);
if close < self.stop {
self.direction = -1.0;
self.stop = ma;
}
} else {
self.stop = self.stop.min(ma);
if close > self.stop {
self.direction = 1.0;
self.stop = ma;
}
}
let out = ModifiedMaStopOutput {
value: self.stop,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.smma.reset();
self.direction = 0.0;
self.stop = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ModifiedMaStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(close: f64) -> Candle {
Candle::new_unchecked(close, close + 1.0, close - 1.0, close, 1_000.0, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(ModifiedMaStop::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let m = ModifiedMaStop::new(14).unwrap();
assert_eq!(m.period(), 14);
assert_eq!(m.warmup_period(), 14);
assert_eq!(m.name(), "ModifiedMaStop");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..12).map(|i| c(100.0 + f64::from(i))).collect();
let out = m.batch(&candles);
for v in out.iter().take(4) {
assert!(v.is_none());
}
assert!(out[4].is_some());
}
#[test]
fn uptrend_keeps_stop_below_price() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
for (o, candle) in m.batch(&candles).into_iter().zip(candles.iter()) {
if let Some(o) = o {
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
}
#[test]
fn long_stop_ratchets_up() {
let mut m = ModifiedMaStop::new(5).unwrap();
let candles: Vec<Candle> = (0..60).map(|i| c(100.0 + 2.0 * f64::from(i))).collect();
let mut prev = f64::NEG_INFINITY;
for o in m.batch(&candles).into_iter().flatten() {
assert_eq!(o.direction, 1.0, "pure uptrend stays long");
assert!(o.value >= prev, "long stop must not fall");
prev = o.value;
}
}
#[test]
fn flips_on_reversal() {
let mut candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
candles.extend((0..40).map(|i| c(140.0 - f64::from(i))));
let mut m = ModifiedMaStop::new(5).unwrap();
let dirs: Vec<f64> = m
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn reset_clears_state() {
let mut m = ModifiedMaStop::new(5).unwrap();
m.batch(&(0..40).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.value(), None);
assert_eq!(m.update(c(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 9.0))
.collect();
let batch = ModifiedMaStop::new(14).unwrap().batch(&candles);
let mut b = ModifiedMaStop::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+259
View File
@@ -0,0 +1,259 @@
//! NRTR — Nick Rypock Trailing Reverse, a percentage trailing-reverse stop.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`Nrtr`]: the trailing-reverse line and the trend direction.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NrtrOutput {
/// The NRTR line — below price in an uptrend, above price in a downtrend.
pub value: f64,
/// Trend direction: `+1.0` up (line below price), `-1.0` down.
pub direction: f64,
}
/// NRTR (Nick Rypock Trailing Reverse) — a **percentage** trailing-reverse stop
/// that follows the trend extreme and flips when price retraces by a fixed
/// percentage.
///
/// ```text
/// uptrend: high_water = max(high_water, close)
/// line = high_water · (1 pct/100)
/// flip down when close < line (reseed low_water = close)
/// downtrend: low_water = min(low_water, close)
/// line = low_water · (1 + pct/100)
/// flip up when close > line (reseed high_water = close)
/// ```
///
/// Unlike volatility stops (ATR, σ-of-range), NRTR uses a pure **percentage**
/// retracement: the line trails the highest close reached in the up-leg at a
/// fixed `pct` below it, and a close that gives back that percentage reverses the
/// trend, handing the line to the opposite extreme. This makes it scale-free and
/// trivially tunable — one number sets how much retracement you tolerate. It
/// differs from a fixed percentage *stop-loss* in that it **reverses** (tracks
/// both directions) rather than just exiting.
///
/// The first bar seeds the up-trend and emits a line immediately. Each `update` is
/// O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Nrtr};
///
/// let mut indicator = Nrtr::new(2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let close = 100.0 + f64::from(i);
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Nrtr {
pct: f64,
direction: f64,
water: f64,
last: Option<NrtrOutput>,
}
impl Nrtr {
/// Construct an NRTR with the given trailing percentage (e.g. `2.0` for 2%).
///
/// # Errors
///
/// Returns [`Error::InvalidParameter`] if `pct` is not finite or is outside
/// `(0, 100)`.
pub fn new(pct: f64) -> Result<Self> {
if !pct.is_finite() || pct <= 0.0 || pct >= 100.0 {
return Err(Error::InvalidParameter {
message: "NRTR percentage must be in (0, 100)",
});
}
Ok(Self {
pct,
direction: 0.0,
water: 0.0,
last: None,
})
}
/// Configured trailing percentage.
pub const fn pct(&self) -> f64 {
self.pct
}
/// Current value if available.
pub const fn value(&self) -> Option<NrtrOutput> {
self.last
}
}
impl Indicator for Nrtr {
type Input = Candle;
type Output = NrtrOutput;
fn update(&mut self, candle: Candle) -> Option<NrtrOutput> {
let close = candle.close;
let down = self.pct / 100.0;
let up = self.pct / 100.0;
if self.direction == 0.0 {
self.direction = 1.0;
self.water = close;
} else if self.direction > 0.0 {
self.water = self.water.max(close);
let line = self.water * (1.0 - down);
if close < line {
self.direction = -1.0;
self.water = close;
}
} else {
self.water = self.water.min(close);
let line = self.water * (1.0 + up);
if close > line {
self.direction = 1.0;
self.water = close;
}
}
let line = if self.direction > 0.0 {
self.water * (1.0 - down)
} else {
self.water * (1.0 + up)
};
let out = NrtrOutput {
value: line,
direction: self.direction,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.direction = 0.0;
self.water = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Nrtr"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(close: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, 1_000.0, 0)
}
#[test]
fn rejects_invalid_pct() {
assert!(matches!(
Nrtr::new(0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Nrtr::new(100.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
Nrtr::new(f64::NAN),
Err(Error::InvalidParameter { .. })
));
assert!(Nrtr::new(2.0).is_ok());
}
#[test]
fn accessors_and_metadata() {
let n = Nrtr::new(2.0).unwrap();
assert_eq!(n.pct(), 2.0);
assert_eq!(n.warmup_period(), 1);
assert_eq!(n.name(), "Nrtr");
assert!(!n.is_ready());
assert_eq!(n.value(), None);
}
#[test]
fn first_bar_emits_up_line() {
let mut n = Nrtr::new(10.0).unwrap();
let o = n.update(c(100.0)).unwrap();
assert_eq!(o.direction, 1.0);
// line = 100 * (1 - 0.10) = 90.
assert!((o.value - 90.0).abs() < 1e-9);
}
#[test]
fn uptrend_keeps_line_below_price() {
let mut n = Nrtr::new(5.0).unwrap();
let candles: Vec<Candle> = (0..40).map(|i| c(100.0 + f64::from(i))).collect();
for (o, candle) in n.batch(&candles).into_iter().zip(candles.iter()) {
let o = o.unwrap();
assert_eq!(o.direction, 1.0);
assert!(o.value < candle.close);
}
}
#[test]
fn reverses_on_retracement() {
let mut n = Nrtr::new(5.0).unwrap();
// Rise to 120, then drop sharply -> a >5% retracement reverses the trend.
let mut candles: Vec<Candle> = (0..20).map(|i| c(100.0 + f64::from(i))).collect();
candles.extend((0..10).map(|i| c(119.0 - 3.0 * f64::from(i))));
let dirs: Vec<f64> = n
.batch(&candles)
.into_iter()
.flatten()
.map(|o| o.direction)
.collect();
assert!(dirs.iter().any(|&d| d > 0.0));
assert!(dirs.iter().any(|&d| d < 0.0));
}
#[test]
fn downtrend_keeps_line_above_price() {
let mut n = Nrtr::new(5.0).unwrap();
// Establish a downtrend after an initial bar.
let mut candles = vec![c(100.0)];
candles.extend((0..30).map(|i| c(80.0 - f64::from(i))));
let out = n.batch(&candles);
let o = out.last().unwrap().unwrap();
let candle = candles.last().unwrap();
assert_eq!(o.direction, -1.0);
assert!(o.value > candle.close);
}
#[test]
fn reset_clears_state() {
let mut n = Nrtr::new(2.0).unwrap();
n.batch(&(0..20).map(|i| c(100.0 + f64::from(i))).collect::<Vec<_>>());
assert!(n.is_ready());
n.reset();
assert!(!n.is_ready());
assert_eq!(n.value(), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| c(100.0 + (f64::from(i) * 0.25).sin() * 15.0))
.collect();
let batch = Nrtr::new(3.0).unwrap().batch(&candles);
let mut b = Nrtr::new(3.0).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,192 @@
//! Time-Based Stop — a holding-period timer that fires after a fixed bar count.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Time-Based Stop — exits a position purely on **elapsed bars**, independent of
/// price.
///
/// ```text
/// bars_held increments by 1 each bar (since the last reset)
/// progress = min(bars_held / max_bars, 1.0) in [0, 1]
/// stop fires when progress == 1.0 (bars_held >= max_bars)
/// ```
///
/// Some setups should not be given unlimited time to work: a mean-reversion entry
/// that has not reverted within `max_bars`, or an event trade whose catalyst has
/// passed, is best closed regardless of price. This indicator is a pure timer —
/// it ignores the candle's prices entirely and reports the fraction of the
/// holding window that has elapsed, reaching `1.0` (the stop) after `max_bars`
/// bars. **Call [`reset`](Indicator::reset) on each new entry** so the timer
/// restarts from the position open.
///
/// Each `update` is O(1) and the first bar already emits a value
/// (`1 / max_bars`).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TimeBasedStop};
///
/// let mut indicator = TimeBasedStop::new(5).unwrap();
/// let c = Candle::new(100.0, 101.0, 99.0, 100.0, 1.0, 0).unwrap();
/// // Five bars reach the stop.
/// let mut last = 0.0;
/// for _ in 0..5 {
/// last = indicator.update(c).unwrap();
/// }
/// assert_eq!(last, 1.0);
/// ```
#[derive(Debug, Clone)]
pub struct TimeBasedStop {
max_bars: usize,
bars_held: usize,
last: Option<f64>,
}
impl TimeBasedStop {
/// Construct a time-based stop that fires after `max_bars` bars.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `max_bars == 0`.
pub fn new(max_bars: usize) -> Result<Self> {
if max_bars == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
max_bars,
bars_held: 0,
last: None,
})
}
/// Configured maximum holding period in bars.
pub const fn max_bars(&self) -> usize {
self.max_bars
}
/// Number of bars held since the last reset.
pub const fn bars_held(&self) -> usize {
self.bars_held
}
/// Whether the stop has fired (the holding period has fully elapsed).
pub const fn triggered(&self) -> bool {
self.bars_held >= self.max_bars
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TimeBasedStop {
type Input = Candle;
type Output = f64;
fn update(&mut self, _candle: Candle) -> Option<f64> {
self.bars_held += 1;
let progress = (self.bars_held as f64 / self.max_bars as f64).min(1.0);
self.last = Some(progress);
Some(progress)
}
fn reset(&mut self) {
self.bars_held = 0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TimeBasedStop"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c() -> Candle {
Candle::new_unchecked(100.0, 101.0, 99.0, 100.0, 1.0, 0)
}
#[test]
fn rejects_zero_max_bars() {
assert!(matches!(TimeBasedStop::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let t = TimeBasedStop::new(5).unwrap();
assert_eq!(t.max_bars(), 5);
assert_eq!(t.bars_held(), 0);
assert!(!t.triggered());
assert_eq!(t.warmup_period(), 1);
assert_eq!(t.name(), "TimeBasedStop");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn progress_climbs_to_one() {
let mut t = TimeBasedStop::new(4).unwrap();
let out = t.batch(&[c(), c(), c(), c()]);
assert_relative_eq!(out[0].unwrap(), 0.25, epsilon = 1e-12);
assert_relative_eq!(out[1].unwrap(), 0.50, epsilon = 1e-12);
assert_relative_eq!(out[2].unwrap(), 0.75, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), 1.00, epsilon = 1e-12);
}
#[test]
fn triggers_after_max_bars() {
let mut t = TimeBasedStop::new(3).unwrap();
t.update(c());
assert!(!t.triggered());
t.update(c());
assert!(!t.triggered());
t.update(c());
assert!(t.triggered());
}
#[test]
fn progress_saturates_at_one() {
// Beyond max_bars the progress stays clamped at 1.0.
let mut t = TimeBasedStop::new(2).unwrap();
let out = t.batch(&[c(), c(), c(), c()]);
assert_relative_eq!(out[2].unwrap(), 1.0, epsilon = 1e-12);
assert_relative_eq!(out[3].unwrap(), 1.0, epsilon = 1e-12);
}
#[test]
fn reset_restarts_timer() {
let mut t = TimeBasedStop::new(3).unwrap();
t.batch(&[c(), c(), c()]);
assert!(t.triggered());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.bars_held(), 0);
assert!(!t.triggered());
assert_relative_eq!(t.update(c()).unwrap(), 1.0 / 3.0, epsilon = 1e-12);
}
#[test]
fn batch_equals_streaming() {
let candles = [c(); 10];
let batch = TimeBasedStop::new(4).unwrap().batch(&candles);
let mut b = TimeBasedStop::new(4).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,235 @@
//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Trade Volume Index — a cumulative line that adds volume while price ticks up
/// and subtracts it while price ticks down, where "up" and "down" are decided by
/// a **minimum tick value** rather than any change.
///
/// ```text
/// change = close prev_close
/// if change > min_tick: direction = +1
/// if change < min_tick: direction = 1
/// else: direction unchanged (price is "churning")
/// TVI_t = TVI_{t1} + direction * volume
/// ```
///
/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick`
/// flip the accumulation direction, so a price drifting within the spread keeps
/// adding volume in the last established direction instead of whipsawing. This is
/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter
/// and applied to close-to-close moves. Like all cumulative lines, only its slope
/// and divergences against price carry meaning — the absolute level is arbitrary.
///
/// The first candle seeds the reference close and emits nothing; thereafter each
/// bar emits the running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TradeVolumeIndex};
///
/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap();
/// let mut last = None;
/// for i in 0..20 {
/// let close = 100.0 + f64::from(i);
/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TradeVolumeIndex {
min_tick: f64,
prev_close: Option<f64>,
direction: f64,
tvi: f64,
last: Option<f64>,
}
impl TradeVolumeIndex {
/// Construct a new Trade Volume Index with the given minimum tick value.
///
/// # Errors
///
/// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is
/// negative. A `min_tick` of `0` is allowed and makes every non-zero move
/// flip the direction.
pub fn new(min_tick: f64) -> Result<Self> {
if !min_tick.is_finite() || min_tick < 0.0 {
return Err(Error::InvalidParameter {
message: "trade volume index min_tick must be finite and non-negative",
});
}
Ok(Self {
min_tick,
prev_close: None,
direction: 0.0,
tvi: 0.0,
last: None,
})
}
/// Configured minimum tick value.
pub const fn min_tick(&self) -> f64 {
self.min_tick
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for TradeVolumeIndex {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let change = candle.close - prev_close;
if change > self.min_tick {
self.direction = 1.0;
} else if change < -self.min_tick {
self.direction = -1.0;
}
// Otherwise the direction is held from the previous bar (or 0 before the
// first decisive move), so a churning price keeps its last lean.
self.tvi += self.direction * candle.volume;
self.prev_close = Some(candle.close);
self.last = Some(self.tvi);
Some(self.tvi)
}
fn reset(&mut self) {
self.prev_close = None;
self.direction = 0.0;
self.tvi = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TradeVolumeIndex"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_min_tick() {
assert!(matches!(
TradeVolumeIndex::new(-1.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
TradeVolumeIndex::new(f64::NAN),
Err(Error::InvalidParameter { .. })
));
assert!(TradeVolumeIndex::new(0.0).is_ok());
}
#[test]
fn accessors_and_metadata() {
let tvi = TradeVolumeIndex::new(0.25).unwrap();
assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12);
assert_eq!(tvi.warmup_period(), 2);
assert_eq!(tvi.name(), "TradeVolumeIndex");
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
assert_eq!(tvi.update(candle(100.0, 1_000.0)), None);
assert!(tvi.update(candle(101.0, 1_000.0)).is_some());
}
#[test]
fn uptrend_accumulates_volume() {
// Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume.
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(101.0, 500.0), // +1 -> +500
candle(102.0, 300.0), // +1 -> +300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9);
}
#[test]
fn small_move_holds_last_direction() {
// After an up-move, a sub-tick wobble keeps adding in the up direction.
let mut tvi = TradeVolumeIndex::new(1.0).unwrap();
let candles = [
candle(100.0, 1_000.0), // seed
candle(102.0, 400.0), // +2 > tick -> dir +1, +400
candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9);
assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9);
}
#[test]
fn downtrend_distributes_volume() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
let candles = [
candle(100.0, 1_000.0),
candle(99.0, 200.0), // -1 -> -200
candle(98.0, 300.0), // -1 -> -300
];
let out = tvi.batch(&candles);
assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut tvi = TradeVolumeIndex::new(0.5).unwrap();
tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]);
assert!(tvi.is_ready());
tvi.reset();
assert!(!tvi.is_ready());
assert_eq!(tvi.value(), None);
assert_eq!(tvi.update(candle(100.0, 1.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles);
let mut b = TradeVolumeIndex::new(0.5).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,314 @@
//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range**
/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum.
///
/// ```text
/// TRH = max(high, prev_close) (true high)
/// TRL = min(low, prev_close) (true low)
/// ad = volume * (2*close TRH TRL) / (TRH TRL) (0 if TRH == TRL)
/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period)
/// ```
///
/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces
/// the bar's raw high/low with the *true* high/low (folding in the prior close so
/// gaps count), and it smooths the accumulated money flow and the volume with a
/// Wilder exponential average rather than a flat `period`-sum, so the oscillator
/// reacts faster and never jumps when a large bar drops out of a window. The
/// output is bounded in roughly `[1, +1]`: positive means buying pressure
/// (closes biased toward the true high), negative means selling pressure.
///
/// The first candle seeds the reference close; the next `period` bars seed both
/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch
/// of zero volume makes the denominator average `0`, in which case the oscillator
/// reports `0` rather than `0 / 0`. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow};
///
/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap();
/// let mut last = None;
/// for i in 0..60 {
/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TwiggsMoneyFlow {
period: usize,
prev_close: Option<f64>,
seed_ad: f64,
seed_vol: f64,
seed_count: usize,
ad_ema: Option<f64>,
vol_ema: Option<f64>,
last: Option<f64>,
}
impl TwiggsMoneyFlow {
/// Construct a new Twiggs Money Flow with the given smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_close: None,
seed_ad: 0.0,
seed_vol: 0.0,
seed_count: 0,
ad_ema: None,
vol_ema: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn ratio(ad_ema: f64, vol_ema: f64) -> f64 {
if vol_ema == 0.0 {
0.0
} else {
ad_ema / vol_ema
}
}
}
impl Indicator for TwiggsMoneyFlow {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let trh = candle.high.max(prev_close);
let trl = candle.low.min(prev_close);
let range = trh - trl;
let ad = if range > 0.0 {
candle.volume * (2.0 * candle.close - trh - trl) / range
} else {
0.0
};
self.prev_close = Some(candle.close);
if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) {
let n = self.period as f64;
let new_ad = ad_ema + (ad - ad_ema) / n;
let new_vol = vol_ema + (candle.volume - vol_ema) / n;
self.ad_ema = Some(new_ad);
self.vol_ema = Some(new_vol);
let v = Self::ratio(new_ad, new_vol);
self.last = Some(v);
return Some(v);
}
self.seed_ad += ad;
self.seed_vol += candle.volume;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ad_ema = self.seed_ad / n;
let vol_ema = self.seed_vol / n;
self.ad_ema = Some(ad_ema);
self.vol_ema = Some(vol_ema);
let v = Self::ratio(ad_ema, vol_ema);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_close = None;
self.seed_ad = 0.0;
self.seed_vol = 0.0;
self.seed_count = 0;
self.ad_ema = None;
self.vol_ema = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TwiggsMoneyFlow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle {
Candle::new_unchecked(low, high, low, close, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero)));
}
#[test]
fn flat_bars_drive_tmf_to_zero() {
// A flat bar (high == low == close == prior close) gives a zero two-bar
// range, so the accumulation term falls back to 0.0 and TMF settles at
// zero. Exercises the `range == 0` guard.
let mut tmf = TwiggsMoneyFlow::new(2).unwrap();
let flat: Vec<Candle> = (0..6)
.map(|_| candle(100.0, 100.0, 100.0, 1_000.0))
.collect();
let last = tmf.batch(&flat).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn accessors_and_metadata() {
let tmf = TwiggsMoneyFlow::new(21).unwrap();
assert_eq!(tmf.period(), 21);
assert_eq!(tmf.warmup_period(), 22);
assert_eq!(tmf.name(), "TwiggsMoneyFlow");
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..8)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
let out = tmf.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn closes_at_true_high_is_positive() {
// Every bar closes at its high -> strong buying pressure -> TMF -> +1.
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
// open=low=base-1, high=close=base+1 -> closes at the top.
Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last > 0.9,
"closing at the high should drive TMF near +1, got {last}"
);
}
#[test]
fn closes_at_true_low_is_negative() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 - f64::from(i);
// closes at the low.
Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0)
})
.collect();
let last = tmf.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last < -0.5,
"closing at the low should drive TMF negative, got {last}"
);
}
#[test]
fn zero_volume_yields_zero() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 0.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn output_in_range() {
let mut tmf = TwiggsMoneyFlow::new(21).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0;
candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0)
})
.collect();
for v in tmf.batch(&candles).into_iter().flatten() {
assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}");
}
}
#[test]
fn reset_clears_state() {
let mut tmf = TwiggsMoneyFlow::new(3).unwrap();
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base, 1_000.0)
})
.collect();
tmf.batch(&candles);
assert!(tmf.is_ready());
tmf.reset();
assert!(!tmf.is_ready());
assert_eq!(tmf.value(), None);
assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i))
})
.collect();
let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles);
let mut b = TwiggsMoneyFlow::new(21).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,265 @@
//! Volume RSI — Wilder's RSI applied to the volume stream.
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume RSI — the Relative Strength Index computed on **volume** changes
/// instead of price changes.
///
/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price*
/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar
/// change in volume:
///
/// ```text
/// change_t = volume_t volume_{t1}
/// gain = max(change, 0), loss = max(change, 0)
/// avg_gain, avg_loss = Wilder-smoothed over `period`
/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss)
/// ```
///
/// Readings above `50` mean volume is expanding (more was added than removed over
/// the smoothing window) and tend to confirm the prevailing move; readings below
/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch
/// of unchanged volume drives both averages to `0` and the indicator reports the
/// neutral `50` rather than an undefined `0 / 0`.
///
/// Only the candle's **volume** is used. The first bar sets the previous volume,
/// then `period` changes seed Wilder's averages, so the first value lands after
/// `period + 1` inputs. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeRsi};
///
/// let mut indicator = VolumeRsi::new(14).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0;
/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeRsi {
period: usize,
prev_volume: Option<f64>,
seed_gains: f64,
seed_losses: f64,
seed_count: usize,
avg_gain: Option<f64>,
avg_loss: Option<f64>,
last: Option<f64>,
}
impl VolumeRsi {
/// Construct a Volume RSI with the given Wilder smoothing `period`.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev_volume: None,
seed_gains: 0.0,
seed_losses: 0.0,
seed_count: 0,
avg_gain: None,
avg_loss: None,
last: None,
})
}
/// Configured smoothing period.
pub const fn period(&self) -> usize {
self.period
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 {
let denom = avg_gain + avg_loss;
if denom == 0.0 {
50.0
} else {
100.0 * (avg_gain / denom)
}
}
}
impl Indicator for VolumeRsi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let volume = candle.volume;
let Some(prev) = self.prev_volume else {
self.prev_volume = Some(volume);
return None;
};
let change = volume - prev;
self.prev_volume = Some(volume);
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::rsi_from_avgs(new_ag, new_al);
self.last = Some(v);
return Some(v);
}
self.seed_gains += gain;
self.seed_losses += loss;
self.seed_count += 1;
if self.seed_count == self.period {
let n = self.period as f64;
let ag = self.seed_gains / n;
let al = self.seed_losses / n;
self.avg_gain = Some(ag);
self.avg_loss = Some(al);
let v = Self::rsi_from_avgs(ag, al);
self.last = Some(v);
return Some(v);
}
None
}
fn reset(&mut self) {
self.prev_volume = None;
self.seed_gains = 0.0;
self.seed_losses = 0.0;
self.seed_count = 0;
self.avg_gain = None;
self.avg_loss = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeRsi"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle whose only material field here is `volume`.
fn vol_candle(volume: f64) -> Candle {
Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0)
}
#[test]
fn rejects_zero_period() {
assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_and_metadata() {
let v = VolumeRsi::new(14).unwrap();
assert_eq!(v.period(), 14);
assert_eq!(v.warmup_period(), 15);
assert_eq!(v.name(), "VolumeRsi");
assert!(!v.is_ready());
assert_eq!(v.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect();
let out = v.batch(&candles);
// warmup_period == period + 1 == 4: first emission at index 3.
for o in out.iter().take(3) {
assert!(o.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn rising_volume_is_one_hundred() {
// Every change positive -> avg_loss 0 -> RSI 100.
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 100.0, epsilon = 1e-9);
}
#[test]
fn falling_volume_is_zero() {
let mut v = VolumeRsi::new(5).unwrap();
let candles: Vec<Candle> = (1..=40)
.map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0))
.collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-9);
}
#[test]
fn flat_volume_is_neutral() {
// Unchanged volume -> no gains and no losses -> neutral 50.
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20).map(|_| vol_candle(2_000.0)).collect();
let last = v.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 50.0, epsilon = 1e-12);
}
#[test]
fn output_in_range() {
let mut v = VolumeRsi::new(14).unwrap();
let candles: Vec<Candle> = (0..200)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0))
.collect();
for o in v.batch(&candles).into_iter().flatten() {
assert!((0.0..=100.0).contains(&o));
}
}
#[test]
fn reset_clears_state() {
let mut v = VolumeRsi::new(3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| vol_candle(1_000.0 + f64::from(i)))
.collect();
v.batch(&candles);
assert!(v.is_ready());
v.reset();
assert!(!v.is_ready());
assert_eq!(v.value(), None);
assert_eq!(v.update(vol_candle(1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0))
.collect();
let batch = VolumeRsi::new(14).unwrap().batch(&candles);
let mut b = VolumeRsi::new(14).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
@@ -0,0 +1,270 @@
//! Volume-Weighted MACD — MACD built on volume-weighted moving averages.
use crate::error::{Error, Result};
use crate::indicators::ema::Ema;
use crate::indicators::vwma::Vwma;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the
/// fast and slow averages volume-weighted.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VolumeWeightedMacdOutput {
/// Fast VWMA slow VWMA.
pub macd: f64,
/// EMA of `macd` over the signal period.
pub signal: f64,
/// `macd signal`.
pub histogram: f64,
}
/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted**
/// moving averages instead of plain EMAs.
///
/// ```text
/// macd = VWMA(close, fast) VWMA(close, slow)
/// signal = EMA(macd, signal_period)
/// histogram = macd signal
/// ```
///
/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential
/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and
/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate
/// the trend estimate and the oscillator leans toward where real participation
/// occurred. Crossovers backed by volume therefore appear sooner and noise from
/// thin bars is damped. The signal line keeps a standard EMA, matching the
/// classic histogram construction.
///
/// `fast` must be strictly smaller than `slow`. The first output lands after
/// `slow + signal 1` inputs: `slow` to seed the slow VWMA, then `signal 1`
/// more to seed the signal EMA. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd};
///
/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct VolumeWeightedMacd {
fast: Vwma,
slow: Vwma,
signal_ema: Ema,
fast_period: usize,
slow_period: usize,
signal_period: usize,
last: Option<VolumeWeightedMacdOutput>,
}
impl VolumeWeightedMacd {
/// Construct a volume-weighted MACD 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> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be strictly less than slow period",
});
}
Ok(Self {
fast: Vwma::new(fast)?,
slow: Vwma::new(slow)?,
signal_ema: Ema::new(signal)?,
fast_period: fast,
slow_period: slow,
signal_period: signal,
last: None,
})
}
/// Configured periods as `(fast, slow, signal)`.
pub const fn periods(&self) -> (usize, usize, usize) {
(self.fast_period, self.slow_period, self.signal_period)
}
/// Most recent fully-computed output if available.
pub const fn value(&self) -> Option<VolumeWeightedMacdOutput> {
self.last
}
}
impl Indicator for VolumeWeightedMacd {
type Input = Candle;
type Output = VolumeWeightedMacdOutput;
fn update(&mut self, candle: Candle) -> Option<VolumeWeightedMacdOutput> {
let fast = self.fast.update(candle);
let slow = self.slow.update(candle);
if let (Some(f), Some(s)) = (fast, slow) {
let macd = f - s;
let signal = self.signal_ema.update(macd)?;
let out = VolumeWeightedMacdOutput {
macd,
signal,
histogram: macd - signal,
};
self.last = Some(out);
return Some(out);
}
None
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal_ema.reset();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.slow_period + self.signal_period - 1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeWeightedMacd"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn candle(close: f64, volume: f64) -> Candle {
Candle::new_unchecked(close, close, close, close, volume, 0)
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
VolumeWeightedMacd::new(0, 26, 9),
Err(Error::PeriodZero)
));
assert!(matches!(
VolumeWeightedMacd::new(26, 12, 9),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
VolumeWeightedMacd::new(12, 12, 9),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m = VolumeWeightedMacd::new(12, 26, 9).unwrap();
assert_eq!(m.periods(), (12, 26, 9));
assert_eq!(m.warmup_period(), 34);
assert_eq!(m.name(), "VolumeWeightedMacd");
assert!(!m.is_ready());
assert_eq!(m.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap();
let candles: Vec<Candle> = (0..20)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let out = m.batch(&candles);
let warmup = m.warmup_period(); // 4 + 3 - 1 = 6
assert_eq!(warmup, 6);
for v in out.iter().take(warmup - 1) {
assert!(v.is_none());
}
assert!(out[warmup - 1].is_some());
}
#[test]
fn uptrend_has_positive_macd() {
// A steady advance with equal volume -> fast VWMA leads slow -> macd > 0.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
let last = m.batch(&candles).into_iter().flatten().last().unwrap();
assert!(
last.macd > 0.0,
"uptrend should give positive macd, got {}",
last.macd
);
}
#[test]
fn histogram_is_macd_minus_signal() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.3).sin() * 5.0,
1_000.0 + f64::from(i),
)
})
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9);
}
}
#[test]
fn equal_volume_matches_plain_macd() {
// With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses
// SMA-based lines; it should still be a well-defined finite series.
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..60)
.map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0))
.collect();
for o in m.batch(&candles).into_iter().flatten() {
assert!(o.macd.is_finite() && o.signal.is_finite());
}
}
#[test]
fn reset_clears_state() {
let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap();
let candles: Vec<Candle> = (0..40)
.map(|i| candle(100.0 + f64::from(i), 1_000.0))
.collect();
m.batch(&candles);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
assert_eq!(m.value(), None);
assert_eq!(m.update(candle(100.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..120)
.map(|i| {
candle(
100.0 + (f64::from(i) * 0.25).sin() * 9.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles);
let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+206
View File
@@ -0,0 +1,206 @@
//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line.
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Williams Accumulation/Distribution — a cumulative price-only line that adds
/// the day's accumulation on up-closes and subtracts the day's distribution on
/// down-closes.
///
/// ```text
/// if close > prev_close: AD = close min(low, prev_close) (true low)
/// if close < prev_close: AD = close max(high, prev_close) (true high)
/// if close = prev_close: AD = 0
/// WAD_t = WAD_{t1} + AD
/// ```
///
/// Larry Williams' A/D line (distinct from Chaikin's volume-based
/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as
/// how far price closed above the *true low* on up-days and distribution as how
/// far it closed below the *true high* on down-days, then accumulates the result.
/// A rising WAD that diverges from a flat or falling price is the classic
/// accumulation signal; a falling WAD under a rising price warns of distribution.
///
/// The line is unbounded and its absolute level is meaningless — only its slope
/// and divergences against price matter. The first candle has no previous close,
/// so it seeds the reference and emits nothing; thereafter every bar emits the
/// running total. Each `update` is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Wad};
///
/// let mut indicator = Wad::new();
/// let mut last = None;
/// for i in 0..20 {
/// let base = 100.0 + f64::from(i);
/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap();
/// last = indicator.update(c);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct Wad {
prev_close: Option<f64>,
line: f64,
last: Option<f64>,
}
impl Wad {
/// Construct a new Williams A/D line. The line is parameter-free.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Current value if available.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for Wad {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev_close) = self.prev_close else {
self.prev_close = Some(candle.close);
return None;
};
let ad = if candle.close > prev_close {
candle.close - candle.low.min(prev_close)
} else if candle.close < prev_close {
candle.close - candle.high.max(prev_close)
} else {
0.0
};
self.line += ad;
self.prev_close = Some(candle.close);
self.last = Some(self.line);
Some(self.line)
}
fn reset(&mut self) {
self.prev_close = None;
self.line = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
// The first bar only seeds the reference close; the first value lands on
// the second bar.
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"Wad"
}
}
#[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_unchecked(low, high, low, close, 1_000.0, 0)
}
#[test]
fn accessors_and_metadata() {
let wad = Wad::new();
assert_eq!(wad.warmup_period(), 2);
assert_eq!(wad.name(), "Wad");
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
}
#[test]
fn first_bar_seeds_without_output() {
let mut wad = Wad::new();
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some());
}
#[test]
fn up_close_accumulates() {
// close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100;
// AD = 101 - 100 = 1.
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap();
assert_relative_eq!(v, 1.0, epsilon = 1e-9);
}
#[test]
fn down_close_distributes() {
// close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101;
// AD = 99 - 101 = -2.
let mut wad = Wad::new();
wad.update(candle(102.0, 100.0, 100.0));
let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap();
assert_relative_eq!(v, -2.0, epsilon = 1e-9);
}
#[test]
fn unchanged_close_adds_nothing() {
let mut wad = Wad::new();
wad.update(candle(101.0, 99.0, 100.0));
let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap();
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
}
#[test]
fn pure_uptrend_is_monotone() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
let mut prev = f64::NEG_INFINITY;
for v in wad.batch(&candles).into_iter().flatten() {
assert!(v >= prev, "WAD must rise in an uptrend");
prev = v;
}
}
#[test]
fn reset_clears_state() {
let mut wad = Wad::new();
let candles: Vec<Candle> = (0..10)
.map(|i| {
let base = 100.0 + f64::from(i);
candle(base + 1.0, base - 1.0, base)
})
.collect();
wad.batch(&candles);
assert!(wad.is_ready());
wad.reset();
assert!(!wad.is_ready());
assert_eq!(wad.value(), None);
assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
candle(base + 2.0, base - 2.0, base + 0.5)
})
.collect();
let batch = Wad::new().batch(&candles);
let mut b = Wad::new();
let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect();
assert_eq!(batch, streamed);
}
}
+55 -53
View File
@@ -60,51 +60,52 @@ pub use indicators::{
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, AdaptiveLaguerreFilter, Adl,
AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator,
AlligatorOutput, Alma, Alpha, AmihudIlliquidity, AnchoredRsi, AnchoredVwap, Apo, Aroon,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, AutoFib,
AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex,
Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity,
Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots,
ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration,
CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock,
Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BetterVolume, BipowerVariation, BodySizePct, BollingerBands,
BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway,
BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput,
Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility,
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation,
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
Coppock, Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
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,
EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel,
FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan,
FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement, FibRetracementOutput,
FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput, FisherRsi,
FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex, FractalChaosBands,
FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore,
GainLossRatio, GapSideBySideWhite, Garch11, 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, MedianChannel,
MedianChannelOutput, MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi,
MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
Engulfing, EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama,
FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput,
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11,
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, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator,
KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
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, MedianChannel, MedianChannelOutput, MedianMa, MedianPrice, Mfi,
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom,
MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nrtr, NrtrOutput, Nvi,
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
@@ -129,20 +130,21 @@ pub use indicators::{
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,
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, VolatilityCone, VolatilityConeOutput,
VolatilityOfVolatility, VolatilityRatio, 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,
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle,
Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice,
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods,
UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility,
VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator,
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd,
VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **434 indicators** across
- A per-indicator deep dive for every one of the **447 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.6.1",
"version": "0.6.3",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.6.1",
"wickra-darwin-x64": "0.6.1",
"wickra-linux-arm64-gnu": "0.6.1",
"wickra-linux-x64-gnu": "0.6.1",
"wickra-win32-arm64-msvc": "0.6.1",
"wickra-win32-x64-msvc": "0.6.1"
"wickra-darwin-arm64": "0.6.3",
"wickra-darwin-x64": "0.6.3",
"wickra-linux-arm64-gnu": "0.6.3",
"wickra-linux-x64-gnu": "0.6.3",
"wickra-win32-arm64-msvc": "0.6.3",
"wickra-win32-x64-msvc": "0.6.3"
}
},
"node_modules/wickra": {
+14 -1
View File
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, 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, ProjectionBands, ProjectionOscillator, 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, VolatilityCone, VolatilityRatio, 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, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, 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, ElderSafeZone, 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, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, 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, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, 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
@@ -79,6 +79,11 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| Donchian::new(20).unwrap(), &candles);
// --- Trailing Stops ---
drive(|| ModifiedMaStop::new(14).unwrap(), &candles);
drive(|| TimeBasedStop::new(5).unwrap(), &candles);
drive(|| Nrtr::new(2.0).unwrap(), &candles);
drive(|| AtrRatchet::new(14, 4.0, 0.1).unwrap(), &candles);
drive(|| ElderSafeZone::new(14, 2.0).unwrap(), &candles);
drive(|| Psar::new(0.02, 0.02, 0.20).unwrap(), &candles);
drive(SarExt::classic, &candles);
drive(|| SuperTrend::new(14, 3.0).unwrap(), &candles);
@@ -88,6 +93,7 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| HiLoActivator::new(3).unwrap(), &candles);
drive(|| VoltyStop::new(14, 2.0).unwrap(), &candles);
drive(|| YoyoExit::new(14, 2.0).unwrap(), &candles);
drive(|| KaseDevStop::new(30, 1.0).unwrap(), &candles);
// --- Trend & Directional ---
drive(|| KasePermissionStochastic::new(9, 3).unwrap(), &candles);
@@ -134,6 +140,13 @@ fuzz_target!(|data: Vec<f64>| {
drive(HighLowRange::new, &candles);
// --- Volume ---
drive(|| BetterVolume::new(14).unwrap(), &candles);
drive(IntradayIntensity::new, &candles);
drive(|| TradeVolumeIndex::new(0.25).unwrap(), &candles);
drive(|| TwiggsMoneyFlow::new(21).unwrap(), &candles);
drive(Wad::new, &candles);
drive(|| VolumeRsi::new(14).unwrap(), &candles);
drive(|| VolumeWeightedMacd::new(12, 26, 9).unwrap(), &candles);
drive(Obv::new, &candles);
drive(|| Mfi::new(14).unwrap(), &candles);
drive(Vwap::new, &candles);