Compare commits

...

4 Commits

Author SHA1 Message Date
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
kingchenc 83e34c6f71 release: bump 0.6.0 -> 0.6.1 (#192)
Version bump for the v0.6.1 release shipping the B6 Bands & Channels family (#191): 429 -> 434 indicators.
2026-06-07 00:13:58 +02:00
kingchenc 67feec598a feat(indicators): add B6 Bands & Channels family (429 -> 434) (#191)
Adds the **B6 Bands & Channels** batch — five band/channel indicators, taking the catalogue from 429 to 434.

| Indicator | Input → Output | Summary |
|-----------|----------------|---------|
| `ProjectionBands` | `Candle` → `{upper,middle,lower}` | Widner forward-projected high/low regression envelope |
| `ProjectionOscillator` | `Candle` → `f64` | Close position inside the projection bands, scaled 0..100 |
| `QuartileBands` | `f64` → `{upper,middle,lower}` | Rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope |
| `BomarBands` | `f64` → `{upper,middle,lower}` | Adaptive percentage bands containing a target coverage fraction of recent closes |
| `MedianChannel` | `f64` → `{upper,middle,lower}` | Robust median ± multiplier·MAD envelope |

All five are distinct from existing indicators (verified against the core: `LinRegChannel`, `StandardErrorBands`, `Donchian`, `RollingQuantile`, `HurstChannel`). SKIPped from the roadmap: Price Channel (= `Donchian`) and Moving-Average Channel (≈ `MaEnvelope`/`Keltner`).

Each ships:
- Core indicator with per-branch unit tests (Codecov-strict 100%).
- python / node / wasm bindings (struct outputs are hand-written; `ProjectionOscillator` uses the generated candle→f64 path).
- Fuzz drives, python (`MULTI`/`SCALAR_MULTI`/`CANDLE_SCALAR`) + node test registries, README + CHANGELOG counter bump to 434.

Verified locally: `cargo fmt`, `clippy --workspace --all-targets --all-features -D warnings` (clean), `wickra-core` 3511 lib + 392 doc tests, node 509 tests, pytest 840.
2026-06-07 00:03:02 +02:00
38 changed files with 5350 additions and 138 deletions
+18 -1
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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`).
- **Median Channel** — robust median +/- multiplier*MAD envelope (`MedianChannel`).
- **Bomar Bands** — adaptive percentage bands containing a target coverage fraction of recent closes (`BomarBands`).
- **Quartile Bands** — rolling 25th/50th/75th-percentile (Q1/median/Q3) envelope (`QuartileBands`).
## [0.6.0] - 2026-06-06
- **Volatility Cone** — volatility cone: current realized volatility within its historical min/median/max envelope (`VolatilityCone`).
- **VolatilityRatio** — Schwager's volatility ratio: true range over the EMA of prior true ranges (`VolatilityRatio`).
@@ -1301,7 +1316,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.0...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.6.2...HEAD
[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
[0.5.8]: https://github.com/wickra-lib/wickra/compare/v0.5.7...v0.5.8
Generated
+8 -8
View File
@@ -1944,7 +1944,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"approx",
"criterion",
@@ -1955,7 +1955,7 @@ dependencies = [
[[package]]
name = "wickra-bench"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"criterion",
"kand",
@@ -1967,7 +1967,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"approx",
"proptest",
@@ -1977,7 +1977,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"approx",
"csv",
@@ -1994,7 +1994,7 @@ dependencies = [
[[package]]
name = "wickra-examples"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"serde_json",
"tokio",
@@ -2004,7 +2004,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"napi",
"napi-build",
@@ -2014,7 +2014,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"numpy",
"pyo3",
@@ -2023,7 +2023,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.6.0"
version = "0.6.2"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -13,7 +13,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.6.0"
version = "0.6.2"
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.0" }
wickra-core = { path = "crates/wickra-core", version = "0.6.2" }
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=429" 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=440" 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 429 indicators; start at the
every one of the 440 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**: 429 indicators that all update in O(1)
Wickra's edge is **breadth with reach**: 440 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
429 streaming-first indicators across twenty-four families. Every one passes the
440 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).
@@ -200,8 +200,8 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Trend & Directional | MACD, MACD Fixed (MACDFIX), MACD Extended (MACDEXT), ADX (+DI/-DI), ADXR, Aroon, TRIX, Aroon Oscillator, Vortex, Random Walk Index, Trend Intensity Index, Wave Trend Oscillator, Mass Index, Choppiness Index, Vertical Horizontal Filter, Plus DM, Minus DM, Plus DI, Minus DI, DX, TTM Trend, Trend Strength Index, Qstick, Polarized Fractal Efficiency, Wave PM, Gator Oscillator, Kase Permission Stochastic |
| Price Oscillators | PPO, DPO, Coppock, Accelerator Oscillator, Balance of Power, APO, AO Histogram, CFO, Zero-Lag MACD, Elder Impulse, STC, TSF Oscillator, MACD Histogram, PPO Histogram |
| Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, 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 |
| 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 |
| 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, 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 |
| 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 |
@@ -297,7 +297,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 429 indicators
│ ├── wickra-core/ core engine + all 440 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)
@@ -355,6 +355,8 @@ const candleScalar = {
TTM_TREND: { make: () => new wickra.TTM_TREND(6), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
Qstick: { make: () => new wickra.Qstick(10), step: (ind, i) => ind.update(open[i], close[i]), batch: (ind) => ind.batch(open, close) },
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) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -442,6 +444,15 @@ const multi = {
GatorOscillator: { make: () => new wickra.GatorOscillator(13, 8, 5), fields: ['upper', 'lower'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
KasePermissionStochastic: { make: () => new wickra.KasePermissionStochastic(9, 3), fields: ['fast', 'slow'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
VolatilityCone: { make: () => new wickra.VolatilityCone(20, 60), fields: ['current', 'min', 'median', 'max', 'percentile'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
QuartileBands: { make: () => new wickra.QuartileBands(4), fields: ['upper', 'middle', 'lower'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
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) },
};
for (const [name, d] of Object.entries(multi)) {
+159
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
@@ -199,6 +219,26 @@ export interface StandardErrorBandsValue {
middle: number
lower: number
}
export interface QuartileBandsValue {
upper: number
middle: number
lower: number
}
export interface BomarBandsValue {
upper: number
middle: number
lower: number
}
export interface MedianChannelValue {
upper: number
middle: number
lower: number
}
export interface ProjectionBandsValue {
upper: number
middle: number
lower: number
}
export interface DoubleBollingerValue {
upperOuter: number
upperInner: number
@@ -1639,6 +1679,24 @@ export declare class VolatilityRatio {
isReady(): boolean
warmupPeriod(): number
}
export type ProjectionOscillatorNode = ProjectionOscillator
export declare class ProjectionOscillator {
constructor(period: number)
update(high: number, low: number, close: number): number | null
batch(high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type 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)
@@ -2299,6 +2357,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()
@@ -2627,6 +2750,42 @@ export declare class StandardErrorBands {
isReady(): boolean
warmupPeriod(): number
}
export type QuartileBandsNode = QuartileBands
export declare class QuartileBands {
constructor(period: number)
update(value: number): QuartileBandsValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BomarBandsNode = BomarBands
export declare class BomarBands {
constructor(period: number, coverage: number)
update(value: number): BomarBandsValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MedianChannelNode = MedianChannel
export declare class MedianChannel {
constructor(period: number, multiplier: number)
update(value: number): MedianChannelValue | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type ProjectionBandsNode = ProjectionBands
export declare class ProjectionBands {
constructor(period: number)
update(high: number, low: number): ProjectionBandsValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type DoubleBollingerNode = DoubleBollinger
export declare class DoubleBollinger {
constructor(period: number, kInner: number, kOuter: number)
+12 -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.0",
"version": "0.6.2",
"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.0",
"version": "0.6.2",
"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.0",
"version": "0.6.2",
"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.0",
"version": "0.6.2",
"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.0",
"version": "0.6.2",
"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.0",
"version": "0.6.2",
"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.0",
"version": "0.6.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.6.0",
"version": "0.6.2",
"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.0",
"wickra-darwin-x64": "0.6.0",
"wickra-linux-arm64-gnu": "0.6.0",
"wickra-linux-x64-gnu": "0.6.0",
"wickra-win32-arm64-msvc": "0.6.0",
"wickra-win32-x64-msvc": "0.6.0"
"wickra-darwin-arm64": "0.6.2",
"wickra-darwin-x64": "0.6.2",
"wickra-linux-arm64-gnu": "0.6.2",
"wickra-linux-x64-gnu": "0.6.2",
"wickra-win32-arm64-msvc": "0.6.2",
"wickra-win32-x64-msvc": "0.6.2"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.0.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.6.2.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.0.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.6.2.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.0.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.6.2.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.0.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.6.2.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.0.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.6.2.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.0.tgz",
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.6.2.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.6.0",
"version": "0.6.2",
"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.0",
"wickra-linux-arm64-gnu": "0.6.0",
"wickra-darwin-x64": "0.6.0",
"wickra-darwin-arm64": "0.6.0",
"wickra-win32-x64-msvc": "0.6.0",
"wickra-win32-arm64-msvc": "0.6.0"
"wickra-linux-x64-gnu": "0.6.2",
"wickra-linux-arm64-gnu": "0.6.2",
"wickra-darwin-x64": "0.6.2",
"wickra-darwin-arm64": "0.6.2",
"wickra-win32-x64-msvc": "0.6.2",
"wickra-win32-arm64-msvc": "0.6.2"
},
"scripts": {
"build": "napi build --platform --release",
+705
View File
@@ -2869,6 +2869,112 @@ impl VolatilityRatioNode {
}
}
#[napi(js_name = "ProjectionOscillator")]
pub struct ProjectionOscillatorNode {
inner: wc::ProjectionOscillator,
}
#[napi]
impl ProjectionOscillatorNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ProjectionOscillator::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, 0.0)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != close.len() {
return Err(NapiError::from_reason(
"high, low, close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
out.push(
self.inner
.update(cnd(high[i], low[i], close[i], 0.0)?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "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,
@@ -6538,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")]
@@ -8529,6 +9001,239 @@ impl StandardErrorBandsNode {
}
}
// ---------- Quartile Bands ----------
#[napi(object)]
pub struct QuartileBandsValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "QuartileBands")]
pub struct QuartileBandsNode {
inner: wc::QuartileBands,
}
#[napi]
impl QuartileBandsNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::QuartileBands::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<QuartileBandsValue> {
self.inner.update(value).map(|o| QuartileBandsValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
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
}
}
// ---------- Bomar Bands ----------
#[napi(object)]
pub struct BomarBandsValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "BomarBands")]
pub struct BomarBandsNode {
inner: wc::BomarBands,
}
#[napi]
impl BomarBandsNode {
#[napi(constructor)]
pub fn new(period: u32, coverage: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::BomarBands::new(period as usize, coverage).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<BomarBandsValue> {
self.inner.update(value).map(|o| BomarBandsValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
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
}
}
// ---------- Median Channel ----------
#[napi(object)]
pub struct MedianChannelValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "MedianChannel")]
pub struct MedianChannelNode {
inner: wc::MedianChannel,
}
#[napi]
impl MedianChannelNode {
#[napi(constructor)]
pub fn new(period: u32, multiplier: f64) -> napi::Result<Self> {
Ok(Self {
inner: wc::MedianChannel::new(period as usize, multiplier).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MedianChannelValue> {
self.inner.update(value).map(|o| MedianChannelValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
})
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
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
}
}
// ---------- Projection Bands ----------
#[napi(object)]
pub struct ProjectionBandsValue {
pub upper: f64,
pub middle: f64,
pub lower: f64,
}
#[napi(js_name = "ProjectionBands")]
pub struct ProjectionBandsNode {
inner: wc::ProjectionBands,
}
#[napi]
impl ProjectionBandsNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::ProjectionBands::new(period as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<ProjectionBandsValue>> {
Ok(self
.inner
.update(cnd(high, low, low, 0.0)?)
.map(|o| ProjectionBandsValue {
upper: o.upper,
middle: o.middle,
lower: o.lower,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high and low must be equal length".to_string(),
));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
if let Some(o) = self.inner.update(cnd(high[i], low[i], low[i], 0.0)?) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
// ---------- Double Bollinger ----------
#[napi(object)]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.6.0"
version = "0.6.2"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+24
View File
@@ -25,6 +25,8 @@ from __future__ import annotations
from ._wickra import (
__version__,
TimeBasedStop,
ProjectionOscillator,
VolatilityCone,
VolatilityRatio,
BipowerVariation,
@@ -167,6 +169,11 @@ from ._wickra import (
HistoricalVolatility,
BollingerBandwidth,
PercentB,
# Trailing Stops
ModifiedMaStop,
Nrtr,
AtrRatchet,
ElderSafeZone,
SuperTrend,
ChandelierExit,
ChandeKrollStop,
@@ -178,6 +185,7 @@ from ._wickra import (
PercentageTrailingStop,
StepTrailingStop,
RenkoTrailingStop,
KaseDevStop,
TrueRange,
ChaikinVolatility,
RVIVolatility,
@@ -263,6 +271,10 @@ from ._wickra import (
MAMA,
FAMA,
# Bands & Channels
ProjectionBands,
MedianChannel,
BomarBands,
QuartileBands,
MaEnvelope,
AccelerationBands,
StarcBands,
@@ -482,6 +494,8 @@ from ._wickra import (
)
__all__ = [
"TimeBasedStop",
"ProjectionOscillator",
"VolatilityCone",
"VolatilityRatio",
"BipowerVariation",
@@ -625,6 +639,11 @@ __all__ = [
"HistoricalVolatility",
"BollingerBandwidth",
"PercentB",
# Trailing Stops
"ModifiedMaStop",
"Nrtr",
"AtrRatchet",
"ElderSafeZone",
"SuperTrend",
"ChandelierExit",
"ChandeKrollStop",
@@ -636,6 +655,7 @@ __all__ = [
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"KaseDevStop",
"TrueRange",
"ChaikinVolatility",
"RVIVolatility",
@@ -721,6 +741,10 @@ __all__ = [
"MAMA",
"FAMA",
# Bands & Channels
"ProjectionBands",
"MedianChannel",
"BomarBands",
"QuartileBands",
"MaEnvelope",
"AccelerationBands",
"StarcBands",
+763
View File
@@ -3559,6 +3559,147 @@ impl PyVolatilityRatio {
}
}
// ============================== ProjectionOscillator ==============================
#[pyclass(
name = "ProjectionOscillator",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyProjectionOscillator {
inner: wc::ProjectionOscillator,
}
#[pymethods]
impl PyProjectionOscillator {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ProjectionOscillator::new(period).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c))
}
/// Batch over numpy columns: high, low, close (all 1-D, equal length).
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != c.len() {
return Err(PyValueError::new_err(
"high, low, close must be equal length",
));
}
let mut out = Vec::with_capacity(h.len());
for i in 0..h.len() {
let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?;
out.push(self.inner.update(candle).unwrap_or(f64::NAN));
}
Ok(out.into_pyarray(py))
}
#[getter]
fn period(&self) -> usize {
self.inner.period()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!("ProjectionOscillator(period={})", self.inner.period())
}
}
// ============================== 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)]
@@ -9741,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)]
@@ -11309,6 +11834,233 @@ impl PyStandardErrorBands {
}
}
// ============================== Quartile Bands ==============================
#[pyclass(name = "QuartileBands", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyQuartileBands {
inner: wc::QuartileBands,
}
#[pymethods]
impl PyQuartileBands {
#[new]
#[pyo3(signature = (period=20))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::QuartileBands::new(period).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
self.inner
.update(value)
.map(|o| (o.upper, o.middle, o.lower))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.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()
}
}
// ============================== Bomar Bands ==============================
#[pyclass(name = "BomarBands", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyBomarBands {
inner: wc::BomarBands,
}
#[pymethods]
impl PyBomarBands {
#[new]
#[pyo3(signature = (period=20, coverage=0.85))]
fn new(period: usize, coverage: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::BomarBands::new(period, coverage).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
self.inner
.update(value)
.map(|o| (o.upper, o.middle, o.lower))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.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()
}
}
// ============================== Median Channel ==============================
#[pyclass(name = "MedianChannel", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyMedianChannel {
inner: wc::MedianChannel,
}
#[pymethods]
impl PyMedianChannel {
#[new]
#[pyo3(signature = (period=20, multiplier=2.0))]
fn new(period: usize, multiplier: f64) -> PyResult<Self> {
Ok(Self {
inner: wc::MedianChannel::new(period, multiplier).map_err(map_err)?,
})
}
fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> {
self.inner
.update(value)
.map(|o| (o.upper, o.middle, o.lower))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
prices: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let slice = prices
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = slice.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in slice.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.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()
}
}
// ============================== Projection Bands ==============================
#[pyclass(
name = "ProjectionBands",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyProjectionBands {
inner: wc::ProjectionBands,
}
#[pymethods]
impl PyProjectionBands {
#[new]
#[pyo3(signature = (period=14))]
fn new(period: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::ProjectionBands::new(period).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.upper, o.middle, o.lower)))
}
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: 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))?;
if h.len() != l.len() {
return Err(PyValueError::new_err("high and low must be equal length"));
}
let n = h.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = wc::Candle::new(h[i], h[i], l[i], l[i], 0.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.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()
}
}
// ============================== Double Bollinger ==============================
#[pyclass(
@@ -21595,6 +22347,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>()?;
@@ -21633,6 +22390,10 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyHurstChannel>()?;
m.add_class::<PyLinRegChannel>()?;
m.add_class::<PyStandardErrorBands>()?;
m.add_class::<PyQuartileBands>()?;
m.add_class::<PyBomarBands>()?;
m.add_class::<PyMedianChannel>()?;
m.add_class::<PyProjectionBands>()?;
m.add_class::<PyDoubleBollinger>()?;
m.add_class::<PyTtmSqueeze>()?;
m.add_class::<PyFractalChaosBands>()?;
@@ -21934,5 +22695,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyGarch11>()?;
m.add_class::<PyVolatilityOfVolatility>()?;
m.add_class::<PyVolatilityCone>()?;
m.add_class::<PyProjectionOscillator>()?;
m.add_class::<PyTimeBasedStop>()?;
Ok(())
}
@@ -173,6 +173,9 @@ SCALAR = [
# Family 05 band/channel indicators with scalar input and multi-output.
# `cols` is the expected number of band columns from `batch`.
SCALAR_MULTI = {
"MedianChannel": (lambda: ta.MedianChannel(5, 2.0), 3),
"BomarBands": (lambda: ta.BomarBands(4, 0.85), 3),
"QuartileBands": (lambda: ta.QuartileBands(4), 3),
"Qqe": (lambda: ta.QQE(14, 5, 4.236), 2),
"MaEnvelope": (lambda: ta.MaEnvelope(20, 0.025), 3),
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
@@ -365,6 +368,8 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"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)),
"StochasticCCI": (lambda: ta.StochasticCCI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
@@ -904,6 +909,36 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
"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),
3,
),
"VolatilityCone": (
lambda: ta.VolatilityCone(20, 60),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -2918,6 +2953,93 @@ def test_garch11_reference():
def test_volatility_cone_reference():
t = ta.VolatilityCone(20, 60)
def test_quartile_bands_reference():
t = ta.QuartileBands(4)
assert t.update(40.0) is None
assert t.update(30.0) is None
assert t.update(20.0) is None
assert t.update(10.0) == pytest.approx((32.5, 25.0, 17.5))
def test_bomar_bands_reference():
t = ta.BomarBands(4, 0.85)
assert t.update(100.0) is None
assert t.update(102.0) is None
assert t.update(98.0) is None
assert t.update(104.0) == pytest.approx((104.0, 101.0, 98.0))
def test_median_channel_reference():
t = ta.MedianChannel(5, 2.0)
assert t.update(1.0) is None
assert t.update(2.0) is None
assert t.update(3.0) is None
assert t.update(4.0) is None
assert t.update(5.0) == pytest.approx((5.0, 3.0, 1.0))
def test_projection_bands_reference():
t = ta.ProjectionBands(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
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((12.5, 11.25, 10.0))
def test_projection_oscillator_reference():
# Same window as ProjectionBands: upper 12.5, lower 10; close 11 -> 40.
t = ta.ProjectionOscillator(3)
assert t.update((8.0, 10.0, 8.0, 9.0, 1.0, 0)) is None
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))
# --- Lifecycle ------------------------------------------------------------
+558
View File
@@ -2514,6 +2514,82 @@ impl WasmVolatilityRatio {
}
}
#[wasm_bindgen(js_name = ProjectionOscillator)]
pub struct WasmProjectionOscillator {
inner: wc::ProjectionOscillator,
}
#[wasm_bindgen(js_class = ProjectionOscillator)]
impl WasmProjectionOscillator {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmProjectionOscillator, JsError> {
Ok(Self {
inner: wc::ProjectionOscillator::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result<Option<f64>, JsError> {
let c = make_candle(high, low, close, 0.0)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != close.len() {
return Err(JsError::new("high, low, close must be equal length"));
}
let mut out = Vec::with_capacity(high.len());
for i in 0..high.len() {
let c = make_candle(high[i], low[i], close[i], 0.0)?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = 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,
@@ -3795,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,
@@ -5844,6 +6189,219 @@ impl WasmStandardErrorBands {
}
}
// ---------- Quartile Bands (scalar input, 3 outputs) ----------
#[wasm_bindgen(js_name = QuartileBands)]
pub struct WasmQuartileBands {
inner: wc::QuartileBands,
}
#[wasm_bindgen(js_class = QuartileBands)]
impl WasmQuartileBands {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmQuartileBands, JsError> {
Ok(Self {
inner: wc::QuartileBands::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
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()
}
}
// ---------- Bomar Bands (scalar input, 3 outputs) ----------
#[wasm_bindgen(js_name = BomarBands)]
pub struct WasmBomarBands {
inner: wc::BomarBands,
}
#[wasm_bindgen(js_class = BomarBands)]
impl WasmBomarBands {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, coverage: f64) -> Result<WasmBomarBands, JsError> {
Ok(Self {
inner: wc::BomarBands::new(period, coverage).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
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()
}
}
// ---------- Median Channel (scalar input, 3 outputs) ----------
#[wasm_bindgen(js_name = MedianChannel)]
pub struct WasmMedianChannel {
inner: wc::MedianChannel,
}
#[wasm_bindgen(js_class = MedianChannel)]
impl WasmMedianChannel {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, multiplier: f64) -> Result<WasmMedianChannel, JsError> {
Ok(Self {
inner: wc::MedianChannel::new(period, multiplier).map_err(map_err)?,
})
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 3];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
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()
}
}
// ---------- Projection Bands (high/low input, 3 outputs) ----------
#[wasm_bindgen(js_name = ProjectionBands)]
pub struct WasmProjectionBands {
inner: wc::ProjectionBands,
}
#[wasm_bindgen(js_class = ProjectionBands)]
impl WasmProjectionBands {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmProjectionBands, JsError> {
Ok(Self {
inner: wc::ProjectionBands::new(period).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
let candle = make_candle(high, low, low, 0.0)?;
match self.inner.update(candle) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
Ok(obj.into())
}
None => Ok(JsValue::NULL),
}
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
if high.len() != low.len() {
return Err(JsError::new("high and low must be equal length"));
}
let n = high.len();
let mut out = vec![f64::NAN; n * 3];
for i in 0..n {
let candle = make_candle(high[i], low[i], low[i], 0.0)?;
if let Some(o) = self.inner.update(candle) {
out[i * 3] = o.upper;
out[i * 3 + 1] = o.middle;
out[i * 3 + 2] = o.lower;
}
}
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()
}
}
// ---------- Double Bollinger (scalar input, 5 outputs) ----------
#[wasm_bindgen(js_name = DoubleBollinger)]
@@ -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,256 @@
//! Bomar Bands — adaptive percentage bands that contain a target fraction of
//! recent price.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Bomar Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BomarBandsOutput {
/// Upper band: `middle + |middle| · p`.
pub upper: f64,
/// Middle line: the simple moving average over the window.
pub middle: f64,
/// Lower band: `middle |middle| · p`.
pub lower: f64,
}
/// Bomar Bands: percentage bands whose width adapts so that a fixed `coverage`
/// fraction of recent closes falls inside them.
///
/// The Bomar Bands predate Bollinger Bands; John Bollinger cites them as an
/// inspiration — percentage bands around a moving average, with the percentage
/// tuned so a fixed share (classically ~85%) of price stayed within. Wickra
/// realises that idea deterministically: the half-width is the `coverage`
/// quantile of the relative deviations from the midline, so by construction
/// `coverage` of the window's closes lie inside the bands.
///
/// ```text
/// middle = SMA(close, period)
/// dev_i = | close_i / middle 1 | // relative distance from midline
/// p = coverage-quantile of { dev_i } // type-7 interpolation
/// upper = middle + |middle| · p
/// lower = middle |middle| · p
/// ```
///
/// Unlike the fixed-percentage [`MaEnvelope`](crate::MaEnvelope), the offset
/// here is data-driven: the bands widen in turbulent regimes and tighten in
/// quiet ones without a volatility input. Unlike Bollinger Bands, the width is
/// an order statistic of the actual deviations rather than a multiple of the
/// standard deviation, so it is unaffected by the shape of the tails beyond the
/// `coverage` rank. When the midline is zero the relative deviation is
/// undefined and the bands collapse onto the midline.
///
/// # Example
///
/// ```
/// use wickra_core::{BomarBands, Indicator};
///
/// let mut indicator = BomarBands::new(20, 0.85).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i % 7));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct BomarBands {
period: usize,
coverage: f64,
window: VecDeque<f64>,
scratch: Vec<f64>,
}
impl BomarBands {
/// Construct new Bomar Bands.
///
/// `coverage` is the target fraction of closes to contain, in `(0.0, 1.0]`.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::InvalidParameter`] if `coverage` is not a finite value in
/// `(0.0, 1.0]`.
pub fn new(period: usize, coverage: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !coverage.is_finite() || coverage <= 0.0 || coverage > 1.0 {
return Err(Error::InvalidParameter {
message: "bomar bands coverage must be a finite value in (0.0, 1.0]",
});
}
Ok(Self {
period,
coverage,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured coverage fraction.
pub const fn coverage(&self) -> f64 {
self.coverage
}
}
impl Indicator for BomarBands {
type Input = f64;
type Output = BomarBandsOutput;
fn update(&mut self, value: f64) -> Option<BomarBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let sum: f64 = self.window.iter().sum();
let middle = sum / (self.period as f64);
let denom = middle.abs();
self.scratch.clear();
for &v in &self.window {
let dev = if denom == 0.0 {
0.0
} else {
((v - middle) / denom).abs()
};
self.scratch.push(dev);
}
self.scratch.sort_by(f64::total_cmp);
let p = quantile_sorted(&self.scratch, self.coverage);
let offset = denom * p;
Some(BomarBandsOutput {
upper: middle + offset,
middle,
lower: middle - offset,
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"BomarBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(BomarBands::new(0, 0.85), Err(Error::PeriodZero)));
assert!(BomarBands::new(1, 0.85).is_ok());
}
#[test]
fn rejects_out_of_range_coverage() {
assert!(matches!(
BomarBands::new(20, 0.0),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, 1.1),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, -0.5),
Err(Error::InvalidParameter { .. })
));
assert!(matches!(
BomarBands::new(20, f64::NAN),
Err(Error::InvalidParameter { .. })
));
}
#[test]
fn accessors_and_metadata() {
let bb = BomarBands::new(20, 0.85).unwrap();
assert_eq!(bb.period(), 20);
assert_relative_eq!(bb.coverage(), 0.85, epsilon = 1e-12);
assert_eq!(bb.warmup_period(), 20);
assert_eq!(bb.name(), "BomarBands");
assert!(!bb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
assert!(bb.update(100.0).is_none());
assert!(bb.update(102.0).is_none());
assert!(bb.update(98.0).is_none());
assert!(bb.update(104.0).is_some());
assert!(bb.is_ready());
}
#[test]
fn known_bands() {
// mean=101; |dev| = {1,1,3,3}/101; coverage 0.85 quantile -> 3/101.
// offset = 101 * 3/101 = 3 -> upper 104, lower 98.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[100.0, 102.0, 98.0, 104.0]);
let last = out[3].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn zero_midline_collapses_bands() {
// Window mean exactly zero -> relative deviation undefined -> collapse.
let mut bb = BomarBands::new(2, 0.85).unwrap();
let out = bb.batch(&[3.0, -3.0]);
let last = out[1].unwrap();
assert_relative_eq!(last.middle, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 0.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 0.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Eight values through a period-4 window: only the last four survive,
// reproducing the `known_bands` window.
let mut bb = BomarBands::new(4, 0.85).unwrap();
let out = bb.batch(&[50.0, 50.0, 50.0, 50.0, 100.0, 102.0, 98.0, 104.0]);
let last = out[7].unwrap();
assert_relative_eq!(last.middle, 101.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 104.0, epsilon = 1e-9);
assert_relative_eq!(last.lower, 98.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut bb = BomarBands::new(4, 0.85).unwrap();
for v in [100.0, 102.0, 98.0, 104.0] {
bb.update(v);
}
assert!(bb.is_ready());
bb.reset();
assert!(!bb.is_ready());
assert!(bb.update(100.0).is_none());
}
}
@@ -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,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);
}
}
@@ -0,0 +1,237 @@
//! Median Channel — a robust median ± MAD envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Median Channel output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MedianChannelOutput {
/// Upper band: `median + multiplier · MAD`.
pub upper: f64,
/// Middle line: the rolling median.
pub middle: f64,
/// Lower band: `median multiplier · MAD`.
pub lower: f64,
}
/// Median Channel: a robust analogue of Bollinger Bands built from the rolling
/// median and the median absolute deviation (MAD).
///
/// ```text
/// middle = median(close, period)
/// MAD = median( | close_i middle | )
/// upper = middle + multiplier · MAD
/// lower = middle multiplier · MAD
/// ```
///
/// Where [`BollingerBands`](crate::BollingerBands) centre on the mean and scale
/// by the standard deviation — both of which a single spike can drag
/// arbitrarily far — the Median Channel uses two order statistics. The
/// breakdown point of the median and MAD is 50%: up to half the window can be
/// contaminated before the centre or width is materially distorted. That makes
/// the channel well suited to noisy, gap-prone, or fat-tailed series where
/// Bollinger Bands flare on every outlier. Both quantiles use the type-7
/// interpolation shared with [`RollingQuantile`](crate::RollingQuantile).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MedianChannel};
///
/// let mut indicator = MedianChannel::new(20, 2.0).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i % 5));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MedianChannel {
period: usize,
multiplier: f64,
window: VecDeque<f64>,
scratch: Vec<f64>,
deviations: Vec<f64>,
}
impl MedianChannel {
/// Construct a new Median Channel.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`, or
/// [`Error::NonPositiveMultiplier`] if `multiplier` is not strictly
/// positive and finite.
pub fn new(period: usize, multiplier: f64) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if !multiplier.is_finite() || multiplier <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
Ok(Self {
period,
multiplier,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
deviations: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// Configured multiplier.
pub const fn multiplier(&self) -> f64 {
self.multiplier
}
}
impl Indicator for MedianChannel {
type Input = f64;
type Output = MedianChannelOutput;
fn update(&mut self, value: f64) -> Option<MedianChannelOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
let median = quantile_sorted(&self.scratch, 0.5);
self.deviations.clear();
for &v in &self.window {
self.deviations.push((v - median).abs());
}
self.deviations.sort_by(f64::total_cmp);
let mad = quantile_sorted(&self.deviations, 0.5);
let offset = self.multiplier * mad;
Some(MedianChannelOutput {
upper: median + offset,
middle: median,
lower: median - offset,
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
self.deviations.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"MedianChannel"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(MedianChannel::new(0, 2.0), Err(Error::PeriodZero)));
assert!(MedianChannel::new(1, 2.0).is_ok());
}
#[test]
fn rejects_non_positive_multiplier() {
assert!(matches!(
MedianChannel::new(20, 0.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
MedianChannel::new(20, -1.0),
Err(Error::NonPositiveMultiplier)
));
assert!(matches!(
MedianChannel::new(20, f64::NAN),
Err(Error::NonPositiveMultiplier)
));
}
#[test]
fn accessors_and_metadata() {
let mc = MedianChannel::new(20, 2.0).unwrap();
assert_eq!(mc.period(), 20);
assert_relative_eq!(mc.multiplier(), 2.0, epsilon = 1e-12);
assert_eq!(mc.warmup_period(), 20);
assert_eq!(mc.name(), "MedianChannel");
assert!(!mc.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut mc = MedianChannel::new(5, 2.0).unwrap();
for v in [1.0, 2.0, 3.0, 4.0] {
assert!(mc.update(v).is_none());
}
assert!(mc.update(5.0).is_some());
assert!(mc.is_ready());
}
#[test]
fn known_channel() {
// [1,2,3,4,5]: median 3; |dev| sorted [0,1,1,2,2] -> MAD 1.
// upper = 3 + 2*1 = 5; lower = 3 - 2*1 = 1.
let mut mc = MedianChannel::new(5, 2.0).unwrap();
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 5.0]);
let last = out[4].unwrap();
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
}
#[test]
fn robust_to_outlier() {
// Replacing the last value with a huge spike leaves the median centre
// unchanged (still the middle order statistic).
let mut mc = MedianChannel::new(5, 2.0).unwrap();
let out = mc.batch(&[1.0, 2.0, 3.0, 4.0, 1_000.0]);
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Ten values through a period-5 window: only the last five survive,
// reproducing the `known_channel` window.
let mut mc = MedianChannel::new(5, 2.0).unwrap();
let out = mc.batch(&[10.0, 10.0, 10.0, 10.0, 10.0, 1.0, 2.0, 3.0, 4.0, 5.0]);
let last = out[9].unwrap();
assert_relative_eq!(last.middle, 3.0, epsilon = 1e-12);
assert_relative_eq!(last.upper, 5.0, epsilon = 1e-12);
assert_relative_eq!(last.lower, 1.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mc = MedianChannel::new(5, 2.0).unwrap();
for v in [1.0, 2.0, 3.0, 4.0, 5.0] {
mc.update(v);
}
assert!(mc.is_ready());
mc.reset();
assert!(!mc.is_ready());
assert!(mc.update(1.0).is_none());
}
}
+34 -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;
@@ -52,6 +53,7 @@ mod bipower_variation;
mod body_size_pct;
mod bollinger;
mod bollinger_bandwidth;
mod bomar_bands;
mod breadth_thrust;
mod breakaway;
mod bullish_percent_index;
@@ -114,6 +116,7 @@ mod ehlers_stochastic;
mod ehma;
mod elder_impulse;
mod elder_ray;
mod elder_safezone;
mod ema;
mod empirical_mode_decomposition;
mod engulfing;
@@ -191,6 +194,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;
@@ -229,6 +233,7 @@ mod mcclellan_oscillator;
mod mcclellan_summation_index;
mod mcginley_dynamic;
mod median_absolute_deviation;
mod median_channel;
mod median_ma;
mod median_price;
mod mfi;
@@ -237,11 +242,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;
@@ -276,10 +283,13 @@ mod polarized_fractal_efficiency;
mod ppo;
mod ppo_histogram;
mod profit_factor;
mod projection_bands;
mod projection_oscillator;
mod psar;
mod pvi;
mod qqe;
mod qstick;
mod quartile_bands;
mod quoted_spread;
mod r_squared;
mod realized_spread;
@@ -373,6 +383,7 @@ 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;
@@ -464,6 +475,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;
@@ -481,6 +493,7 @@ pub use bipower_variation::BipowerVariation;
pub use body_size_pct::BodySizePct;
pub use bollinger::{BollingerBands, BollingerOutput};
pub use bollinger_bandwidth::BollingerBandwidth;
pub use bomar_bands::{BomarBands, BomarBandsOutput};
pub use breadth_thrust::BreadthThrust;
pub use breakaway::Breakaway;
pub use bullish_percent_index::BullishPercentIndex;
@@ -543,6 +556,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;
@@ -620,6 +634,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};
@@ -658,6 +673,7 @@ pub use mcclellan_oscillator::McClellanOscillator;
pub use mcclellan_summation_index::McClellanSummationIndex;
pub use mcginley_dynamic::McGinleyDynamic;
pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_channel::{MedianChannel, MedianChannelOutput};
pub use median_ma::MedianMa;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
@@ -666,11 +682,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;
@@ -705,10 +723,13 @@ pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
pub use ppo::Ppo;
pub use ppo_histogram::PpoHistogram;
pub use profit_factor::ProfitFactor;
pub use projection_bands::{ProjectionBands, ProjectionBandsOutput};
pub use projection_oscillator::ProjectionOscillator;
pub use psar::Psar;
pub use pvi::Pvi;
pub use qqe::{Qqe, QqeOutput};
pub use qstick::Qstick;
pub use quartile_bands::{QuartileBands, QuartileBandsOutput};
pub use quoted_spread::QuotedSpread;
pub use r_squared::RSquared;
pub use realized_spread::RealizedSpread;
@@ -802,6 +823,7 @@ 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;
@@ -1040,6 +1062,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TtmSqueeze",
"FractalChaosBands",
"VwapStdDevBands",
"QuartileBands",
"BomarBands",
"MedianChannel",
"ProjectionBands",
"ProjectionOscillator",
],
),
(
@@ -1058,6 +1085,12 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"StepTrailingStop",
"RenkoTrailingStop",
"SarExt",
"KaseDevStop",
"ElderSafeZone",
"AtrRatchet",
"Nrtr",
"TimeBasedStop",
"ModifiedMaStop",
],
),
(
@@ -1441,6 +1474,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, 429, "FAMILIES total drifted from indicator count");
assert_eq!(total, 440, "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,253 @@
//! Projection Bands (Mel Widner) — a high/low linear-regression projection
//! envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Projection Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ProjectionBandsOutput {
/// Upper band: the maximum forward-projected high in the window.
pub upper: f64,
/// Middle line: the midpoint of the upper and lower bands.
pub middle: f64,
/// Lower band: the minimum forward-projected low in the window.
pub lower: f64,
}
/// Projection Bands: forward-projected high/low envelope.
///
/// Mel Widner ("Projection Bands and the Projection Oscillator", *Technical
/// Analysis of Stocks & Commodities*, May 1995) fits a separate linear
/// regression to the highs and to the lows over the last `period` bars, then
/// slides every bar's high and low forward to the current bar along its own
/// slope. The upper band is the maximum of the projected highs, the lower band
/// the minimum of the projected lows:
///
/// ```text
/// slope_h = OLS slope of (x, high) over the window
/// slope_l = OLS slope of (x, low) over the window
/// // bar i (0 = oldest, period-1 = newest) is (period-1-i) bars in the past
/// upper = max over i of [ high_i + slope_h · (period-1-i) ]
/// lower = min over i of [ low_i + slope_l · (period-1-i) ]
/// middle = (upper + lower) / 2
/// ```
///
/// Unlike [`LinRegChannel`](crate::LinRegChannel) and
/// [`StandardErrorBands`](crate::StandardErrorBands) — which wrap a single
/// close-regression endpoint by a dispersion statistic — Projection Bands are
/// built from the *extremes*: the envelope adapts to the trend's slope yet
/// always contains every projected high and low, so by construction price never
/// pierces the bands within the window. A flat slope reduces the bands to the
/// rolling highest-high / lowest-low (a Donchian channel); a steep slope tilts
/// the whole envelope with the trend.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ProjectionBands};
///
/// let mut indicator = ProjectionBands::new(14).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ProjectionBands {
period: usize,
highs: VecDeque<f64>,
lows: VecDeque<f64>,
sum_x: f64,
sum_xx: f64,
}
impl ProjectionBands {
/// Construct new Projection Bands.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` (a regression slope
/// needs at least two points).
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "projection bands need period >= 2",
});
}
let n = period as f64;
Ok(Self {
period,
highs: VecDeque::with_capacity(period),
lows: VecDeque::with_capacity(period),
sum_x: n * (n - 1.0) / 2.0,
sum_xx: (n - 1.0) * n * (2.0 * n - 1.0) / 6.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
/// OLS slope of `(0..period, values)` over the live window.
fn slope(&self, values: &VecDeque<f64>) -> f64 {
let n = self.period as f64;
let mut sum_y = 0.0;
let mut sum_xy = 0.0;
for (i, &y) in values.iter().enumerate() {
sum_y += y;
sum_xy += (i as f64) * y;
}
let denom = n * self.sum_xx - self.sum_x * self.sum_x;
(n * sum_xy - self.sum_x * sum_y) / denom
}
}
impl Indicator for ProjectionBands {
type Input = Candle;
type Output = ProjectionBandsOutput;
fn update(&mut self, candle: Candle) -> Option<ProjectionBandsOutput> {
if self.highs.len() == self.period {
self.highs.pop_front();
self.lows.pop_front();
}
self.highs.push_back(candle.high);
self.lows.push_back(candle.low);
if self.highs.len() < self.period {
return None;
}
let slope_h = self.slope(&self.highs);
let slope_l = self.slope(&self.lows);
let last = (self.period - 1) as f64;
let mut upper = f64::NEG_INFINITY;
let mut lower = f64::INFINITY;
for (i, (&high, &low)) in self.highs.iter().zip(self.lows.iter()).enumerate() {
let forward = last - (i as f64);
let projected_high = high + slope_h * forward;
let projected_low = low + slope_l * forward;
if projected_high > upper {
upper = projected_high;
}
if projected_low < lower {
lower = projected_low;
}
}
Some(ProjectionBandsOutput {
upper,
middle: f64::midpoint(upper, lower),
lower,
})
}
fn reset(&mut self) {
self.highs.clear();
self.lows.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.highs.len() == self.period
}
fn name(&self) -> &'static str {
"ProjectionBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(low, high, low, close, 10.0, ts).unwrap()
}
#[test]
fn rejects_period_below_two() {
assert!(matches!(
ProjectionBands::new(0),
Err(Error::InvalidPeriod { .. })
));
assert!(matches!(
ProjectionBands::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(ProjectionBands::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let pb = ProjectionBands::new(14).unwrap();
assert_eq!(pb.period(), 14);
assert_eq!(pb.warmup_period(), 14);
assert_eq!(pb.name(), "ProjectionBands");
assert!(!pb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut pb = ProjectionBands::new(3).unwrap();
assert!(pb.update(candle(10.0, 8.0, 9.0, 0)).is_none());
assert!(pb.update(candle(12.0, 9.0, 11.0, 1)).is_none());
assert!(pb.update(candle(11.0, 10.0, 11.0, 2)).is_some());
assert!(pb.is_ready());
}
#[test]
fn known_projection() {
// highs 10,12,11 -> slope_h = 0.5; projected = 11, 12.5, 11 -> upper 12.5
// lows 8, 9,10 -> slope_l = 1.0; projected = 10, 10, 10 -> lower 10
let mut pb = ProjectionBands::new(3).unwrap();
pb.update(candle(10.0, 8.0, 9.0, 0));
pb.update(candle(12.0, 9.0, 11.0, 1));
let out = pb.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
assert_relative_eq!(out.upper, 12.5, epsilon = 1e-9);
assert_relative_eq!(out.lower, 10.0, epsilon = 1e-9);
assert_relative_eq!(out.middle, 11.25, epsilon = 1e-9);
}
#[test]
fn perfect_trend_pins_bands_to_current_extremes() {
// High_i and Low_i both rise by exactly 1 per bar: every projected high
// collapses onto the current high, every projected low onto the current
// low.
let mut pb = ProjectionBands::new(5).unwrap();
let mut last = None;
for i in 0..10 {
let high = 100.0 + f64::from(i);
let low = 95.0 + f64::from(i);
last = pb.update(candle(high, low, high, i64::from(i)));
}
let out = last.unwrap();
assert_relative_eq!(out.upper, 109.0, epsilon = 1e-9);
assert_relative_eq!(out.lower, 104.0, epsilon = 1e-9);
assert_relative_eq!(out.middle, 106.5, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut pb = ProjectionBands::new(3).unwrap();
pb.update(candle(10.0, 8.0, 9.0, 0));
pb.update(candle(12.0, 9.0, 11.0, 1));
pb.update(candle(11.0, 10.0, 11.0, 2));
assert!(pb.is_ready());
pb.reset();
assert!(!pb.is_ready());
assert!(pb.update(candle(10.0, 8.0, 9.0, 3)).is_none());
}
}
@@ -0,0 +1,168 @@
//! Projection Oscillator (Mel Widner) — the close's position inside the
//! [`ProjectionBands`](crate::ProjectionBands).
use crate::error::Result;
use crate::indicators::projection_bands::ProjectionBands;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Projection Oscillator: where the close sits inside the projection bands,
/// scaled to `0..100`.
///
/// The companion to [`ProjectionBands`](crate::ProjectionBands) from Mel
/// Widner's May 1995 *Stocks & Commodities* article. It maps the close onto the
/// `[lower, upper]` projection envelope:
///
/// ```text
/// PO = 100 · (close lower) / (upper lower)
/// ```
///
/// `PO = 0` means the close is sitting on the lower band, `PO = 100` on the
/// upper band, and `PO = 50` at the midline. Because the bands by construction
/// bracket every projected high and low, the close almost always falls inside
/// them and the oscillator stays in `0..100` — readings near the extremes flag
/// an overbought/oversold position *relative to the trend-tilted channel*
/// rather than to a horizontal level. When the bands collapse (a zero-range
/// window, `upper == lower`) the position is undefined and the oscillator
/// returns the neutral `50.0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, ProjectionOscillator};
///
/// let mut indicator = ProjectionOscillator::new(14).unwrap();
/// let mut last = None;
/// for i in 0..30 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct ProjectionOscillator {
bands: ProjectionBands,
}
impl ProjectionOscillator {
/// Construct a new Projection Oscillator.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`](crate::Error::InvalidPeriod) if
/// `period < 2`.
pub fn new(period: usize) -> Result<Self> {
Ok(Self {
bands: ProjectionBands::new(period)?,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.bands.period()
}
}
impl Indicator for ProjectionOscillator {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let bands = self.bands.update(candle)?;
let width = bands.upper - bands.lower;
if width == 0.0 {
return Some(50.0);
}
Some(100.0 * (candle.close - bands.lower) / width)
}
fn reset(&mut self) {
self.bands.reset();
}
fn warmup_period(&self) -> usize {
self.bands.warmup_period()
}
fn is_ready(&self) -> bool {
self.bands.is_ready()
}
fn name(&self) -> &'static str {
"ProjectionOscillator"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use approx::assert_relative_eq;
fn candle(high: f64, low: f64, close: f64, ts: i64) -> Candle {
Candle::new(low, high, low, close, 10.0, ts).unwrap()
}
#[test]
fn rejects_period_below_two() {
assert!(matches!(
ProjectionOscillator::new(1),
Err(Error::InvalidPeriod { .. })
));
assert!(ProjectionOscillator::new(2).is_ok());
}
#[test]
fn accessors_and_metadata() {
let po = ProjectionOscillator::new(14).unwrap();
assert_eq!(po.period(), 14);
assert_eq!(po.warmup_period(), 14);
assert_eq!(po.name(), "ProjectionOscillator");
assert!(!po.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut po = ProjectionOscillator::new(3).unwrap();
assert!(po.update(candle(10.0, 8.0, 9.0, 0)).is_none());
assert!(po.update(candle(12.0, 9.0, 11.0, 1)).is_none());
assert!(po.update(candle(11.0, 10.0, 11.0, 2)).is_some());
assert!(po.is_ready());
}
#[test]
fn known_position() {
// Same window as ProjectionBands::known_projection: upper 12.5, lower 10.
// close 11 -> 100 * (11 - 10) / (12.5 - 10) = 40.
let mut po = ProjectionOscillator::new(3).unwrap();
po.update(candle(10.0, 8.0, 9.0, 0));
po.update(candle(12.0, 9.0, 11.0, 1));
let out = po.update(candle(11.0, 10.0, 11.0, 2)).unwrap();
assert_relative_eq!(out, 40.0, epsilon = 1e-9);
}
#[test]
fn collapsed_bands_return_neutral() {
// Zero-range, perfectly trending candles: upper == lower every bar.
let mut po = ProjectionOscillator::new(3).unwrap();
let mut last = None;
for i in 0..6 {
let v = 100.0 + f64::from(i);
last = po.update(candle(v, v, v, i64::from(i)));
}
assert_relative_eq!(last.unwrap(), 50.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut po = ProjectionOscillator::new(3).unwrap();
po.update(candle(10.0, 8.0, 9.0, 0));
po.update(candle(12.0, 9.0, 11.0, 1));
po.update(candle(11.0, 10.0, 11.0, 2));
assert!(po.is_ready());
po.reset();
assert!(!po.is_ready());
assert!(po.update(candle(10.0, 8.0, 9.0, 3)).is_none());
}
}
@@ -0,0 +1,194 @@
//! Quartile Bands — rolling 25th / 50th / 75th percentile envelope.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::indicators::rolling_quantile::quantile_sorted;
use crate::traits::Indicator;
/// Quartile Bands output.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct QuartileBandsOutput {
/// Upper band: the rolling third quartile (75th percentile, `Q3`).
pub upper: f64,
/// Middle line: the rolling median (50th percentile, `Q2`).
pub middle: f64,
/// Lower band: the rolling first quartile (25th percentile, `Q1`).
pub lower: f64,
}
/// Quartile Bands: a distribution-based envelope drawn at the rolling quartiles.
///
/// ```text
/// lower = Q1 = 25th percentile of the last `period` values
/// middle = Q2 = 50th percentile (median)
/// upper = Q3 = 75th percentile
/// ```
///
/// Quantiles use the type-7 (`NumPy`/`R-7`) linear interpolation shared with
/// [`RollingQuantile`](crate::RollingQuantile). Where Bollinger Bands assume an
/// approximately normal distribution and size the envelope by the mean and
/// standard deviation, Quartile Bands are fully **non-parametric**: the band
/// edges are order statistics, so a single outlier shifts at most one rank
/// rather than inflating the whole width, and the inter-quartile span between
/// the bands is exactly the [`RollingIqr`](crate::RollingIqr). The middle line
/// is the robust median rather than the mean, so it is unmoved by spikes.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, QuartileBands};
///
/// let mut indicator = QuartileBands::new(20).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct QuartileBands {
period: usize,
window: VecDeque<f64>,
scratch: Vec<f64>,
}
impl QuartileBands {
/// Construct new Quartile Bands.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
scratch: Vec::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for QuartileBands {
type Input = f64;
type Output = QuartileBandsOutput;
fn update(&mut self, value: f64) -> Option<QuartileBandsOutput> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
self.scratch.clear();
self.scratch.extend(self.window.iter().copied());
self.scratch.sort_by(f64::total_cmp);
Some(QuartileBandsOutput {
upper: quantile_sorted(&self.scratch, 0.75),
middle: quantile_sorted(&self.scratch, 0.5),
lower: quantile_sorted(&self.scratch, 0.25),
})
}
fn reset(&mut self) {
self.window.clear();
self.scratch.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"QuartileBands"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(QuartileBands::new(0), Err(Error::PeriodZero)));
assert!(QuartileBands::new(1).is_ok());
}
#[test]
fn accessors_and_metadata() {
let qb = QuartileBands::new(20).unwrap();
assert_eq!(qb.period(), 20);
assert_eq!(qb.warmup_period(), 20);
assert_eq!(qb.name(), "QuartileBands");
assert!(!qb.is_ready());
}
#[test]
fn warms_up_then_emits() {
let mut qb = QuartileBands::new(4).unwrap();
assert!(qb.update(10.0).is_none());
assert!(qb.update(20.0).is_none());
assert!(qb.update(30.0).is_none());
assert!(qb.update(40.0).is_some());
assert!(qb.is_ready());
}
#[test]
fn known_quartiles() {
// sorted [10,20,30,40]:
// Q1 h=(4-1)*0.25=0.75 -> 10 + 0.75*10 = 17.5
// Q2 h=1.5 -> 20 + 0.5*10 = 25.0
// Q3 h=2.25 -> 30 + 0.25*10 = 32.5
let mut qb = QuartileBands::new(4).unwrap();
let out = qb.batch(&[40.0, 30.0, 20.0, 10.0]);
let last = out[3].unwrap();
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
}
#[test]
fn median_robust_to_outlier() {
// A single spike shifts the mean a lot but the median by at most one rank.
let mut qb = QuartileBands::new(5).unwrap();
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 1000.0]);
assert_relative_eq!(out[4].unwrap().middle, 3.0, epsilon = 1e-12);
}
#[test]
fn rolling_window_evicts_oldest() {
// Eight values through a period-4 window: only the last four survive,
// reproducing the `known_quartiles` window.
let mut qb = QuartileBands::new(4).unwrap();
let out = qb.batch(&[1.0, 2.0, 3.0, 4.0, 40.0, 30.0, 20.0, 10.0]);
let last = out[7].unwrap();
assert_relative_eq!(last.lower, 17.5, epsilon = 1e-9);
assert_relative_eq!(last.middle, 25.0, epsilon = 1e-9);
assert_relative_eq!(last.upper, 32.5, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut qb = QuartileBands::new(4).unwrap();
for v in [10.0, 20.0, 30.0, 40.0] {
qb.update(v);
}
assert!(qb.is_ready());
qb.reset();
assert!(!qb.is_ready());
assert!(qb.update(10.0).is_none());
}
}
@@ -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);
}
}
+77 -74
View File
@@ -60,85 +60,88 @@ 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,
AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput,
AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown,
AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta,
BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth,
BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread,
CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow,
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, Crab,
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, 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, MedianMa, MedianPrice, Mfi,
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar,
Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck,
OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap,
OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore,
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars,
PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Psar, Pvi, Qqe, QqeOutput, Qstick,
QuotedSpread, RSquared, RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange,
RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop,
RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility,
RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr, RollingPercentileRank,
RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput,
SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange,
SessionRangeOutput, SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume,
SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation,
SpinningTop, SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput,
SpreadHurst, StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput,
StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi,
Stochastic, StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
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, 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, 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,
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo, PpoHistogram,
ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar, Pvi, Qqe,
QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared, RealizedSpread,
RealizedVolatility, RecoveryFactor, RectangleRange, RegimeLabel, RelativeStrengthAB,
RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Rmi,
Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure, RollingCorrelation,
RollingCovariance, RollingIqr, RollingPercentileRank, RollingQuantile, RollingVwap,
RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore,
SeparatingLines, SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput,
SessionVwap, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
SineWeightedMa, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema,
TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside,
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeOfDayReturnProfile,
TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput, TradeImbalance, TrendLabel,
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,
ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop,
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,
};
+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 **429 indicators** across
- A per-indicator deep dive for every one of the **440 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.0",
"version": "0.6.2",
"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.0",
"wickra-darwin-x64": "0.6.0",
"wickra-linux-arm64-gnu": "0.6.0",
"wickra-linux-x64-gnu": "0.6.0",
"wickra-win32-arm64-msvc": "0.6.0",
"wickra-win32-x64-msvc": "0.6.0"
"wickra-darwin-arm64": "0.6.2",
"wickra-darwin-x64": "0.6.2",
"wickra-linux-arm64-gnu": "0.6.2",
"wickra-linux-x64-gnu": "0.6.2",
"wickra-win32-arm64-msvc": "0.6.2",
"wickra-win32-x64-msvc": "0.6.2"
}
},
"node_modules/wickra": {
+22 -1
View File
@@ -14,7 +14,7 @@
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
use libfuzzer_sys::fuzz_target;
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BipowerVariation, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
/// Drive a single streaming + batch run through one scalar indicator. Marked
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
@@ -272,6 +272,27 @@ fuzz_target!(|data: Vec<f64>| {
}
// --- Family 05: scalar-input band/channel indicators (multi-output) ---
{
let mut medianchannel = MedianChannel::new(5, 2.0).unwrap();
for &x in &data {
let _ = medianchannel.update(x);
}
let _ = MedianChannel::new(5, 2.0).unwrap().batch(&data);
}
{
let mut bomarbands = BomarBands::new(4, 0.85).unwrap();
for &x in &data {
let _ = bomarbands.update(x);
}
let _ = BomarBands::new(4, 0.85).unwrap().batch(&data);
}
{
let mut quartilebands = QuartileBands::new(4).unwrap();
for &x in &data {
let _ = quartilebands.update(x);
}
let _ = QuartileBands::new(4).unwrap().batch(&data);
}
{
let mut env = MaEnvelope::new(20, 0.025).unwrap();
for &x in &data {
+9 -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, 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, 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, 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, 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};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -73,10 +73,17 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| YangZhangVolatility::new(20, 252).unwrap(), &candles);
// --- Bands & Channels ---
drive(|| ProjectionOscillator::new(14).unwrap(), &candles);
drive(|| ProjectionBands::new(3).unwrap(), &candles);
drive(|| Keltner::new(20, 10, 2.0).unwrap(), &candles);
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);
@@ -86,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);