feat: TA-Lib parity — 19 standalone indicators (DM components, price transforms, ROC/LinReg/MACD/SAR variants, Hilbert outputs) (#148)

Closes the remaining TA-Lib function-name gap by shipping each missing or
bundled-only function as a real, standalone, fully-covered indicator. 19 new
indicators across 5 families; mod-count 295 -> 314.

### Trend & Directional — Directional Movement components
- `PlusDm` (`PLUS_DM`), `MinusDm` (`MINUS_DM`) — Wilder-smoothed ±DM.
- `PlusDi` (`PLUS_DI`), `MinusDi` (`MINUS_DI`) — `100·smoothed(±DM)/ATR`.
- `Dx` (`DX`) — `100·|+DI−−DI|/(+DI+−DI)`.

### Price Statistics
- `AvgPrice` (`AVGPRICE`) — `(O+H+L+C)/4`.
- `MidPoint` (`MIDPOINT`) — `(max+min)/2` of a scalar series over N.
- `MidPrice` (`MIDPRICE`) — `(highestHigh+lowestLow)/2` over N.
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — OLS intercept.
- `Tsf` (`TSF`) — time series forecast `a + b·period`.

### Momentum Oscillators
- `Rocp` (`ROCP`), `Rocr` (`ROCR`), `Rocr100` (`ROCR100`) — ROC ratio forms.

### Trailing Stops
- `SarExt` (`SAREXT`) — Parabolic SAR with start value, reversal offset,
  separate long/short acceleration, signed output.

### Trend & Directional — MACD variants
- `MacdFix` (`MACDFIX`) — MACD fixed 12/26.
- `MacdExt` (`MACDEXT`) — MACD with a selectable moving-average type per line
  (new public `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA).

### Ehlers / Cycle (DSP) — Hilbert transform outputs
- `HtPhasor` (`HT_PHASOR`) — in-phase / quadrature components.
- `HtDcPhase` (`HT_DCPHASE`) — dominant-cycle phase (degrees).
- `HtTrendMode` (`HT_TRENDMODE`) — trend (1) vs cycle (0) classification.

Each indicator ships the full chain: core + every-branch unit tests, Python /
Node / WASM bindings, fuzz coverage, README counter + family rows, CHANGELOG.
`cargo test`, doctests, `clippy -D warnings`, `npm test` and pytest all green
locally; mod-count == lib-block == README counter (314), FAMILIES total 309.
This commit is contained in:
kingchenc
2026-06-03 02:26:38 +02:00
committed by GitHub
parent 9a98e9bf55
commit 9eb46f144a
36 changed files with 6869 additions and 51 deletions
+38
View File
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **TA-Lib parity — Directional Movement components** — the ADX building blocks,
previously available only bundled inside `Adx`, as standalone single-output
indicators:
- `PlusDm` (`PLUS_DM`) — Wilder-smoothed plus directional movement.
- `MinusDm` (`MINUS_DM`) — Wilder-smoothed minus directional movement.
- `PlusDi` (`PLUS_DI`) — plus directional indicator, `100 · smoothed(+DM) / ATR`.
- `MinusDi` (`MINUS_DI`) — minus directional indicator, `100 · smoothed(-DM) / ATR`.
- `Dx` (`DX`) — directional movement index, `100 · |+DI DI| / (+DI + DI)`.
- **TA-Lib parity — price transforms** — window and per-bar price aggregates:
- `MidPrice` (`MIDPRICE`) — `(highest high + lowest low) / 2` over a window.
- `MidPoint` (`MIDPOINT`) — `(max + min) / 2` of a scalar series over a window.
- `AvgPrice` (`AVGPRICE`) — per-bar `(open + high + low + close) / 4`.
- **TA-Lib parity — rate-of-change variants** — the ratio forms of `Roc`:
- `Rocp` (`ROCP`) — `(close close[period]) / close[period]` (fraction).
- `Rocr` (`ROCR`) — `close / close[period]` (ratio).
- `Rocr100` (`ROCR100`) — `close / close[period] · 100`.
- **TA-Lib parity — linear-regression outputs** — the remaining OLS endpoints:
- `LinRegIntercept` (`LINEARREG_INTERCEPT`) — the OLS intercept `a`.
- `Tsf` (`TSF`) — time series forecast, `a + b·period` (one bar ahead).
- **TA-Lib parity — `MacdFix` (`MACDFIX`)** — MACD with fast/slow fixed at 12/26
and only the signal period configurable; output is the usual `{macd, signal,
histogram}` triple.
- **TA-Lib parity — `SarExt` (`SAREXT`)** — Parabolic SAR with a start value,
reversal offset, independent long/short acceleration, and a signed output
(positive in long phases, negative in short phases).
- **TA-Lib parity — `MacdExt` (`MACDEXT`)** — MACD with an independently
selectable moving-average type (new `MaType` enum: SMA/EMA/WMA/DEMA/TEMA/TRIMA)
for each of the fast, slow and signal lines.
- **TA-Lib parity — `HtPhasor` (`HT_PHASOR`)** — the in-phase and quadrature
components of the Hilbert-transform analytic signal, as a `{inphase,
quadrature}` pair.
- **TA-Lib parity — `HtDcPhase` (`HT_DCPHASE`)** — the phase angle (in degrees)
of the Hilbert-transform dominant cycle.
- **TA-Lib parity — `HtTrendMode` (`HT_TRENDMODE`)** — Ehlers' trend (`1`) vs
cycle (`0`) classification from the Hilbert-transform dominant cycle.
## [0.4.5] - 2026-06-02
### Added
+4 -4
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=295" 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=314" 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)
@@ -47,7 +47,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 295 indicators; start at the
every one of the 314 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),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
295 streaming-first indicators across nineteen families. Every one passes the
314 streaming-first indicators across nineteen 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).
@@ -239,7 +239,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 295 indicators
│ ├── wickra-core/ core engine + all 314 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -28,6 +28,12 @@ function num(v) {
// --- Scalar indicators: update(value) vs batch(prices) ---
const scalarFactories = {
TSF: () => new wickra.TSF(14),
LINEARREG_INTERCEPT: () => new wickra.LINEARREG_INTERCEPT(14),
ROCR100: () => new wickra.ROCR100(10),
ROCR: () => new wickra.ROCR(10),
ROCP: () => new wickra.ROCP(10),
MIDPOINT: () => new wickra.MIDPOINT(14),
SMA: () => new wickra.SMA(14),
EMA: () => new wickra.EMA(14),
WMA: () => new wickra.WMA(14),
@@ -90,6 +96,8 @@ const scalarFactories = {
EhlersStochastic: () => new wickra.EhlersStochastic(20),
EmpiricalModeDecomposition: () => new wickra.EmpiricalModeDecomposition(20, 0.5),
HilbertDominantCycle: () => new wickra.HilbertDominantCycle(),
HT_DCPHASE: () => new wickra.HT_DCPHASE(),
HT_TRENDMODE: () => new wickra.HT_TRENDMODE(),
AdaptiveCycle: () => new wickra.AdaptiveCycle(),
SineWave: () => new wickra.SineWave(),
FAMA: () => new wickra.FAMA(0.5, 0.05),
@@ -159,10 +167,17 @@ for (const [name, make] of Object.entries(scalarFactories)) {
// --- Scalar-output candle indicators: update(...) vs batch(...) ---
const candleScalar = {
MIDPRICE: { make: () => new wickra.MIDPRICE(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
DX: { make: () => new wickra.DX(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MINUS_DI: { make: () => new wickra.MINUS_DI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PLUS_DI: { make: () => new wickra.PLUS_DI(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
ATR: { make: () => new wickra.ATR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PLUS_DM: { make: () => new wickra.PLUS_DM(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MINUS_DM: { make: () => new wickra.MINUS_DM(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
CCI: { make: () => new wickra.CCI(20), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
WilliamsR: { make: () => new wickra.WilliamsR(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
PSAR: { make: () => new wickra.PSAR(0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SAREXT: { make: () => new wickra.SAREXT(0, 0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
MFI: { make: () => new wickra.MFI(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
VWAP: { make: () => new wickra.VWAP(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
RollingVWAP: { make: () => new wickra.RollingVWAP(20), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
@@ -170,6 +185,7 @@ const candleScalar = {
OBV: { make: () => new wickra.OBV(), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
VWMA: { make: () => new wickra.VWMA(20), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) },
RVI: { make: () => new wickra.RVI(10), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
AVGPRICE: { make: () => new wickra.AVGPRICE(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Inertia: { make: () => new wickra.Inertia(14, 20), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
PGO: { make: () => new wickra.PGO(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
SMI: { make: () => new wickra.SMI(5, 3, 3), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
@@ -302,6 +318,9 @@ const multi = {
Alligator: { make: () => new wickra.Alligator(13, 8, 5), fields: ['jaw', 'teeth', 'lips'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
ZeroLagMACD: { make: () => new wickra.ZeroLagMACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACD: { make: () => new wickra.MACD(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
HT_PHASOR: { make: () => new wickra.HT_PHASOR(), fields: ['inphase', 'quadrature'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACDFIX: { make: () => new wickra.MACDFIX(9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
MACDEXT: { make: () => new wickra.MACDEXT(12, 0, 26, 0, 9, 0), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
KST: { make: () => wickra.KST.classic(), fields: ['kst', 'signal'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
BollingerBands: { make: () => new wickra.BollingerBands(20, 2), fields: ['upper', 'middle', 'lower', 'stddev'], step: (ind, i) => ind.update(close[i]), batch: (ind) => ind.batch(close) },
Stochastic: { make: () => new wickra.Stochastic(14, 3), fields: ['k', 'd'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
+191
View File
@@ -45,6 +45,10 @@ export interface BollingerValue {
lower: number
stddev: number
}
export interface HtPhasorValue {
inphase: number
quadrature: number
}
export interface StochValue {
k: number
d: number
@@ -658,6 +662,60 @@ export declare class MedianAbsoluteDeviation {
isReady(): boolean
warmupPeriod(): number
}
export type MidPointNode = MIDPOINT
export declare class MIDPOINT {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RocpNode = ROCP
export declare class ROCP {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type RocrNode = ROCR
export declare class ROCR {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type Rocr100Node = ROCR100
export declare class ROCR100 {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type LinRegInterceptNode = LINEARREG_INTERCEPT
export declare class LINEARREG_INTERCEPT {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TsfNode = TSF
export declare class TSF {
constructor(period: number)
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type AutocorrelationNode = Autocorrelation
export declare class Autocorrelation {
constructor(period: number, lag: number)
@@ -801,6 +859,36 @@ export declare class MACD {
isReady(): boolean
warmupPeriod(): number
}
export type MacdFixNode = MACDFIX
export declare class MACDFIX {
constructor(signal: number)
update(value: number): MacdValue | null
/**
* Batch over a price array. Returns a flat array of length `3 * n`,
* interleaved per row as `[macd0, signal0, histogram0, macd1, ...]`.
*/
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type MacdExtNode = MACDEXT
export declare class MACDEXT {
/**
* Moving-average types are TA-Lib `MA_Type` codes `0..=5`
* (SMA, EMA, WMA, DEMA, TEMA, TRIMA).
*/
constructor(fast: number, fastMatype: number, slow: number, slowMatype: number, signal: number, signalMatype: number)
update(value: number): MacdValue | null
/**
* Batch over a price array. Returns a flat array of length `3 * n`,
* interleaved per row as `[macd0, signal0, histogram0, macd1, ...]`.
*/
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type BollingerNode = BollingerBands
export declare class BollingerBands {
constructor(period: number, multiplier: number)
@@ -824,6 +912,91 @@ export declare class ATR {
isReady(): boolean
warmupPeriod(): number
}
export type PlusDmNode = PLUS_DM
export declare class PLUS_DM {
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 MinusDmNode = MINUS_DM
export declare class MINUS_DM {
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 PlusDiNode = PLUS_DI
export declare class PLUS_DI {
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 MinusDiNode = MINUS_DI
export declare class MINUS_DI {
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 DxNode = DX
export declare class DX {
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 MidPriceNode = MIDPRICE
export declare class MIDPRICE {
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 AvgPriceNode = AVGPRICE
export declare class AVGPRICE {
constructor()
update(open: number, high: number, low: number, close: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SarExtNode = SAREXT
export declare class SAREXT {
constructor(startValue: number, offsetOnReverse: number, accelInitLong: number, accelLong: number, accelMaxLong: number, accelInitShort: number, accelShort: number, accelMaxShort: 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 HtPhasorNode = HT_PHASOR
export declare class HT_PHASOR {
constructor()
update(value: number): HtPhasorValue | null
/**
* Batch over a price array. Returns a flat array of length `2 * n`,
* interleaved per row as `[inphase0, quadrature0, inphase1, ...]`.
*/
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type StochNode = Stochastic
export declare class Stochastic {
constructor(kPeriod: number, dPeriod: number)
@@ -2010,6 +2183,24 @@ export declare class EmpiricalModeDecomposition {
isReady(): boolean
warmupPeriod(): number
}
export type HtDcPhaseNode = HT_DCPHASE
export declare class HT_DCPHASE {
constructor()
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HtTrendModeNode = HT_TRENDMODE
export declare class HT_TRENDMODE {
constructor()
update(value: number): number | null
batch(prices: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type HilbertDominantCycleNode = HilbertDominantCycle
export declare class HilbertDominantCycle {
constructor()
+20 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -350,6 +350,12 @@ module.exports.StandardError = StandardError
module.exports.DetrendedStdDev = DetrendedStdDev
module.exports.RSquared = RSquared
module.exports.MedianAbsoluteDeviation = MedianAbsoluteDeviation
module.exports.MIDPOINT = MIDPOINT
module.exports.ROCP = ROCP
module.exports.ROCR = ROCR
module.exports.ROCR100 = ROCR100
module.exports.LINEARREG_INTERCEPT = LINEARREG_INTERCEPT
module.exports.TSF = TSF
module.exports.Autocorrelation = Autocorrelation
module.exports.HurstExponent = HurstExponent
module.exports.PearsonCorrelation = PearsonCorrelation
@@ -361,8 +367,19 @@ module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
module.exports.Cointegration = Cointegration
module.exports.RelativeStrengthAB = RelativeStrengthAB
module.exports.MACD = MACD
module.exports.MACDFIX = MACDFIX
module.exports.MACDEXT = MACDEXT
module.exports.BollingerBands = BollingerBands
module.exports.ATR = ATR
module.exports.PLUS_DM = PLUS_DM
module.exports.MINUS_DM = MINUS_DM
module.exports.PLUS_DI = PLUS_DI
module.exports.MINUS_DI = MINUS_DI
module.exports.DX = DX
module.exports.MIDPRICE = MIDPRICE
module.exports.AVGPRICE = AVGPRICE
module.exports.SAREXT = SAREXT
module.exports.HT_PHASOR = HT_PHASOR
module.exports.Stochastic = Stochastic
module.exports.OBV = OBV
module.exports.ADX = ADX
@@ -491,6 +508,8 @@ module.exports.InverseFisherTransform = InverseFisherTransform
module.exports.DecyclerOscillator = DecyclerOscillator
module.exports.RoofingFilter = RoofingFilter
module.exports.EmpiricalModeDecomposition = EmpiricalModeDecomposition
module.exports.HT_DCPHASE = HT_DCPHASE
module.exports.HT_TRENDMODE = HT_TRENDMODE
module.exports.HilbertDominantCycle = HilbertDominantCycle
module.exports.AdaptiveCycle = AdaptiveCycle
module.exports.SineWave = SineWave
+723
View File
@@ -172,6 +172,16 @@ node_scalar_indicator!(
"MedianAbsoluteDeviation",
wc::MedianAbsoluteDeviation
);
node_scalar_indicator!(MidPointNode, "MIDPOINT", wc::MidPoint);
node_scalar_indicator!(RocpNode, "ROCP", wc::Rocp);
node_scalar_indicator!(RocrNode, "ROCR", wc::Rocr);
node_scalar_indicator!(Rocr100Node, "ROCR100", wc::Rocr100);
node_scalar_indicator!(
LinRegInterceptNode,
"LINEARREG_INTERCEPT",
wc::LinRegIntercept
);
node_scalar_indicator!(TsfNode, "TSF", wc::Tsf);
// ============================== Autocorrelation (period + lag) ==============================
@@ -632,6 +642,121 @@ impl MacdNode {
}
}
#[napi(js_name = "MACDFIX")]
pub struct MacdFixNode {
inner: wc::MacdFix,
}
#[napi]
impl MacdFixNode {
#[napi(constructor)]
pub fn new(signal: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::MacdFix::new(signal as usize).map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MacdValue> {
self.inner.update(value).map(|o| MacdValue {
macd: o.macd,
signal: o.signal,
histogram: o.histogram,
})
}
/// Batch over a price array. Returns a flat array of length `3 * n`,
/// interleaved per row as `[macd0, signal0, histogram0, macd1, ...]`.
#[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.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
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 = "MACDEXT")]
pub struct MacdExtNode {
inner: wc::MacdExt,
}
#[napi]
impl MacdExtNode {
/// Moving-average types are TA-Lib `MA_Type` codes `0..=5`
/// (SMA, EMA, WMA, DEMA, TEMA, TRIMA).
#[napi(constructor)]
pub fn new(
fast: u32,
fast_matype: u32,
slow: u32,
slow_matype: u32,
signal: u32,
signal_matype: u32,
) -> napi::Result<Self> {
Ok(Self {
inner: wc::MacdExt::new(
fast as usize,
wc::MaType::from_code(fast_matype).map_err(map_err)?,
slow as usize,
wc::MaType::from_code(slow_matype).map_err(map_err)?,
signal as usize,
wc::MaType::from_code(signal_matype).map_err(map_err)?,
)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<MacdValue> {
self.inner.update(value).map(|o| MacdValue {
macd: o.macd,
signal: o.signal,
histogram: o.histogram,
})
}
/// Batch over a price array. Returns a flat array of length `3 * n`,
/// interleaved per row as `[macd0, signal0, histogram0, macd1, ...]`.
#[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.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
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
}
}
// ============================== Bollinger ==============================
#[napi(object)]
@@ -753,6 +878,522 @@ impl AtrNode {
}
}
#[napi(js_name = "PLUS_DM")]
pub struct PlusDmNode {
inner: wc::PlusDm,
}
#[napi]
impl PlusDmNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PlusDm::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 = "MINUS_DM")]
pub struct MinusDmNode {
inner: wc::MinusDm,
}
#[napi]
impl MinusDmNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::MinusDm::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 = "PLUS_DI")]
pub struct PlusDiNode {
inner: wc::PlusDi,
}
#[napi]
impl PlusDiNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::PlusDi::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 = "MINUS_DI")]
pub struct MinusDiNode {
inner: wc::MinusDi,
}
#[napi]
impl MinusDiNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::MinusDi::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 = "DX")]
pub struct DxNode {
inner: wc::Dx,
}
#[napi]
impl DxNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::Dx::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 = "MIDPRICE")]
pub struct MidPriceNode {
inner: wc::MidPrice,
}
#[napi]
impl MidPriceNode {
#[napi(constructor)]
pub fn new(period: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::MidPrice::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 = "AVGPRICE")]
pub struct AvgPriceNode {
inner: wc::AvgPrice,
}
impl Default for AvgPriceNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl AvgPriceNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::AvgPrice::new(),
}
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(cnd4(open, high, low, close)?))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) {
return Err(NapiError::from_reason(
"open, high, low and close must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
out.push(
self.inner
.update(cnd4(open[i], high[i], low[i], close[i])?)
.unwrap_or(f64::NAN),
);
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "SAREXT")]
pub struct SarExtNode {
inner: wc::SarExt,
}
#[napi]
impl SarExtNode {
#[napi(constructor)]
#[allow(clippy::too_many_arguments)]
pub fn new(
start_value: f64,
offset_on_reverse: f64,
accel_init_long: f64,
accel_long: f64,
accel_max_long: f64,
accel_init_short: f64,
accel_short: f64,
accel_max_short: f64,
) -> napi::Result<Self> {
Ok(Self {
inner: wc::SarExt::new(
start_value,
offset_on_reverse,
accel_init_long,
accel_long,
accel_max_long,
accel_init_short,
accel_short,
accel_max_short,
)
.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 HtPhasorValue {
pub inphase: f64,
pub quadrature: f64,
}
#[napi(js_name = "HT_PHASOR")]
pub struct HtPhasorNode {
inner: wc::HtPhasor,
}
impl Default for HtPhasorNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl HtPhasorNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::HtPhasor::new(),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<HtPhasorValue> {
self.inner.update(value).map(|o| HtPhasorValue {
inphase: o.inphase,
quadrature: o.quadrature,
})
}
/// Batch over a price array. Returns a flat array of length `2 * n`,
/// interleaved per row as `[inphase0, quadrature0, inphase1, ...]`.
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
let mut out = vec![f64::NAN; prices.len() * 2];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.inphase;
out[i * 2 + 1] = o.quadrature;
}
}
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,
@@ -7991,6 +8632,88 @@ impl EmpiricalModeDecompositionNode {
}
}
#[napi(js_name = "HT_DCPHASE")]
pub struct HtDcPhaseNode {
inner: wc::HtDcPhase,
}
impl Default for HtDcPhaseNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl HtDcPhaseNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::HtDcPhase::new(),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "HT_TRENDMODE")]
pub struct HtTrendModeNode {
inner: wc::HtTrendMode,
}
impl Default for HtTrendModeNode {
fn default() -> Self {
Self::new()
}
}
#[napi]
impl HtTrendModeNode {
#[napi(constructor)]
pub fn new() -> Self {
Self {
inner: wc::HtTrendMode::new(),
}
}
#[napi]
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
#[napi]
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
flatten(self.inner.batch(&prices))
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "HilbertDominantCycle")]
pub struct HilbertDominantCycleNode {
inner: wc::HilbertDominantCycle,
+38
View File
@@ -25,6 +25,17 @@ from __future__ import annotations
from ._wickra import (
__version__,
TSF,
LINEARREG_INTERCEPT,
ROCR100,
ROCR,
ROCP,
AVGPRICE,
MIDPOINT,
MIDPRICE,
DX,
MINUS_DI,
PLUS_DI,
# Trend
SMA,
EMA,
@@ -49,12 +60,16 @@ from ._wickra import (
RSI,
AnchoredRSI,
MACD,
MACDFIX,
MACDEXT,
Stochastic,
CCI,
ROC,
WilliamsR,
ADX,
ADXR,
PLUS_DM,
MINUS_DM,
MFI,
TRIX,
AwesomeOscillator,
@@ -98,6 +113,7 @@ from ._wickra import (
Keltner,
Donchian,
PSAR,
SAREXT,
NATR,
StdDev,
UlcerIndex,
@@ -181,6 +197,9 @@ from ._wickra import (
EhlersStochastic,
EmpiricalModeDecomposition,
HilbertDominantCycle,
HT_DCPHASE,
HT_PHASOR,
HT_TRENDMODE,
AdaptiveCycle,
SineWave,
MAMA,
@@ -343,6 +362,17 @@ from ._wickra import (
)
__all__ = [
"TSF",
"LINEARREG_INTERCEPT",
"ROCR100",
"ROCR",
"ROCP",
"AVGPRICE",
"MIDPOINT",
"MIDPRICE",
"DX",
"MINUS_DI",
"PLUS_DI",
"__version__",
# Trend
"SMA",
@@ -368,12 +398,16 @@ __all__ = [
"RSI",
"AnchoredRSI",
"MACD",
"MACDFIX",
"MACDEXT",
"Stochastic",
"CCI",
"ROC",
"WilliamsR",
"ADX",
"ADXR",
"PLUS_DM",
"MINUS_DM",
"MFI",
"TRIX",
"AwesomeOscillator",
@@ -417,6 +451,7 @@ __all__ = [
"Keltner",
"Donchian",
"PSAR",
"SAREXT",
"NATR",
"StdDev",
"UlcerIndex",
@@ -500,6 +535,9 @@ __all__ = [
"EhlersStochastic",
"EmpiricalModeDecomposition",
"HilbertDominantCycle",
"HT_DCPHASE",
"HT_PHASOR",
"HT_TRENDMODE",
"AdaptiveCycle",
"SineWave",
"MAMA",
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,12 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
# --- Scalar (f64 -> f64) indicators ---------------------------------------
SCALAR = [
(ta.TSF, (14,)),
(ta.LINEARREG_INTERCEPT, (14,)),
(ta.ROCR100, (10,)),
(ta.ROCR, (10,)),
(ta.ROCP, (10,)),
(ta.MIDPOINT, (14,)),
(ta.SMMA, (14,)),
(ta.TRIMA, (20,)),
(ta.ZLEMA, (14,)),
@@ -96,6 +102,8 @@ SCALAR = [
(ta.EhlersStochastic, (20,)),
(ta.EmpiricalModeDecomposition, (20, 0.5)),
(ta.HilbertDominantCycle, ()),
(ta.HT_DCPHASE, ()),
(ta.HT_TRENDMODE, ()),
(ta.AdaptiveCycle, ()),
(ta.SineWave, ()),
(ta.FAMA, (0.5, 0.05)),
@@ -136,6 +144,9 @@ SCALAR_MULTI = {
"LinRegChannel": (lambda: ta.LinRegChannel(20, 2.0), 3),
"StandardErrorBands": (lambda: ta.StandardErrorBands(21, 2.0), 3),
"DoubleBollinger": (lambda: ta.DoubleBollinger(20, 1.0, 2.0), 5),
"MacdFix": (lambda: ta.MACDFIX(9), 3),
"MacdExt": (lambda: ta.MACDEXT(12, 0, 26, 0, 9, 0), 3),
"HtPhasor": (lambda: ta.HT_PHASOR(), 2),
}
@@ -275,7 +286,15 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
"MIDPRICE": (lambda: ta.MIDPRICE(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"AVGPRICE": (lambda: ta.AVGPRICE(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
"DX": (lambda: ta.DX(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"MINUS_DI": (lambda: ta.MINUS_DI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"PLUS_DI": (lambda: ta.PLUS_DI(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"VWMA": (lambda: ta.VWMA(20), lambda ind, h, l, c, v: ind.batch(c, v)),
"SAREXT": (lambda: ta.SAREXT(), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"PLUS_DM": (lambda: ta.PLUS_DM(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"MINUS_DM": (lambda: ta.MINUS_DM(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
"RVI": (
# extract_candle pulls the open price from index 0 of the tuple; the
# streaming test below already builds candles with open == close, so
@@ -1171,6 +1190,99 @@ def test_weighted_close_reference():
)
def test_plus_dm_reference():
# Highs rise by 1 (up = +1) while lows rise by 0.5, so every raw +DM equals
# the up-move (1.0). Period 3: seed = 3 * 1 = 3.0, then the Wilder step holds it.
high = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
low = np.array([9.0, 9.5, 10.0, 10.5, 11.0])
close = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
out = ta.PLUS_DM(3).batch(high, low, close)
assert math.isnan(out[0]) and math.isnan(out[2])
assert out[3] == pytest.approx(3.0)
assert out[4] == pytest.approx(3.0)
def test_minus_dm_reference():
# Lows fall by 1 (down = +1) while highs fall by 0.5, so every raw -DM equals
# the down-move (1.0). Period 3: seed = 3 * 1 = 3.0, then the Wilder step holds it.
high = np.array([20.0, 19.5, 19.0, 18.5, 18.0])
low = np.array([18.0, 17.0, 16.0, 15.0, 14.0])
close = np.array([19.0, 18.0, 17.0, 16.0, 15.0])
out = ta.MINUS_DM(3).batch(high, low, close)
assert math.isnan(out[0]) and math.isnan(out[2])
assert out[3] == pytest.approx(3.0)
assert out[4] == pytest.approx(3.0)
def test_plus_di_reference():
# Strict uptrend -> +DI dominates and stays within (0, 100].
high = np.array([101.0, 103.0, 105.0, 107.0, 109.0, 111.0])
low = np.array([99.5, 101.5, 103.5, 105.5, 107.5, 109.5])
close = np.array([100.5, 102.5, 104.5, 106.5, 108.5, 110.5])
out = ta.PLUS_DI(3).batch(high, low, close)
assert 0.0 < out[-1] <= 100.0
def test_minus_di_reference():
# Strict downtrend -> -DI dominates and stays within (0, 100].
high = np.array([111.0, 109.0, 107.0, 105.0, 103.0, 101.0])
low = np.array([109.5, 107.5, 105.5, 103.5, 101.5, 99.5])
close = np.array([110.5, 108.5, 106.5, 104.5, 102.5, 100.5])
out = ta.MINUS_DI(3).batch(high, low, close)
assert 0.0 < out[-1] <= 100.0
def test_dx_reference():
# Strict trend -> one-sided directional movement -> DX is large, in (0, 100].
high = np.array([101.0, 103.0, 105.0, 107.0, 109.0, 111.0])
low = np.array([99.5, 101.5, 103.5, 105.5, 107.5, 109.5])
close = np.array([100.5, 102.5, 104.5, 106.5, 108.5, 110.5])
out = ta.DX(3).batch(high, low, close)
assert 50.0 < out[-1] <= 100.0
def test_mid_price_reference():
# Window highs {12, 14, 16}, lows {8, 9, 10}: (16 + 8) / 2 = 12.
high = np.array([12.0, 14.0, 16.0])
low = np.array([8.0, 9.0, 10.0])
close = np.array([10.0, 11.0, 12.0])
out = ta.MIDPRICE(3).batch(high, low, close)
assert out[-1] == pytest.approx(12.0)
def test_mid_point_reference():
# Window {8, 12, 10}: (12 + 8) / 2 = 10.
out = ta.MIDPOINT(3).batch(np.array([8.0, 12.0, 10.0]))
assert out[-1] == pytest.approx(10.0)
def test_avg_price_reference():
# (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5.
assert ta.AVGPRICE().update((10.0, 14.0, 6.0, 12.0, 1.0, 0)) == pytest.approx(10.5)
def test_roc_ratio_variants_reference():
# period 1 over [10, 11]: ROCP = 0.1, ROCR = 1.1, ROCR100 = 110.
assert ta.ROCP(1).batch(np.array([10.0, 11.0]))[-1] == pytest.approx(0.1)
assert ta.ROCR(1).batch(np.array([10.0, 11.0]))[-1] == pytest.approx(1.1)
assert ta.ROCR100(1).batch(np.array([10.0, 11.0]))[-1] == pytest.approx(110.0)
def test_linreg_intercept_and_tsf_reference():
# period 3 over [1, 2, 9]: fit y = 0 + 4x. intercept = 0; forecast at x=3 = 12.
data = np.array([1.0, 2.0, 9.0])
assert ta.LINEARREG_INTERCEPT(3).batch(data)[-1] == pytest.approx(0.0, abs=1e-9)
assert ta.TSF(3).batch(data)[-1] == pytest.approx(12.0)
def test_macdfix_matches_macd():
# MACDFIX(signal) is exactly MACD(12, 26, signal).
prices = 100.0 + np.sin(np.arange(80) * 0.3) * 5.0
fix = ta.MACDFIX(9).batch(prices)
classic = ta.MACD(12, 26, 9).batch(prices)
np.testing.assert_allclose(fix, classic, equal_nan=True)
def test_nvi_reference():
# closes [10, 11], volumes [200, 100]: volume contracts -> NVI absorbs +10%.
# 1000 * (1 + 0.1) = 1100.
+560
View File
@@ -1131,6 +1131,494 @@ impl WasmAtr {
}
}
#[wasm_bindgen(js_name = PLUS_DM)]
pub struct WasmPlusDm {
inner: wc::PlusDm,
}
#[wasm_bindgen(js_class = PLUS_DM)]
impl WasmPlusDm {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmPlusDm, JsError> {
Ok(Self {
inner: wc::PlusDm::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 = MINUS_DM)]
pub struct WasmMinusDm {
inner: wc::MinusDm,
}
#[wasm_bindgen(js_class = MINUS_DM)]
impl WasmMinusDm {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmMinusDm, JsError> {
Ok(Self {
inner: wc::MinusDm::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 = PLUS_DI)]
pub struct WasmPlusDi {
inner: wc::PlusDi,
}
#[wasm_bindgen(js_class = PLUS_DI)]
impl WasmPlusDi {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmPlusDi, JsError> {
Ok(Self {
inner: wc::PlusDi::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 = MINUS_DI)]
pub struct WasmMinusDi {
inner: wc::MinusDi,
}
#[wasm_bindgen(js_class = MINUS_DI)]
impl WasmMinusDi {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmMinusDi, JsError> {
Ok(Self {
inner: wc::MinusDi::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 = DX)]
pub struct WasmDx {
inner: wc::Dx,
}
#[wasm_bindgen(js_class = DX)]
impl WasmDx {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmDx, JsError> {
Ok(Self {
inner: wc::Dx::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 = MIDPRICE)]
pub struct WasmMidPrice {
inner: wc::MidPrice,
}
#[wasm_bindgen(js_class = MIDPRICE)]
impl WasmMidPrice {
#[wasm_bindgen(constructor)]
pub fn new(period: usize) -> Result<WasmMidPrice, JsError> {
Ok(Self {
inner: wc::MidPrice::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 = AVGPRICE)]
pub struct WasmAvgPrice {
inner: wc::AvgPrice,
}
impl Default for WasmAvgPrice {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = AVGPRICE)]
impl WasmAvgPrice {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmAvgPrice {
Self {
inner: wc::AvgPrice::new(),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
) -> Result<Option<f64>, JsError> {
let c = make_candle_ohlc(open, high, low, close)?;
Ok(self.inner.update(c))
}
pub fn batch(
&mut self,
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
) -> Result<Float64Array, JsError> {
if !(open.len() == high.len() && high.len() == low.len() && low.len() == close.len()) {
return Err(JsError::new(
"open, high, low and close must be equal length",
));
}
let mut out = Vec::with_capacity(close.len());
for i in 0..close.len() {
let c = make_candle_ohlc(open[i], high[i], low[i], close[i])?;
out.push(self.inner.update(c).unwrap_or(f64::NAN));
}
Ok(Float64Array::from(out.as_slice()))
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = MACDEXT)]
pub struct WasmMacdExt {
inner: wc::MacdExt,
}
#[wasm_bindgen(js_class = MACDEXT)]
impl WasmMacdExt {
/// Moving-average types are TA-Lib `MA_Type` codes `0..=5`.
#[wasm_bindgen(constructor)]
pub fn new(
fast: usize,
fast_matype: u32,
slow: usize,
slow_matype: u32,
signal: usize,
signal_matype: u32,
) -> Result<WasmMacdExt, JsError> {
Ok(Self {
inner: wc::MacdExt::new(
fast,
wc::MaType::from_code(fast_matype).map_err(map_err)?,
slow,
wc::MaType::from_code(slow_matype).map_err(map_err)?,
signal,
wc::MaType::from_code(signal_matype).map_err(map_err)?,
)
.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, &"macd".into(), &o.macd.into()).ok();
Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok();
Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Returns a flat `Float64Array` of length `3 * n`: `[macd0, sig0, hist0, ...]`.
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.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = MACDFIX)]
pub struct WasmMacdFix {
inner: wc::MacdFix,
}
#[wasm_bindgen(js_class = MACDFIX)]
impl WasmMacdFix {
#[wasm_bindgen(constructor)]
pub fn new(signal: usize) -> Result<WasmMacdFix, JsError> {
Ok(Self {
inner: wc::MacdFix::new(signal).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, &"macd".into(), &o.macd.into()).ok();
Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok();
Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Returns a flat `Float64Array` of length `3 * n`: `[macd0, sig0, hist0, ...]`.
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.macd;
out[i * 3 + 1] = o.signal;
out[i * 3 + 2] = o.histogram;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = SAREXT)]
pub struct WasmSarExt {
inner: wc::SarExt,
}
#[wasm_bindgen(js_class = SAREXT)]
impl WasmSarExt {
#[wasm_bindgen(constructor)]
#[allow(clippy::too_many_arguments)]
pub fn new(
start_value: f64,
offset_on_reverse: f64,
accel_init_long: f64,
accel_long: f64,
accel_max_long: f64,
accel_init_short: f64,
accel_short: f64,
accel_max_short: f64,
) -> Result<WasmSarExt, JsError> {
Ok(Self {
inner: wc::SarExt::new(
start_value,
offset_on_reverse,
accel_init_long,
accel_long,
accel_max_long,
accel_init_short,
accel_short,
accel_max_short,
)
.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 = HT_PHASOR)]
pub struct WasmHtPhasor {
inner: wc::HtPhasor,
}
impl Default for WasmHtPhasor {
fn default() -> Self {
Self::new()
}
}
#[wasm_bindgen(js_class = HT_PHASOR)]
impl WasmHtPhasor {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmHtPhasor {
Self {
inner: wc::HtPhasor::new(),
}
}
pub fn update(&mut self, value: f64) -> JsValue {
match self.inner.update(value) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"inphase".into(), &o.inphase.into()).ok();
Reflect::set(&obj, &"quadrature".into(), &o.quadrature.into()).ok();
obj.into()
}
None => JsValue::NULL,
}
}
/// Returns a flat `Float64Array` of length `2 * n`: `[inphase0, quad0, ...]`.
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
let n = prices.len();
let mut out = vec![f64::NAN; n * 2];
for (i, p) in prices.iter().enumerate() {
if let Some(o) = self.inner.update(*p) {
out[i * 2] = o.inphase;
out[i * 2 + 1] = o.quadrature;
}
}
Float64Array::from(out.as_slice())
}
pub fn reset(&mut self) {
self.inner.reset();
}
}
#[wasm_bindgen(js_name = Stochastic)]
pub struct WasmStoch {
inner: wc::Stochastic,
@@ -3826,6 +4314,72 @@ impl WasmAroon {
// ============================== Family 10: parameterless / multi-output ==============================
#[wasm_bindgen(js_name = HT_DCPHASE)]
pub struct WasmHtDcPhase {
inner: wc::HtDcPhase,
}
#[wasm_bindgen(js_class = HT_DCPHASE)]
impl WasmHtDcPhase {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmHtDcPhase {
Self {
inner: wc::HtDcPhase::new(),
}
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
Float64Array::from(flatten(self.inner.batch(prices)).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()
}
}
#[wasm_bindgen(js_name = HT_TRENDMODE)]
pub struct WasmHtTrendMode {
inner: wc::HtTrendMode,
}
#[wasm_bindgen(js_class = HT_TRENDMODE)]
impl WasmHtTrendMode {
#[wasm_bindgen(constructor)]
#[allow(clippy::new_without_default)]
pub fn new() -> WasmHtTrendMode {
Self {
inner: wc::HtTrendMode::new(),
}
}
pub fn update(&mut self, value: f64) -> Option<f64> {
self.inner.update(value)
}
pub fn batch(&mut self, prices: &[f64]) -> Float64Array {
Float64Array::from(flatten(self.inner.batch(prices)).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()
}
}
#[wasm_bindgen(js_name = HilbertDominantCycle)]
pub struct WasmHilbertDominantCycle {
inner: wc::HilbertDominantCycle,
@@ -8339,6 +8893,12 @@ wasm_scalar_indicator!(WasmSortinoRatio, "SortinoRatio", wc::SortinoRatio, perio
wasm_scalar_indicator!(WasmOmegaRatio, "OmegaRatio", wc::OmegaRatio, period: usize, threshold: f64);
wasm_scalar_indicator!(WasmValueAtRisk, "ValueAtRisk", wc::ValueAtRisk, period: usize, confidence: f64);
wasm_scalar_indicator!(WasmConditionalValueAtRisk, "ConditionalValueAtRisk", wc::ConditionalValueAtRisk, period: usize, confidence: f64);
wasm_scalar_indicator!(WasmMidPoint, "MIDPOINT", wc::MidPoint, period: usize);
wasm_scalar_indicator!(WasmRocp, "ROCP", wc::Rocp, period: usize);
wasm_scalar_indicator!(WasmRocr, "ROCR", wc::Rocr, period: usize);
wasm_scalar_indicator!(WasmRocr100, "ROCR100", wc::Rocr100, period: usize);
wasm_scalar_indicator!(WasmLinRegIntercept, "LINEARREG_INTERCEPT", wc::LinRegIntercept, period: usize);
wasm_scalar_indicator!(WasmTsf, "TSF", wc::Tsf, period: usize);
// --- DrawdownDuration: u32 output, no constructor args ---
+1 -1
View File
@@ -91,7 +91,7 @@ impl Adx {
}
}
fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
pub(crate) fn directional_movement(prev: &Candle, current: &Candle) -> (f64, f64) {
let up = current.high - prev.high;
let down = prev.low - current.low;
let plus_dm = if up > down && up > 0.0 { up } else { 0.0 };
@@ -0,0 +1,92 @@
//! Average Price (AVGPRICE).
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Price (`AVGPRICE`) — the bar's `(open + high + low + close) / 4`.
///
/// A per-bar price aggregate that, unlike [`TypicalPrice`](crate::TypicalPrice)
/// and [`WeightedClose`](crate::WeightedClose), folds in the open as well as the
/// high, low and close. As a stateless transform it emits a value from the very
/// first candle.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AvgPrice};
///
/// let mut indicator = AvgPrice::new();
/// let mut last = None;
/// for i in 0..80 {
/// 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, Default)]
pub struct AvgPrice {
has_emitted: bool,
}
impl AvgPrice {
/// Construct a new Average Price transform.
pub const fn new() -> Self {
Self { has_emitted: false }
}
}
impl Indicator for AvgPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
self.has_emitted = true;
Some(candle.avg_price())
}
fn reset(&mut self) {
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"AVGPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use approx::assert_relative_eq;
#[test]
fn averages_the_four_prices() {
// (open + high + low + close) / 4 = (10 + 14 + 6 + 12) / 4 = 10.5.
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let mut ap = AvgPrice::new();
assert!(!ap.is_ready());
assert_relative_eq!(ap.update(candle).unwrap(), 10.5, epsilon = 1e-12);
assert!(ap.is_ready());
}
#[test]
fn accessors_and_reset() {
let mut ap = AvgPrice::new();
assert_eq!(ap.name(), "AVGPRICE");
assert_eq!(ap.warmup_period(), 1);
let candle = Candle::new(10.0, 14.0, 6.0, 12.0, 1.0, 0).unwrap();
let _ = ap.update(candle);
assert!(ap.is_ready());
ap.reset();
assert!(!ap.is_ready());
}
}
+232
View File
@@ -0,0 +1,232 @@
//! Directional Movement Index (DX), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Directional Movement Index (`DX`).
///
/// `DX = 100 · |+DI DI| / (+DI + DI)`, the un-smoothed precursor to
/// [`Adx`](crate::Adx) (which is the Wilder average of `DX`). Both directional
/// indicators are derived from Wilder-smoothed `+DM`, `DM` and true range over
/// `period` bars, so the first value is emitted after `period + 1` candles.
///
/// `DX` ranges over `[0, 100]`: high when one side of the directional system
/// clearly dominates (a strong trend) and near zero when `+DI` and `DI` are
/// balanced (a range). When both directional indicators are zero — a perfectly
/// flat market — the index returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, Dx};
///
/// let mut indicator = Dx::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Dx {
period: usize,
prev: Option<Candle>,
plus_dm_seed: f64,
minus_dm_seed: f64,
tr_seed: f64,
seed_count: usize,
plus_dm_smooth: Option<f64>,
minus_dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl Dx {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
plus_dm_seed: 0.0,
minus_dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
plus_dm_smooth: None,
minus_dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Dx {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, minus_dm) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (plus_v, minus_v, tr_v) = if let (Some(p), Some(m), Some(t)) =
(self.plus_dm_smooth, self.minus_dm_smooth, self.tr_smooth)
{
let p_new = p - p / n + plus_dm;
let m_new = m - m / n + minus_dm;
let t_new = t - t / n + tr;
self.plus_dm_smooth = Some(p_new);
self.minus_dm_smooth = Some(m_new);
self.tr_smooth = Some(t_new);
(p_new, m_new, t_new)
} else {
self.plus_dm_seed += plus_dm;
self.minus_dm_seed += minus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.plus_dm_smooth = Some(self.plus_dm_seed);
self.minus_dm_smooth = Some(self.minus_dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.plus_dm_seed, self.minus_dm_seed, self.tr_seed)
};
let (plus_di, minus_di) = if tr_v == 0.0 {
(0.0, 0.0)
} else {
(100.0 * plus_v / tr_v, 100.0 * minus_v / tr_v)
};
let di_sum = plus_di + minus_di;
let dx = if di_sum == 0.0 {
0.0
} else {
100.0 * (plus_di - minus_di).abs() / di_sum
};
Some(dx)
}
fn reset(&mut self) {
self.prev = None;
self.plus_dm_seed = 0.0;
self.minus_dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.plus_dm_smooth = None;
self.minus_dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.tr_smooth.is_some()
}
fn name(&self) -> &'static str {
"DX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(Dx::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dx = Dx::new(7).unwrap();
assert_eq!(dx.period(), 7);
assert_eq!(dx.name(), "DX");
assert_eq!(dx.warmup_period(), 7);
assert!(!dx.is_ready());
}
#[test]
fn strong_trend_drives_dx_high() {
// A clean uptrend has one-sided directional movement, so DX is large.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let out: Vec<Option<f64>> = dx.batch(&candles);
assert_eq!(out[0], None);
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 50.0 && last <= 100.0);
assert!(dx.is_ready());
}
#[test]
fn flat_market_returns_zero() {
// Both directional indicators collapse to zero -> DX is zero.
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut dx = Dx::new(3).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn balanced_directional_movement_is_low() {
// Alternating up and down bars of equal magnitude keep +DI and -DI close,
// so DX stays well below a trending reading.
let candles: Vec<Candle> = (0..30)
.map(|i| {
let base = if i % 2 == 0 { 100.0 } else { 101.0 };
c(base + 1.0, base - 1.0, base)
})
.collect();
let mut dx = Dx::new(5).unwrap();
let last = dx.batch(&candles).into_iter().flatten().last().unwrap();
assert!((0.0..=100.0).contains(&last));
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut dx = Dx::new(3).unwrap();
let _ = dx.batch(&candles);
assert!(dx.is_ready());
dx.reset();
assert!(!dx.is_ready());
assert_eq!(dx.update(candles[0]), None);
}
}
@@ -0,0 +1,278 @@
//! Ehlers Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Dominant Cycle Phase (`HT_DCPHASE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) to recover the dominant
/// cycle period, then measures the **phase angle** of that cycle (in degrees) by
/// correlating the smoothed price over one dominant-cycle window against a unit
/// phasor. The phase advances roughly linearly through a clean cycle and stalls
/// in a trend, which is the basis of Ehlers' trend-versus-cycle detection.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_DCPHASE`. The first value is emitted after ~50 inputs, once the engine's
/// moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtDcPhase};
///
/// let mut ht = HtDcPhase::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtDcPhase {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
// Longer history of the 4-bar smoothed price, used to integrate the phase
// over one dominant-cycle window (up to 50 bars).
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
count: usize,
last_value: Option<f64>,
}
impl HtDcPhase {
/// Construct a new Hilbert transform dominant-cycle phase estimator.
pub fn new() -> Self {
Self::default()
}
/// Current dominant-cycle phase (degrees) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtDcPhase {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
if self.count < 50 {
return None;
}
// Integrate the smoothed price over one dominant-cycle window against a
// unit phasor to recover the instantaneous dominant-cycle phase.
let smooth_period = self.prev_smooth_period;
let dc_period = (smooth_period + 0.5) as usize;
let dc_period = dc_period.clamp(1, self.smooth_price.len());
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
// Compensate the group delay of the 4-bar weighted smoother.
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
self.last_value = Some(dc_phase);
Some(dc_phase)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_DCPHASE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtDcPhase::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_DCPHASE");
assert!(!ht.is_ready());
}
#[test]
fn emits_after_warmup_within_phase_band() {
let mut ht = HtDcPhase::new();
let out: Vec<Option<f64>> = ht.batch(&sine_prices(200));
assert_eq!(out[0], None);
assert!(ht.is_ready());
for v in out.into_iter().flatten() {
assert!(v.is_finite(), "phase must be finite");
assert!((-360.0..=360.0).contains(&v), "phase {v} outside band");
}
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(200);
let mut a = HtDcPhase::new();
let mut b = HtDcPhase::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtDcPhase::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,240 @@
//! Ehlers Hilbert Transform Phasor components (`HT_PHASOR`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// In-phase and quadrature components of the Hilbert transform phasor.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HtPhasorOutput {
/// In-phase component (`I1`).
pub inphase: f64,
/// Quadrature component (`Q1`).
pub quadrature: f64,
}
/// Ehlers' Hilbert Transform Phasor (`HT_PHASOR`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle) but reports the raw
/// in-phase (`I1`) and quadrature (`Q1`) components of the analytic signal rather
/// than the recovered cycle period. The two components are 90° out of phase, so
/// their ratio tracks the instantaneous phase of the dominant cycle.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_PHASOR`. The first value is emitted once the transform's tap buffers fill.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtPhasor};
///
/// let mut ht = HtPhasor::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + (f64::from(i) * 0.4).sin() * 5.0);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtPhasor {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
ready: bool,
}
impl HtPhasor {
/// Construct a new Hilbert transform phasor.
pub fn new() -> Self {
Self::default()
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtPhasor {
type Input = f64;
type Output = HtPhasorOutput;
fn update(&mut self, input: f64) -> Option<HtPhasorOutput> {
if !input.is_finite() {
return None;
}
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
// Continue the dominant-cycle period adaptation so the next bar's `adj`
// coefficient tracks the cycle, exactly as TA-Lib's HT_PHASOR does.
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.ready = true;
Some(HtPhasorOutput {
inphase: i1,
quadrature: q1,
})
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.ready = false;
}
fn warmup_period(&self) -> usize {
19
}
fn is_ready(&self) -> bool {
self.ready
}
fn name(&self) -> &'static str {
"HT_PHASOR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn sine_prices(n: usize) -> Vec<f64> {
(0..n)
.map(|i| 100.0 + (i as f64 * 0.4).sin() * 5.0)
.collect()
}
#[test]
fn accessors_and_metadata() {
let ht = HtPhasor::new();
assert_eq!(ht.warmup_period(), 19);
assert_eq!(ht.name(), "HT_PHASOR");
assert!(!ht.is_ready());
}
#[test]
fn emits_after_warmup_and_stays_finite() {
let mut ht = HtPhasor::new();
let out: Vec<Option<HtPhasorOutput>> = ht.batch(&sine_prices(120));
assert_eq!(out[0], None);
let first = out.iter().position(Option::is_some).expect("emits");
assert!(first <= 19, "first phasor at index {first}");
for o in out.into_iter().flatten() {
assert!(o.inphase.is_finite() && o.quadrature.is_finite());
}
assert!(ht.is_ready());
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
// A non-finite input is skipped and produces no value.
assert_eq!(ht.update(f64::NAN), None);
}
#[test]
fn batch_equals_streaming() {
let prices = sine_prices(150);
let mut a = HtPhasor::new();
let mut b = HtPhasor::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtPhasor::new();
let _ = ht.batch(&sine_prices(120));
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,354 @@
//! Ehlers Hilbert Transform Trend vs Cycle Mode (`HT_TRENDMODE`).
#![allow(clippy::manual_clamp)]
use std::f64::consts::PI;
use crate::traits::Indicator;
/// Ehlers' Hilbert Transform Trend Mode (`HT_TRENDMODE`).
///
/// Runs the same adaptive Hilbert-transform engine as
/// [`HilbertDominantCycle`](crate::HilbertDominantCycle), derives the dominant
/// cycle phase, its sine / lead-sine, and an instantaneous trendline, then
/// classifies the market into **trend mode (`1`)** or **cycle mode (`0`)**:
///
/// - it is a *cycle* shortly after the sine and lead-sine cross, while the phase
/// advances at roughly the dominant-cycle rate;
/// - it is a *trend* otherwise, and is forced to trend whenever price separates
/// from the trendline by more than 1.5%.
///
/// From *Rocket Science for Traders* (Ehlers 2001), aligned with TA-Lib's
/// `HT_TRENDMODE`. The output is `1.0` or `0.0`; the first value is emitted after
/// ~50 inputs once the engine's moving-average chain has filled.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, HtTrendMode};
///
/// let mut ht = HtTrendMode::new();
/// let mut last = None;
/// for i in 0..120 {
/// last = ht.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone, Default)]
pub struct HtTrendMode {
smooth_buf: Vec<f64>,
detrender_buf: Vec<f64>,
q1_buf: Vec<f64>,
i1_buf: Vec<f64>,
smooth_price: Vec<f64>,
prev_i2: f64,
prev_q2: f64,
prev_re: f64,
prev_im: f64,
prev_period: f64,
prev_smooth_period: f64,
// Trend-mode state.
prev_dc_phase: f64,
prev_sine: f64,
prev_lead_sine: f64,
days_in_trend: f64,
it1: f64,
it2: f64,
it3: f64,
count: usize,
last_value: Option<f64>,
}
impl HtTrendMode {
/// Construct a new Hilbert transform trend-mode classifier.
pub fn new() -> Self {
Self::default()
}
/// Current trend-mode flag (`1.0` trend, `0.0` cycle) if available.
pub const fn value(&self) -> Option<f64> {
self.last_value
}
fn push_front(buf: &mut Vec<f64>, v: f64, cap: usize) {
buf.insert(0, v);
if buf.len() > cap {
buf.truncate(cap);
}
}
}
impl Indicator for HtTrendMode {
type Input = f64;
type Output = f64;
#[allow(clippy::too_many_lines)]
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last_value;
}
self.count += 1;
Self::push_front(&mut self.smooth_buf, input, 7);
if self.smooth_buf.len() < 7 {
return None;
}
let smooth = (4.0 * self.smooth_buf[0]
+ 3.0 * self.smooth_buf[1]
+ 2.0 * self.smooth_buf[2]
+ self.smooth_buf[3])
/ 10.0;
Self::push_front(&mut self.smooth_price, smooth, 50);
let period = self.prev_period.max(6.0).min(50.0);
let adj = 0.075 * period + 0.54;
let s0 = smooth;
let s2 = self.smooth_buf[2];
let s4 = self.smooth_buf[4];
let s6 = self.smooth_buf[6];
let detrender = (0.0962 * s0 + 0.5769 * s2 - 0.5769 * s4 - 0.0962 * s6) * adj;
Self::push_front(&mut self.detrender_buf, detrender, 7);
if self.detrender_buf.len() < 7 {
return None;
}
let q1 = (0.0962 * self.detrender_buf[0] + 0.5769 * self.detrender_buf[2]
- 0.5769 * self.detrender_buf[4]
- 0.0962 * self.detrender_buf[6])
* adj;
let i1 = self.detrender_buf[3];
Self::push_front(&mut self.q1_buf, q1, 7);
Self::push_front(&mut self.i1_buf, i1, 7);
if self.q1_buf.len() < 7 || self.i1_buf.len() < 7 {
return None;
}
let ji = (0.0962 * self.i1_buf[0] + 0.5769 * self.i1_buf[2]
- 0.5769 * self.i1_buf[4]
- 0.0962 * self.i1_buf[6])
* adj;
let jq = (0.0962 * self.q1_buf[0] + 0.5769 * self.q1_buf[2]
- 0.5769 * self.q1_buf[4]
- 0.0962 * self.q1_buf[6])
* adj;
let mut i2 = i1 - jq;
let mut q2 = q1 + ji;
i2 = 0.2 * i2 + 0.8 * self.prev_i2;
q2 = 0.2 * q2 + 0.8 * self.prev_q2;
let mut re = i2 * self.prev_i2 + q2 * self.prev_q2;
let mut im = i2 * self.prev_q2 - q2 * self.prev_i2;
re = 0.2 * re + 0.8 * self.prev_re;
im = 0.2 * im + 0.8 * self.prev_im;
self.prev_i2 = i2;
self.prev_q2 = q2;
self.prev_re = re;
self.prev_im = im;
let mut new_period = if im.abs() > f64::EPSILON && re.abs() > f64::EPSILON {
2.0 * PI / im.atan2(re)
} else {
self.prev_period
};
new_period = new_period.min(1.5 * self.prev_period);
new_period = new_period.max(0.67 * self.prev_period);
new_period = new_period.clamp(6.0, 50.0);
self.prev_period = 0.2 * new_period + 0.8 * self.prev_period;
self.prev_smooth_period = 0.33 * self.prev_period + 0.67 * self.prev_smooth_period;
let smooth_period = self.prev_smooth_period;
let dc_period = ((smooth_period + 0.5) as usize).clamp(1, self.smooth_price.len());
// Dominant-cycle phase over one cycle window.
let mut real_part = 0.0;
let mut imag_part = 0.0;
for i in 0..dc_period {
let angle = (i as f64) * 2.0 * PI / (dc_period as f64);
let sp = self.smooth_price[i];
real_part += angle.sin() * sp;
imag_part += angle.cos() * sp;
}
let mut dc_phase = if imag_part.abs() > 0.001 {
(real_part / imag_part).atan().to_degrees()
} else if real_part < 0.0 {
-90.0
} else {
90.0
};
dc_phase += 90.0;
dc_phase += 360.0 / smooth_period;
if imag_part < 0.0 {
dc_phase += 180.0;
}
if dc_phase > 315.0 {
dc_phase -= 360.0;
}
let sine = (dc_phase * PI / 180.0).sin();
let lead_sine = ((dc_phase + 45.0) * PI / 180.0).sin();
// Instantaneous trendline: average smoothed price over the cycle window,
// then a 4-3-2-1 weighted smoothing of that running average.
let mut trend_sum = 0.0;
for i in 0..dc_period {
trend_sum += self.smooth_price[i];
}
trend_sum /= dc_period as f64;
let trendline = (4.0 * trend_sum + 3.0 * self.it1 + 2.0 * self.it2 + self.it3) / 10.0;
self.it3 = self.it2;
self.it2 = self.it1;
self.it1 = trend_sum;
// Trend / cycle decision (assume trend, override to cycle).
let mut trend = 1.0_f64;
// A crossing of sine and lead-sine restarts the cycle clock.
if (sine > lead_sine && self.prev_sine <= self.prev_lead_sine)
|| (sine < lead_sine && self.prev_sine >= self.prev_lead_sine)
{
self.days_in_trend = 0.0;
trend = 0.0;
}
self.days_in_trend += 1.0;
if self.days_in_trend < 0.5 * smooth_period {
trend = 0.0;
}
// Cycle mode while the phase advances at roughly the dominant-cycle rate.
let delta_phase = dc_phase - self.prev_dc_phase;
if smooth_period != 0.0
&& delta_phase > 0.67 * 360.0 / smooth_period
&& delta_phase < 1.5 * 360.0 / smooth_period
{
trend = 0.0;
}
// Force trend mode when price separates from the trendline.
if trendline != 0.0 && ((smooth - trendline) / trendline).abs() >= 0.015 {
trend = 1.0;
}
self.prev_dc_phase = dc_phase;
self.prev_sine = sine;
self.prev_lead_sine = lead_sine;
if self.count < 50 {
return None;
}
self.last_value = Some(trend);
Some(trend)
}
fn reset(&mut self) {
self.smooth_buf.clear();
self.detrender_buf.clear();
self.q1_buf.clear();
self.i1_buf.clear();
self.smooth_price.clear();
self.prev_i2 = 0.0;
self.prev_q2 = 0.0;
self.prev_re = 0.0;
self.prev_im = 0.0;
self.prev_period = 0.0;
self.prev_smooth_period = 0.0;
self.prev_dc_phase = 0.0;
self.prev_sine = 0.0;
self.prev_lead_sine = 0.0;
self.days_in_trend = 0.0;
self.it1 = 0.0;
self.it2 = 0.0;
self.it3 = 0.0;
self.count = 0;
self.last_value = None;
}
fn warmup_period(&self) -> usize {
50
}
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
fn name(&self) -> &'static str {
"HT_TRENDMODE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
/// A trending ramp followed by a clean cycle, so both modes are exercised.
fn mixed_prices() -> Vec<f64> {
let mut v = Vec::new();
for i in 0..150 {
v.push(100.0 + f64::from(i) * 0.8);
}
for i in 0..200 {
v.push(220.0 + (f64::from(i) * 0.45).sin() * 12.0);
}
v
}
#[test]
fn accessors_and_metadata() {
let ht = HtTrendMode::new();
assert_eq!(ht.warmup_period(), 50);
assert_eq!(ht.name(), "HT_TRENDMODE");
assert!(!ht.is_ready());
assert!(ht.value().is_none());
}
#[test]
fn emits_binary_flag_and_visits_both_modes() {
let mut ht = HtTrendMode::new();
let out: Vec<Option<f64>> = ht.batch(&mixed_prices());
assert_eq!(out[0], None);
assert!(ht.is_ready());
let mut saw_trend = false;
let mut saw_cycle = false;
for v in out.into_iter().flatten() {
assert!(v == 0.0 || v == 1.0, "trend mode must be binary, got {v}");
if v == 1.0 {
saw_trend = true;
} else {
saw_cycle = true;
}
}
assert!(saw_trend, "ramp segment should report trend mode");
assert!(saw_cycle, "cycle segment should report cycle mode");
}
#[test]
fn ignores_non_finite_input() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
let before = ht.value();
assert_eq!(ht.update(f64::NAN), before);
}
#[test]
fn batch_equals_streaming() {
let prices = mixed_prices();
let mut a = HtTrendMode::new();
let mut b = HtTrendMode::new();
let batch = a.batch(&prices);
let streamed: Vec<_> = prices.iter().map(|p| b.update(*p)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn reset_clears_state() {
let mut ht = HtTrendMode::new();
let _ = ht.batch(&mixed_prices());
assert!(ht.is_ready());
ht.reset();
assert!(!ht.is_ready());
assert_eq!(ht.update(100.0), None);
}
}
@@ -0,0 +1,170 @@
//! Linear Regression Intercept (`LINEARREG_INTERCEPT`).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Linear Regression Intercept (`LINEARREG_INTERCEPT`): the intercept `a` of the
/// rolling least-squares fit `y = a + b·x` over the last `period` inputs, indexed
/// `x = 0, 1, …, period 1`.
///
/// ```text
/// b (slope) = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// a (intercept) = (Σy b·Σx) / n
/// ```
///
/// Where [`LinearRegression`](crate::LinearRegression) reports the fitted line at
/// the most recent bar (`a + b·(period 1)`), this reports its value at the
/// *start* of the window (`x = 0`). Each update is O(1), maintaining the same
/// closed-form sliding-window sums as `LinearRegression`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, LinRegIntercept};
///
/// let mut indicator = LinRegIntercept::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct LinRegIntercept {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
denom: f64,
sum_y: f64,
sum_xy: f64,
}
impl LinRegIntercept {
/// Construct a new rolling linear-regression intercept over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "linear regression intercept needs period >= 2",
});
}
let n = period as f64;
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for LinRegIntercept {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
let intercept = (self.sum_y - slope * self.sum_x) / n;
Some(intercept)
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"LINEARREG_INTERCEPT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_short_period() {
assert!(matches!(
LinRegIntercept::new(1),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_report_config() {
let lr = LinRegIntercept::new(5).unwrap();
assert_eq!(lr.period(), 5);
assert_eq!(lr.name(), "LINEARREG_INTERCEPT");
assert_eq!(lr.warmup_period(), 5);
assert!(!lr.is_ready());
}
#[test]
fn reference_value() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, intercept = 0.
let mut lr = LinRegIntercept::new(3).unwrap();
let out: Vec<Option<f64>> = lr.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-9);
assert!(lr.is_ready());
}
#[test]
fn slides_and_tracks_a_shifted_line() {
// After sliding to window [2, 9, 4]... intercept stays finite and the
// fit is exact for a clean line [10, 12, 14]: y = 10 + 2x, intercept 10.
let mut lr = LinRegIntercept::new(3).unwrap();
let out: Vec<Option<f64>> = lr.batch(&[1.0, 10.0, 12.0, 14.0]);
assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut lr = LinRegIntercept::new(3).unwrap();
let _ = lr.batch(&[1.0, 2.0, 9.0]);
assert!(lr.is_ready());
lr.reset();
assert!(!lr.is_ready());
assert_eq!(lr.update(1.0), None);
}
}
@@ -0,0 +1,286 @@
//! MACD with selectable moving-average types (MACDEXT).
use crate::error::{Error, Result};
use crate::indicators::dema::Dema;
use crate::indicators::ema::Ema;
use crate::indicators::macd::MacdOutput;
use crate::indicators::sma::Sma;
use crate::indicators::tema::Tema;
use crate::indicators::trima::Trima;
use crate::indicators::wma::Wma;
use crate::traits::Indicator;
/// Moving-average type selector for [`MacdExt`] and other multi-MA indicators.
///
/// The variants map to TA-Lib's `MA_Type` codes `0..=5` — the period-only
/// moving averages. (TA-Lib's KAMA / MAMA / T3 take additional shape parameters
/// and are not selectable here.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaType {
/// Simple moving average (TA-Lib code `0`).
Sma,
/// Exponential moving average (TA-Lib code `1`).
Ema,
/// Weighted moving average (TA-Lib code `2`).
Wma,
/// Double exponential moving average (TA-Lib code `3`).
Dema,
/// Triple exponential moving average (TA-Lib code `4`).
Tema,
/// Triangular moving average (TA-Lib code `5`).
Trima,
}
impl MaType {
/// Map a TA-Lib `MA_Type` integer code (`0..=5`) to a [`MaType`].
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] for codes outside `0..=5` (the period-only
/// moving averages); codes `6..=8` (KAMA / MAMA / T3) are not supported.
pub fn from_code(code: u32) -> Result<Self> {
match code {
0 => Ok(Self::Sma),
1 => Ok(Self::Ema),
2 => Ok(Self::Wma),
3 => Ok(Self::Dema),
4 => Ok(Self::Tema),
5 => Ok(Self::Trima),
_ => Err(Error::InvalidPeriod {
message: "unsupported moving-average type code (expected 0..=5)",
}),
}
}
}
/// A concrete period-only moving average instance, dispatched by [`MaType`].
#[derive(Debug, Clone)]
enum Ma {
Sma(Sma),
Ema(Ema),
Wma(Wma),
Dema(Dema),
Tema(Tema),
Trima(Trima),
}
impl Ma {
fn new(kind: MaType, period: usize) -> Result<Self> {
Ok(match kind {
MaType::Sma => Self::Sma(Sma::new(period)?),
MaType::Ema => Self::Ema(Ema::new(period)?),
MaType::Wma => Self::Wma(Wma::new(period)?),
MaType::Dema => Self::Dema(Dema::new(period)?),
MaType::Tema => Self::Tema(Tema::new(period)?),
MaType::Trima => Self::Trima(Trima::new(period)?),
})
}
fn update(&mut self, value: f64) -> Option<f64> {
match self {
Self::Sma(m) => m.update(value),
Self::Ema(m) => m.update(value),
Self::Wma(m) => m.update(value),
Self::Dema(m) => m.update(value),
Self::Tema(m) => m.update(value),
Self::Trima(m) => m.update(value),
}
}
fn reset(&mut self) {
match self {
Self::Sma(m) => m.reset(),
Self::Ema(m) => m.reset(),
Self::Wma(m) => m.reset(),
Self::Dema(m) => m.reset(),
Self::Tema(m) => m.reset(),
Self::Trima(m) => m.reset(),
}
}
fn warmup_period(&self) -> usize {
match self {
Self::Sma(m) => m.warmup_period(),
Self::Ema(m) => m.warmup_period(),
Self::Wma(m) => m.warmup_period(),
Self::Dema(m) => m.warmup_period(),
Self::Tema(m) => m.warmup_period(),
Self::Trima(m) => m.warmup_period(),
}
}
}
/// MACD Extended (`MACDEXT`): MACD with an independently selectable
/// [`MaType`] for each of the fast, slow and signal lines.
///
/// Classic [`MacdIndicator`](crate::MacdIndicator) hard-wires the exponential
/// moving average everywhere; `MACDEXT` lets each line use any period-only
/// moving average. The MACD line is `fast_ma(price) slow_ma(price)`, the signal
/// line is `signal_ma(macd)`, and the histogram is `macd signal`. The first
/// full [`MacdOutput`] is emitted once the slow and signal averages are both warm.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdExt, MaType};
///
/// let mut indicator =
/// MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap();
/// let mut last = None;
/// for i in 0..120 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MacdExt {
fast: Ma,
slow: Ma,
signal: Ma,
has_emitted: bool,
}
impl MacdExt {
/// Construct a MACDEXT with per-line periods and moving-average types.
///
/// # Errors
/// Returns [`Error::PeriodZero`] if any period is zero and
/// [`Error::InvalidPeriod`] if `fast >= slow`, propagating any moving-average
/// construction error.
pub fn new(
fast: usize,
fast_type: MaType,
slow: usize,
slow_type: MaType,
signal: usize,
signal_type: MaType,
) -> Result<Self> {
if fast == 0 || slow == 0 || signal == 0 {
return Err(Error::PeriodZero);
}
if fast >= slow {
return Err(Error::InvalidPeriod {
message: "fast period must be < slow period",
});
}
Ok(Self {
fast: Ma::new(fast_type, fast)?,
slow: Ma::new(slow_type, slow)?,
signal: Ma::new(signal_type, signal)?,
has_emitted: false,
})
}
}
impl Indicator for MacdExt {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, value: f64) -> Option<MacdOutput> {
let fast_v = self.fast.update(value);
let slow_v = self.slow.update(value);
let (Some(fast_v), Some(slow_v)) = (fast_v, slow_v) else {
return None;
};
let macd = fast_v - slow_v;
let signal = self.signal.update(macd)?;
self.has_emitted = true;
Some(MacdOutput {
macd,
signal,
histogram: macd - signal,
})
}
fn reset(&mut self) {
self.fast.reset();
self.slow.reset();
self.signal.reset();
self.has_emitted = false;
}
fn warmup_period(&self) -> usize {
self.slow.warmup_period() + self.signal.warmup_period()
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"MACDEXT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
const TYPES: [MaType; 6] = [
MaType::Sma,
MaType::Ema,
MaType::Wma,
MaType::Dema,
MaType::Tema,
MaType::Trima,
];
#[test]
fn from_code_maps_all_supported_types() {
assert_eq!(MaType::from_code(0).unwrap(), MaType::Sma);
assert_eq!(MaType::from_code(1).unwrap(), MaType::Ema);
assert_eq!(MaType::from_code(2).unwrap(), MaType::Wma);
assert_eq!(MaType::from_code(3).unwrap(), MaType::Dema);
assert_eq!(MaType::from_code(4).unwrap(), MaType::Tema);
assert_eq!(MaType::from_code(5).unwrap(), MaType::Trima);
assert!(MaType::from_code(6).is_err());
}
#[test]
fn rejects_invalid_periods() {
assert!(matches!(
MacdExt::new(0, MaType::Ema, 26, MaType::Ema, 9, MaType::Ema),
Err(Error::PeriodZero)
));
assert!(matches!(
MacdExt::new(26, MaType::Ema, 12, MaType::Ema, 9, MaType::Ema),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let m = MacdExt::new(12, MaType::Ema, 26, MaType::Sma, 9, MaType::Sma).unwrap();
assert_eq!(m.name(), "MACDEXT");
assert!(!m.is_ready());
assert!(m.warmup_period() >= 26);
}
#[test]
fn every_ma_type_produces_a_consistent_histogram() {
let prices: Vec<f64> = (0..120)
.map(|i| 100.0 + (f64::from(i) * 0.2).sin() * 6.0)
.collect();
for &t in &TYPES {
let mut m = MacdExt::new(5, t, 10, t, 4, t).unwrap();
let out: Vec<Option<MacdOutput>> = m.batch(&prices);
assert!(out.iter().any(Option::is_some), "{t:?} never emitted");
for o in out.into_iter().flatten() {
assert!((o.histogram - (o.macd - o.signal)).abs() < 1e-9);
}
// Exercise the warmup accessor for this variant's inner averages.
assert!(m.warmup_period() >= 10);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
}
}
#[test]
fn mixed_ma_types_per_line() {
let prices: Vec<f64> = (0..120).map(|i| 100.0 + f64::from(i)).collect();
let mut m = MacdExt::new(12, MaType::Wma, 26, MaType::Dema, 9, MaType::Trima).unwrap();
let last = m.batch(&prices).into_iter().flatten().last();
assert!(last.is_some());
}
}
@@ -0,0 +1,117 @@
//! MACD with fixed 12/26 periods (MACDFIX).
use crate::error::Result;
use crate::indicators::macd::{MacdIndicator, MacdOutput};
use crate::traits::Indicator;
/// MACD Fix (`MACDFIX`): the classic MACD with the fast and slow EMAs fixed at
/// 12 and 26, leaving only the signal period configurable.
///
/// This is TA-Lib's `MACDFIX` — identical output to
/// [`MacdIndicator::new(12, 26, signal)`](crate::MacdIndicator), packaged as a
/// single-parameter constructor for the common case. The output is the usual
/// [`MacdOutput`] triple `{ macd, signal, histogram }`.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MacdFix};
///
/// let mut indicator = MacdFix::new(9).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MacdFix {
inner: MacdIndicator,
}
impl MacdFix {
/// Construct a MACDFIX with fast = 12, slow = 26 and the given signal period.
///
/// # Errors
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `signal == 0`.
pub fn new(signal: usize) -> Result<Self> {
Ok(Self {
inner: MacdIndicator::new(12, 26, signal)?,
})
}
/// Configured signal period.
pub fn signal_period(&self) -> usize {
self.inner.periods().2
}
}
impl Indicator for MacdFix {
type Input = f64;
type Output = MacdOutput;
fn update(&mut self, value: f64) -> Option<MacdOutput> {
self.inner.update(value)
}
fn reset(&mut self) {
self.inner.reset();
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn name(&self) -> &'static str {
"MACDFIX"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
#[test]
fn rejects_zero_signal() {
assert!(MacdFix::new(0).is_err());
}
#[test]
fn accessors_report_config() {
let m = MacdFix::new(9).unwrap();
assert_eq!(m.signal_period(), 9);
assert_eq!(m.name(), "MACDFIX");
assert!(!m.is_ready());
assert_eq!(
m.warmup_period(),
MacdIndicator::new(12, 26, 9).unwrap().warmup_period()
);
}
#[test]
fn matches_macd_with_fixed_periods() {
let prices: Vec<f64> = (0..80)
.map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0)
.collect();
let fix: Vec<Option<MacdOutput>> = MacdFix::new(9).unwrap().batch(&prices);
let classic: Vec<Option<MacdOutput>> =
MacdIndicator::new(12, 26, 9).unwrap().batch(&prices);
assert_eq!(fix, classic);
assert!(fix.iter().any(Option::is_some));
}
#[test]
fn reset_clears_state() {
let prices: Vec<f64> = (0..80).map(|i| 100.0 + f64::from(i)).collect();
let mut m = MacdFix::new(9).unwrap();
let _ = m.batch(&prices);
assert!(m.is_ready());
m.reset();
assert!(!m.is_ready());
}
}
@@ -0,0 +1,144 @@
//! Midpoint (MIDPOINT) over a rolling window of a scalar series.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Midpoint (`MIDPOINT`): the average of the highest and lowest value of the
/// input series over the last `period` points.
///
/// ```text
/// MIDPOINT = (highest(value, period) + lowest(value, period)) / 2
/// ```
///
/// Where [`MidPrice`](crate::MidPrice) takes the window extremes from a candle's
/// high/low, `MIDPOINT` works on a single scalar stream (typically the close),
/// taking the max and min of that stream over the window. The first value is
/// emitted once `period` points have been seen.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, MidPoint};
///
/// let mut indicator = MidPoint::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MidPoint {
period: usize,
window: VecDeque<f64>,
}
impl MidPoint {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
window: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MidPoint {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(value);
if self.window.len() < self.period {
return None;
}
let highest = self
.window
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self.window.iter().copied().fold(f64::INFINITY, f64::min);
Some(f64::midpoint(highest, lowest))
}
fn reset(&mut self) {
self.window.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"MIDPOINT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(MidPoint::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let mp = MidPoint::new(7).unwrap();
assert_eq!(mp.period(), 7);
assert_eq!(mp.name(), "MIDPOINT");
assert_eq!(mp.warmup_period(), 7);
assert!(!mp.is_ready());
}
#[test]
fn averages_window_min_and_max() {
// Window {8, 12, 10}: highest 12, lowest 8 -> 10.
let mut mp = MidPoint::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&[8.0, 12.0, 10.0]);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 10.0, epsilon = 1e-12);
assert!(mp.is_ready());
}
#[test]
fn window_slides_and_drops_old_values() {
// After the 30 spike leaves the window, the midpoint falls back.
let mut mp = MidPoint::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&[30.0, 8.0, 12.0, 10.0]);
// Last window {8, 12, 10}: (12 + 8) / 2 = 10.
assert_relative_eq!(out[3].unwrap(), 10.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let mut mp = MidPoint::new(3).unwrap();
let _ = mp.batch(&[8.0, 12.0, 10.0]);
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
assert_eq!(mp.update(8.0), None);
}
}
@@ -0,0 +1,165 @@
//! Midpoint Price (MIDPRICE) over a rolling window of high/low extremes.
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Midpoint Price (`MIDPRICE`): the average of the highest high and the lowest
/// low over the last `period` candles.
///
/// ```text
/// MIDPRICE = (highest(high, period) + lowest(low, period)) / 2
/// ```
///
/// Unlike [`MedianPrice`](crate::MedianPrice), which averages a single bar's own
/// high and low, `MIDPRICE` averages the *window* extremes — it is numerically
/// the centre line of [`Donchian`](crate::Donchian) channels, exposed as a
/// standalone scalar for TA-Lib parity. The first value is emitted once `period`
/// candles have been seen.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MidPrice};
///
/// let mut indicator = MidPrice::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MidPrice {
period: usize,
candles: VecDeque<Candle>,
}
impl MidPrice {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
candles: VecDeque::with_capacity(period),
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MidPrice {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.candles.len() == self.period {
self.candles.pop_front();
}
self.candles.push_back(candle);
if self.candles.len() < self.period {
return None;
}
let highest = self
.candles
.iter()
.map(|c| c.high)
.fold(f64::NEG_INFINITY, f64::max);
let lowest = self
.candles
.iter()
.map(|c| c.low)
.fold(f64::INFINITY, f64::min);
Some(f64::midpoint(highest, lowest))
}
fn reset(&mut self) {
self.candles.clear();
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.candles.len() == self.period
}
fn name(&self) -> &'static str {
"MIDPRICE"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MidPrice::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let mp = MidPrice::new(7).unwrap();
assert_eq!(mp.period(), 7);
assert_eq!(mp.name(), "MIDPRICE");
assert_eq!(mp.warmup_period(), 7);
assert!(!mp.is_ready());
}
#[test]
fn averages_window_extremes() {
// Window highs {12, 14, 16}, lows {8, 9, 10}: highest 16, lowest 8 -> 12.
let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
let mut mp = MidPrice::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&candles);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-12);
assert!(mp.is_ready());
}
#[test]
fn window_slides_and_drops_old_extremes() {
// After the spike leaves the window the midpoint falls back.
let candles = [
c(30.0, 10.0, 20.0),
c(12.0, 8.0, 10.0),
c(14.0, 9.0, 11.0),
c(16.0, 10.0, 12.0),
];
let mut mp = MidPrice::new(3).unwrap();
let out: Vec<Option<f64>> = mp.batch(&candles);
// Last window {12,14,16}/{8,9,10}: (16 + 8) / 2 = 12.
assert_relative_eq!(out[3].unwrap(), 12.0, epsilon = 1e-12);
}
#[test]
fn reset_clears_state() {
let candles = [c(12.0, 8.0, 10.0), c(14.0, 9.0, 11.0), c(16.0, 10.0, 12.0)];
let mut mp = MidPrice::new(3).unwrap();
let _ = mp.batch(&candles);
assert!(mp.is_ready());
mp.reset();
assert!(!mp.is_ready());
assert_eq!(mp.update(candles[0]), None);
}
}
@@ -0,0 +1,199 @@
//! Minus Directional Indicator (-DI), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Minus Directional Indicator (`MINUS_DI`).
///
/// `-DI = 100 · smoothed(-DM) / smoothed(TR)`, where both the minus directional
/// movement and the true range are Wilder-smoothed over `period` bars. It is the
/// bearish half of the directional system that drives [`Adx`](crate::Adx);
/// readings above [`PlusDi`](crate::PlusDi) mark a down-trending regime.
///
/// The first `period` raw values seed the two running sums; from then on each
/// applies the Wilder recursion `smoothed smoothed / period + raw`. Because a
/// bar's directional movement and true range both need the previous bar, the
/// first value is emitted after `period + 1` candles. When the smoothed true
/// range is zero (a perfectly flat market) the indicator returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MinusDi};
///
/// let mut indicator = MinusDi::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 - f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MinusDi {
period: usize,
prev: Option<Candle>,
dm_seed: f64,
tr_seed: f64,
seed_count: usize,
dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl MinusDi {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MinusDi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (_, minus_dm) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
let d_new = d - d / n + minus_dm;
let t_new = t - t / n + tr;
self.dm_smooth = Some(d_new);
self.tr_smooth = Some(t_new);
(d_new, t_new)
} else {
self.dm_seed += minus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.dm_smooth = Some(self.dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.dm_seed, self.tr_seed)
};
let di = if tr_v == 0.0 {
0.0
} else {
100.0 * dm_v / tr_v
};
Some(di)
}
fn reset(&mut self) {
self.prev = None;
self.dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.dm_smooth.is_some()
}
fn name(&self) -> &'static str {
"MINUS_DI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MinusDi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let di = MinusDi::new(7).unwrap();
assert_eq!(di.period(), 7);
assert_eq!(di.name(), "MINUS_DI");
assert_eq!(di.warmup_period(), 7);
assert!(!di.is_ready());
}
#[test]
fn downtrend_drives_minus_di_high() {
// Strict downtrend: -DM dominates, so -DI is large and bounded by 100.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 140.0 - f64::from(i) * 2.0;
c(base + 0.5, base - 1.0, base - 0.5)
})
.collect();
let mut di = MinusDi::new(3).unwrap();
let out: Vec<Option<f64>> = di.batch(&candles);
assert_eq!(out[0], None);
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 0.0 && last <= 100.0);
assert!(di.is_ready());
}
#[test]
fn flat_market_returns_zero() {
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut di = MinusDi::new(3).unwrap();
let last = di.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 140.0 - f64::from(i) * 2.0;
c(base + 0.5, base - 1.0, base - 0.5)
})
.collect();
let mut di = MinusDi::new(3).unwrap();
let _ = di.batch(&candles);
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(candles[0]), None);
}
}
@@ -0,0 +1,195 @@
//! Minus Directional Movement (-DM), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Minus Directional Movement (`MINUS_DM`).
///
/// The raw minus directional movement of a bar is `max(low_prev low, 0)` when
/// the down-move exceeds the up-move `high high_prev`, and `0` otherwise. This
/// indicator returns the Wilder-smoothed running total of that raw `-DM` over
/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
/// [`MinusDi`](crate::MinusDi).
///
/// The first `period` raw values seed the sum; from then on each update applies
/// the Wilder recursion `smoothed smoothed / period + raw`. Because a bar's
/// directional movement needs the previous bar, the first value is emitted after
/// `period + 1` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, MinusDm};
///
/// let mut indicator = MinusDm::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 - f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base - 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct MinusDm {
period: usize,
prev: Option<Candle>,
seed: f64,
seed_count: usize,
smooth: Option<f64>,
}
impl MinusDm {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
seed: 0.0,
seed_count: 0,
smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for MinusDm {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (_, minus_dm) = directional_movement(&prev, &candle);
let n = self.period as f64;
if let Some(s) = self.smooth {
let s_new = s - s / n + minus_dm;
self.smooth = Some(s_new);
return Some(s_new);
}
self.seed += minus_dm;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.smooth = Some(self.seed);
Some(self.seed)
}
fn reset(&mut self) {
self.prev = None;
self.seed = 0.0;
self.seed_count = 0;
self.smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.smooth.is_some()
}
fn name(&self) -> &'static str {
"MINUS_DM"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle with explicit high/low; open and close are pinned to `cl`.
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(MinusDm::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dm = MinusDm::new(7).unwrap();
assert_eq!(dm.period(), 7);
assert_eq!(dm.name(), "MINUS_DM");
assert_eq!(dm.warmup_period(), 7);
assert!(!dm.is_ready());
}
#[test]
fn seeds_then_smooths_a_constant_minus_dm() {
// Low falls by 1 each bar (down = +1); high falls by 0.5 each bar, so the
// up-move is negative and -DM equals the down-move (1.0) on every bar.
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
20.0 - 0.5 * f64::from(i),
18.0 - f64::from(i),
19.0 - f64::from(i),
)
})
.collect();
let mut dm = MinusDm::new(3).unwrap();
let out: Vec<Option<f64>> = dm.batch(&candles);
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_eq!(out[2], None);
// Seed = sum of three unit -DM values.
assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
// Wilder step: 3 - 3/3 + 1 = 3.
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
assert!(dm.is_ready());
}
#[test]
fn up_moves_contribute_zero() {
// Strict uptrend: lows rise, so every raw -DM is zero.
let candles: Vec<Candle> = (0..6)
.map(|i| c(20.0 + f64::from(i), 5.0 + f64::from(i), 12.0 + f64::from(i)))
.collect();
let mut dm = MinusDm::new(3).unwrap();
let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
20.0 - 0.5 * f64::from(i),
18.0 - f64::from(i),
19.0 - f64::from(i),
)
})
.collect();
let mut dm = MinusDm::new(3).unwrap();
let _ = dm.batch(&candles);
assert!(dm.is_ready());
dm.reset();
assert!(!dm.is_ready());
assert_eq!(dm.update(candles[0]), None);
}
}
+58 -1
View File
@@ -26,6 +26,7 @@ mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
mod awesome_oscillator_histogram;
mod balance_of_power;
@@ -74,6 +75,7 @@ mod downside_gap_three_methods;
mod dpo;
mod dragonfly_doji;
mod drawdown_duration;
mod dx;
mod ease_of_movement;
mod effective_spread;
mod ehlers_stochastic;
@@ -111,6 +113,9 @@ mod hilo_activator;
mod historical_volatility;
mod hma;
mod homing_pigeon;
mod ht_dcphase;
mod ht_phasor;
mod ht_trendmode;
mod hurst_channel;
mod hurst_exponent;
mod ichimoku;
@@ -139,6 +144,7 @@ mod lead_lag_cross_correlation;
mod linreg;
mod linreg_angle;
mod linreg_channel;
mod linreg_intercept;
mod linreg_slope;
mod liquidation_features;
mod long_legged_doji;
@@ -146,6 +152,8 @@ mod long_line;
mod long_short_ratio;
mod ma_envelope;
mod macd;
mod macd_ext;
mod macd_fix;
mod mama;
mod market_facilitation_index;
mod marubozu;
@@ -158,6 +166,10 @@ mod median_absolute_deviation;
mod median_price;
mod mfi;
mod microprice;
mod mid_point;
mod mid_price;
mod minus_di;
mod minus_dm;
mod mom;
mod morning_doji_star;
mod morning_evening_star;
@@ -183,6 +195,8 @@ mod percent_b;
mod percentage_trailing_stop;
mod pgo;
mod piercing_dark_cloud;
mod plus_di;
mod plus_dm;
mod pmo;
mod point_and_figure_bars;
mod ppo;
@@ -199,12 +213,16 @@ mod renko_trailing_stop;
mod rickshaw_man;
mod rising_three_methods;
mod roc;
mod rocp;
mod rocr;
mod rocr100;
mod rogers_satchell;
mod roofing_filter;
mod rsi;
mod rvi;
mod rvi_volatility;
mod rwi;
mod sar_ext;
mod separating_lines;
mod sharpe_ratio;
mod shooting_star;
@@ -261,6 +279,7 @@ mod treynor_ratio;
mod trima;
mod trix;
mod true_range;
mod tsf;
mod tsi;
mod tsv;
mod ttm_squeeze;
@@ -321,6 +340,7 @@ pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
pub use average_drawdown::AverageDrawdown;
pub use avg_price::AvgPrice;
pub use awesome_oscillator::AwesomeOscillator;
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
pub use balance_of_power::BalanceOfPower;
@@ -369,6 +389,7 @@ pub use downside_gap_three_methods::DownsideGapThreeMethods;
pub use dpo::Dpo;
pub use dragonfly_doji::DragonflyDoji;
pub use drawdown_duration::DrawdownDuration;
pub use dx::Dx;
pub use ease_of_movement::EaseOfMovement;
pub use effective_spread::EffectiveSpread;
pub use ehlers_stochastic::EhlersStochastic;
@@ -406,6 +427,9 @@ pub use hilo_activator::HiLoActivator;
pub use historical_volatility::HistoricalVolatility;
pub use hma::Hma;
pub use homing_pigeon::HomingPigeon;
pub use ht_dcphase::HtDcPhase;
pub use ht_phasor::{HtPhasor, HtPhasorOutput};
pub use ht_trendmode::HtTrendMode;
pub use hurst_channel::{HurstChannel, HurstChannelOutput};
pub use hurst_exponent::HurstExponent;
pub use ichimoku::{Ichimoku, IchimokuOutput};
@@ -434,6 +458,7 @@ pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrel
pub use linreg::LinearRegression;
pub use linreg_angle::LinRegAngle;
pub use linreg_channel::{LinRegChannel, LinRegChannelOutput};
pub use linreg_intercept::LinRegIntercept;
pub use linreg_slope::LinRegSlope;
pub use liquidation_features::{LiquidationFeatures, LiquidationFeaturesOutput};
pub use long_legged_doji::LongLeggedDoji;
@@ -441,6 +466,8 @@ pub use long_line::LongLine;
pub use long_short_ratio::LongShortRatio;
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
pub use macd::{MacdIndicator, MacdOutput};
pub use macd_ext::{MaType, MacdExt};
pub use macd_fix::MacdFix;
pub use mama::{Mama, MamaOutput};
pub use market_facilitation_index::MarketFacilitationIndex;
pub use marubozu::Marubozu;
@@ -453,6 +480,10 @@ pub use median_absolute_deviation::MedianAbsoluteDeviation;
pub use median_price::MedianPrice;
pub use mfi::Mfi;
pub use microprice::Microprice;
pub use mid_point::MidPoint;
pub use mid_price::MidPrice;
pub use minus_di::MinusDi;
pub use minus_dm::MinusDm;
pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
@@ -478,6 +509,8 @@ pub use percent_b::PercentB;
pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use plus_di::PlusDi;
pub use plus_dm::PlusDm;
pub use pmo::Pmo;
pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use ppo::Ppo;
@@ -494,12 +527,16 @@ pub use renko_trailing_stop::RenkoTrailingStop;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
pub use roc::Roc;
pub use rocp::Rocp;
pub use rocr::Rocr;
pub use rocr100::Rocr100;
pub use rogers_satchell::RogersSatchellVolatility;
pub use roofing_filter::RoofingFilter;
pub use rsi::Rsi;
pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sar_ext::SarExt;
pub use separating_lines::SeparatingLines;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
@@ -556,6 +593,7 @@ pub use treynor_ratio::TreynorRatio;
pub use trima::Trima;
pub use trix::Trix;
pub use true_range::TrueRange;
pub use tsf::Tsf;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
@@ -649,12 +687,17 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"LaguerreRsi",
"ConnorsRsi",
"Inertia",
"Rocp",
"Rocr",
"Rocr100",
],
),
(
"Trend & Directional",
&[
"MacdIndicator",
"MacdFix",
"MacdExt",
"Adx",
"Adxr",
"Aroon",
@@ -667,6 +710,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"MassIndex",
"ChoppinessIndex",
"VerticalHorizontalFilter",
"PlusDm",
"MinusDm",
"PlusDi",
"MinusDi",
"Dx",
],
),
(
@@ -738,6 +786,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PercentageTrailingStop",
"StepTrailingStop",
"RenkoTrailingStop",
"SarExt",
],
),
(
@@ -787,6 +836,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"PearsonCorrelation",
"Beta",
"SpearmanCorrelation",
"MidPrice",
"MidPoint",
"AvgPrice",
"LinRegIntercept",
"Tsf",
],
),
(
@@ -798,6 +852,9 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"InverseFisherTransform",
"SuperSmoother",
"HilbertDominantCycle",
"HtDcPhase",
"HtPhasor",
"HtTrendMode",
"SineWave",
"Decycler",
"DecyclerOscillator",
@@ -1004,6 +1061,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, 290, "FAMILIES total drifted from indicator count");
assert_eq!(total, 309, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,201 @@
//! Plus Directional Indicator (+DI), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Plus Directional Indicator (`PLUS_DI`).
///
/// `+DI = 100 · smoothed(+DM) / smoothed(TR)`, where both the plus directional
/// movement and the true range are Wilder-smoothed over `period` bars. It is the
/// bullish half of the directional system that drives [`Adx`](crate::Adx);
/// readings above [`MinusDi`](crate::MinusDi) mark an up-trending regime.
///
/// The first `period` raw values seed the two running sums; from then on each
/// applies the Wilder recursion `smoothed smoothed / period + raw`. Because a
/// bar's directional movement and true range both need the previous bar, the
/// first value is emitted after `period + 1` candles. When the smoothed true
/// range is zero (a perfectly flat market) the indicator returns `0`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PlusDi};
///
/// let mut indicator = PlusDi::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PlusDi {
period: usize,
prev: Option<Candle>,
dm_seed: f64,
tr_seed: f64,
seed_count: usize,
dm_smooth: Option<f64>,
tr_smooth: Option<f64>,
}
impl PlusDi {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
dm_seed: 0.0,
tr_seed: 0.0,
seed_count: 0,
dm_smooth: None,
tr_smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PlusDi {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, _) = directional_movement(&prev, &candle);
let tr = candle.true_range(Some(prev.close));
let n = self.period as f64;
let (dm_v, tr_v) = if let (Some(d), Some(t)) = (self.dm_smooth, self.tr_smooth) {
let d_new = d - d / n + plus_dm;
let t_new = t - t / n + tr;
self.dm_smooth = Some(d_new);
self.tr_smooth = Some(t_new);
(d_new, t_new)
} else {
self.dm_seed += plus_dm;
self.tr_seed += tr;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.dm_smooth = Some(self.dm_seed);
self.tr_smooth = Some(self.tr_seed);
(self.dm_seed, self.tr_seed)
};
let di = if tr_v == 0.0 {
0.0
} else {
100.0 * dm_v / tr_v
};
Some(di)
}
fn reset(&mut self) {
self.prev = None;
self.dm_seed = 0.0;
self.tr_seed = 0.0;
self.seed_count = 0;
self.dm_smooth = None;
self.tr_smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.dm_smooth.is_some()
}
fn name(&self) -> &'static str {
"PLUS_DI"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(PlusDi::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let di = PlusDi::new(7).unwrap();
assert_eq!(di.period(), 7);
assert_eq!(di.name(), "PLUS_DI");
assert_eq!(di.warmup_period(), 7);
assert!(!di.is_ready());
}
#[test]
fn uptrend_drives_plus_di_high() {
// Strict uptrend: +DM dominates, so +DI is large and bounded by 100.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut di = PlusDi::new(3).unwrap();
let out: Vec<Option<f64>> = di.batch(&candles);
assert_eq!(out[0], None);
// Seeds after `period` directional moves (candle index `period`).
assert!(out[3].is_some());
let last = out.into_iter().flatten().last().unwrap();
assert!(last > 0.0 && last <= 100.0);
assert!(di.is_ready());
}
#[test]
fn flat_market_returns_zero() {
// No range and no movement: smoothed true range is zero -> +DI is zero.
let candles: Vec<Candle> = (0..6).map(|_| c(50.0, 50.0, 50.0)).collect();
let mut di = PlusDi::new(3).unwrap();
let last = di.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..6)
.map(|i| {
let base = 100.0 + f64::from(i) * 2.0;
c(base + 1.0, base - 0.5, base + 0.5)
})
.collect();
let mut di = PlusDi::new(3).unwrap();
let _ = di.batch(&candles);
assert!(di.is_ready());
di.reset();
assert!(!di.is_ready());
assert_eq!(di.update(candles[0]), None);
}
}
@@ -0,0 +1,197 @@
//! Plus Directional Movement (+DM), Wilder-smoothed.
use crate::error::{Error, Result};
use crate::indicators::adx::directional_movement;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Wilder's Plus Directional Movement (`PLUS_DM`).
///
/// The raw plus directional movement of a bar is `max(high high_prev, 0)` when
/// the up-move exceeds the down-move `low_prev low`, and `0` otherwise. This
/// indicator returns the Wilder-smoothed running total of that raw `+DM` over
/// `period` bars, the same accumulation that feeds [`Adx`](crate::Adx) and
/// [`PlusDi`](crate::PlusDi).
///
/// The first `period` raw values seed the sum; from then on each update applies
/// the Wilder recursion `smoothed smoothed / period + raw`. Because a bar's
/// directional movement needs the previous bar, the first value is emitted after
/// `period + 1` candles.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, PlusDm};
///
/// let mut indicator = PlusDm::new(5).unwrap();
/// let mut last = None;
/// for i in 0..40 {
/// let base = 100.0 + f64::from(i);
/// let candle =
/// Candle::new(base, base + 2.0, base - 2.0, base + 1.0, 10.0, i64::from(i)).unwrap();
/// last = indicator.update(candle);
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct PlusDm {
period: usize,
prev: Option<Candle>,
seed: f64,
seed_count: usize,
smooth: Option<f64>,
}
impl PlusDm {
/// # Errors
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
prev: None,
seed: 0.0,
seed_count: 0,
smooth: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for PlusDm {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let Some(prev) = self.prev else {
self.prev = Some(candle);
return None;
};
self.prev = Some(candle);
let (plus_dm, _) = directional_movement(&prev, &candle);
let n = self.period as f64;
if let Some(s) = self.smooth {
let s_new = s - s / n + plus_dm;
self.smooth = Some(s_new);
return Some(s_new);
}
self.seed += plus_dm;
self.seed_count += 1;
if self.seed_count < self.period {
return None;
}
self.smooth = Some(self.seed);
Some(self.seed)
}
fn reset(&mut self) {
self.prev = None;
self.seed = 0.0;
self.seed_count = 0;
self.smooth = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.smooth.is_some()
}
fn name(&self) -> &'static str {
"PLUS_DM"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
/// Candle with explicit high/low; open and close are pinned to `cl`.
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(PlusDm::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let dm = PlusDm::new(7).unwrap();
assert_eq!(dm.period(), 7);
assert_eq!(dm.name(), "PLUS_DM");
assert_eq!(dm.warmup_period(), 7);
assert!(!dm.is_ready());
}
#[test]
fn seeds_then_smooths_a_constant_plus_dm() {
// High rises by 1 each bar (up = +1); low rises by 0.5 each bar, so the
// down-move is negative and +DM equals the up-move (1.0) on every bar.
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
11.0 + f64::from(i),
9.0 + 0.5 * f64::from(i),
10.0 + f64::from(i),
)
})
.collect();
let mut dm = PlusDm::new(3).unwrap();
let out: Vec<Option<f64>> = dm.batch(&candles);
// First candle only sets the previous bar; bars 2-3 seed the sum.
assert_eq!(out[0], None);
assert_eq!(out[1], None);
assert_eq!(out[2], None);
// Seed = sum of three unit +DM values.
assert_relative_eq!(out[3].unwrap(), 3.0, epsilon = 1e-12);
// Wilder step: 3 - 3/3 + 1 = 3.
assert_relative_eq!(out[4].unwrap(), 3.0, epsilon = 1e-12);
assert!(dm.is_ready());
}
#[test]
fn down_moves_contribute_zero() {
// Strict downtrend: highs fall, so every raw +DM is zero and the smoothed
// total stays at zero.
let candles: Vec<Candle> = (0..6)
.map(|i| c(20.0 - f64::from(i), 5.0 - f64::from(i), 12.0 - f64::from(i)))
.collect();
let mut dm = PlusDm::new(3).unwrap();
let last = dm.batch(&candles).into_iter().flatten().last().unwrap();
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
}
#[test]
fn reset_restores_initial_state() {
let candles: Vec<Candle> = (0..5)
.map(|i| {
c(
11.0 + f64::from(i),
9.0 + 0.5 * f64::from(i),
10.0 + f64::from(i),
)
})
.collect();
let mut dm = PlusDm::new(3).unwrap();
let _ = dm.batch(&candles);
assert!(dm.is_ready());
dm.reset();
assert!(!dm.is_ready());
assert_eq!(dm.update(candles[0]), None);
}
}
+162
View File
@@ -0,0 +1,162 @@
//! Rate of Change Percentage (ROCP).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Percentage (`ROCP`): `(close - close[period]) / close[period]`.
///
/// The same momentum measure as [`Roc`](crate::Roc) but expressed as a raw
/// fraction rather than a percentage — `Roc` is exactly `100 · ROCP`. Where the
/// reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocp};
///
/// let mut indicator = Rocp::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocp {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocp {
/// # 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 + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocp {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocp = if prev == 0.0 {
0.0
} else {
(input - prev) / prev
};
self.last = Some(rocp);
Some(rocp)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCP"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocp::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocp::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCP");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_fraction() {
// period 1 over [10, 11]: (11 - 10) / 10 = 0.1.
let mut r = Rocp::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 0.1, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_zero() {
let mut r = Rocp::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 0.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
// period 1 over [0, 5]: reference price is zero -> guarded to 0.
let mut r = Rocp::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocp::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::NAN), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocp::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Rate of Change Ratio (ROCR).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Ratio (`ROCR`): `close / close[period]`.
///
/// The momentum ratio relative to the price `period` bars ago: `1.0` means no
/// change, `> 1` an advance, `< 1` a decline. It is [`Rocp`](crate::Rocp) plus
/// one. Where the reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocr};
///
/// let mut indicator = Rocr::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocr {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocr {
/// # 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 + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocr {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocr = if prev == 0.0 { 0.0 } else { input / prev };
self.last = Some(rocr);
Some(rocr)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCR"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocr::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocr::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCR");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_ratio() {
// period 1 over [10, 11]: 11 / 10 = 1.1.
let mut r = Rocr::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 1.1, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_one() {
let mut r = Rocr::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 1.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
let mut r = Rocr::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocr::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::INFINITY), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocr::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
@@ -0,0 +1,161 @@
//! Rate of Change Ratio scaled by 100 (ROCR100).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Rate of Change Ratio × 100 (`ROCR100`): `close / close[period] · 100`.
///
/// The same ratio as [`Rocr`](crate::Rocr) rescaled so that an unchanged price
/// reads `100` rather than `1`: `> 100` is an advance, `< 100` a decline. Where
/// the reference price is zero the result is reported as `0`.
///
/// Non-finite inputs are ignored and leave the window untouched; the last
/// computed value is returned instead, matching the SMA / EMA convention.
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Rocr100};
///
/// let mut indicator = Rocr100::new(3).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(100.0 + f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Rocr100 {
period: usize,
window: VecDeque<f64>,
last: Option<f64>,
}
impl Rocr100 {
/// # 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 + 1),
last: None,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Rocr100 {
type Input = f64;
type Output = f64;
fn update(&mut self, input: f64) -> Option<f64> {
if !input.is_finite() {
return self.last;
}
if self.window.len() == self.period + 1 {
self.window.pop_front();
}
self.window.push_back(input);
if self.window.len() < self.period + 1 {
return None;
}
let prev = *self.window.front().expect("non-empty");
let rocr = if prev == 0.0 {
0.0
} else {
input / prev * 100.0
};
self.last = Some(rocr);
Some(rocr)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period + 1
}
fn is_ready(&self) -> bool {
self.window.len() == self.period + 1
}
fn name(&self) -> &'static str {
"ROCR100"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_zero_period() {
assert!(matches!(Rocr100::new(0), Err(Error::PeriodZero)));
}
#[test]
fn accessors_report_config() {
let r = Rocr100::new(3).unwrap();
assert_eq!(r.period(), 3);
assert_eq!(r.name(), "ROCR100");
assert_eq!(r.warmup_period(), 4);
assert!(!r.is_ready());
}
#[test]
fn known_value_is_a_scaled_ratio() {
// period 1 over [10, 11]: 11 / 10 * 100 = 110.
let mut r = Rocr100::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[10.0, 11.0]);
assert_eq!(out[0], None);
assert_relative_eq!(out[1].unwrap(), 110.0, epsilon = 1e-12);
assert!(r.is_ready());
}
#[test]
fn constant_series_yields_hundred() {
let mut r = Rocr100::new(3).unwrap();
for v in r.batch(&[10.0_f64; 12]).iter().skip(4).flatten() {
assert_relative_eq!(*v, 100.0, epsilon = 1e-12);
}
}
#[test]
fn zero_reference_price_reports_zero() {
let mut r = Rocr100::new(1).unwrap();
let out: Vec<Option<f64>> = r.batch(&[0.0, 5.0]);
assert_relative_eq!(out[1].unwrap(), 0.0, epsilon = 1e-12);
}
#[test]
fn non_finite_input_holds_last() {
let mut r = Rocr100::new(1).unwrap();
assert_eq!(r.update(10.0), None);
let v = r.update(11.0).unwrap();
assert_eq!(r.update(f64::NEG_INFINITY), Some(v));
}
#[test]
fn reset_clears_state() {
let mut r = Rocr100::new(1).unwrap();
let _ = r.batch(&[10.0, 11.0]);
assert!(r.is_ready());
r.reset();
assert!(!r.is_ready());
assert_eq!(r.update(10.0), None);
}
}
@@ -0,0 +1,414 @@
//! Parabolic SAR Extended (SAREXT).
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Trend {
Up,
Down,
}
/// One direction's acceleration-factor schedule (initial, step, maximum).
#[derive(Debug, Clone, Copy)]
struct Accel {
init: f64,
step: f64,
max: f64,
}
impl Accel {
fn validate(self) -> Result<Self> {
if !(self.init.is_finite() && self.step.is_finite() && self.max.is_finite()) {
return Err(Error::NonPositiveMultiplier);
}
if self.init <= 0.0 || self.step <= 0.0 || self.max <= 0.0 {
return Err(Error::NonPositiveMultiplier);
}
if self.init > self.max {
return Err(Error::InvalidPeriod {
message: "acceleration init must be <= max",
});
}
Ok(self)
}
}
/// Parabolic SAR Extended (`SAREXT`): Wilder's Parabolic SAR with TA-Lib's
/// extended controls.
///
/// Beyond [`Psar`](crate::Psar) it adds:
/// - **`start_value`** — the initial SAR. `0` auto-seeds (long, like `Psar`);
/// a positive value starts a long phase at that SAR, a negative value starts a
/// short phase at its absolute value.
/// - **`offset_on_reverse`** — a fractional offset applied to the new SAR on each
/// reversal, pushing it further from price (`0` disables it).
/// - **separate long / short acceleration** — independent `(init, step, max)`
/// schedules for rising and falling phases.
///
/// The output is **signed**: a positive value during a long phase (SAR below
/// price) and a negative value during a short phase (SAR above price), so the
/// sign alone encodes the current trade direction.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SarExt};
///
/// let mut indicator =
/// SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// 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 SarExt {
start_value: f64,
offset_on_reverse: f64,
long: Accel,
short: Accel,
initialised: bool,
has_emitted: bool,
prev_high: f64,
prev_low: f64,
trend: Trend,
sar: f64,
ep: f64,
af: f64,
}
impl SarExt {
/// Construct an extended Parabolic SAR.
///
/// Parameters mirror TA-Lib's `SAREXT`: `start_value`, `offset_on_reverse`,
/// then the long `(init, step, max)` and short `(init, step, max)`
/// acceleration schedules.
///
/// # Errors
/// Returns [`Error::NonPositiveMultiplier`] if any acceleration term is
/// non-positive or non-finite, [`Error::InvalidPeriod`] if an `init` exceeds
/// its `max`, and [`Error::NonPositiveMultiplier`] if `start_value` or
/// `offset_on_reverse` is non-finite or `offset_on_reverse` is negative.
#[allow(clippy::too_many_arguments)]
pub fn new(
start_value: f64,
offset_on_reverse: f64,
accel_init_long: f64,
accel_long: f64,
accel_max_long: f64,
accel_init_short: f64,
accel_short: f64,
accel_max_short: f64,
) -> Result<Self> {
if !start_value.is_finite() || !offset_on_reverse.is_finite() || offset_on_reverse < 0.0 {
return Err(Error::NonPositiveMultiplier);
}
let long = Accel {
init: accel_init_long,
step: accel_long,
max: accel_max_long,
}
.validate()?;
let short = Accel {
init: accel_init_short,
step: accel_short,
max: accel_max_short,
}
.validate()?;
Ok(Self {
start_value,
offset_on_reverse,
long,
short,
initialised: false,
has_emitted: false,
prev_high: f64::NAN,
prev_low: f64::NAN,
trend: Trend::Up,
sar: f64::NAN,
ep: f64::NAN,
af: long.init,
})
}
/// Wilder's defaults with no start value or reversal offset and symmetric
/// `(0.02, 0.02, 0.20)` acceleration in both directions.
pub fn classic() -> Self {
Self::new(0.0, 0.0, 0.02, 0.02, 0.20, 0.02, 0.02, 0.20)
.expect("classic SAREXT params are valid")
}
fn signed(&self, sar: f64) -> f64 {
match self.trend {
Trend::Up => sar,
Trend::Down => -sar,
}
}
}
impl Indicator for SarExt {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if !self.initialised {
self.prev_high = candle.high;
self.prev_low = candle.low;
if self.start_value > 0.0 {
self.trend = Trend::Up;
self.sar = self.start_value;
self.ep = candle.high;
self.af = self.long.init;
} else if self.start_value < 0.0 {
self.trend = Trend::Down;
self.sar = -self.start_value;
self.ep = candle.low;
self.af = self.short.init;
} else {
self.trend = Trend::Up;
self.sar = candle.low;
self.ep = candle.high;
self.af = self.long.init;
}
self.initialised = true;
return None;
}
let mut new_sar = self.sar + self.af * (self.ep - self.sar);
let prev_h = self.prev_high;
let prev_l = self.prev_low;
new_sar = match self.trend {
Trend::Up => new_sar.min(prev_l).min(candle.low),
Trend::Down => new_sar.max(prev_h).max(candle.high),
};
let mut output_sar = new_sar;
let reversed = match self.trend {
Trend::Up => candle.low <= new_sar,
Trend::Down => candle.high >= new_sar,
};
if reversed {
output_sar = self.ep;
self.trend = match self.trend {
Trend::Up => Trend::Down,
Trend::Down => Trend::Up,
};
match self.trend {
Trend::Up => {
output_sar -= output_sar.abs() * self.offset_on_reverse;
self.ep = candle.high;
self.af = self.long.init;
}
Trend::Down => {
output_sar += output_sar.abs() * self.offset_on_reverse;
self.ep = candle.low;
self.af = self.short.init;
}
}
} else {
match self.trend {
Trend::Up => {
if candle.high > self.ep {
self.ep = candle.high;
self.af = (self.af + self.long.step).min(self.long.max);
}
}
Trend::Down => {
if candle.low < self.ep {
self.ep = candle.low;
self.af = (self.af + self.short.step).min(self.short.max);
}
}
}
}
self.sar = output_sar;
self.prev_high = candle.high;
self.prev_low = candle.low;
self.has_emitted = true;
Some(self.signed(output_sar))
}
fn reset(&mut self) {
self.initialised = false;
self.has_emitted = false;
self.prev_high = f64::NAN;
self.prev_low = f64::NAN;
self.trend = Trend::Up;
self.sar = f64::NAN;
self.ep = f64::NAN;
self.af = self.long.init;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.has_emitted
}
fn name(&self) -> &'static str {
"SAREXT"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(h: f64, l: f64, cl: f64) -> Candle {
Candle::new(cl, h, l, cl, 1.0, 0).unwrap()
}
fn classic() -> SarExt {
SarExt::classic()
}
#[test]
fn rejects_invalid_params() {
// Non-positive / non-finite acceleration terms.
assert!(SarExt::new(0.0, 0.0, 0.0, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.0, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, 0.0, 0.30, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
// Bad start value / offset.
assert!(SarExt::new(f64::NAN, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
assert!(SarExt::new(0.0, -1.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).is_err());
}
#[test]
fn accessors_and_metadata() {
let s = classic();
assert_eq!(s.warmup_period(), 2);
assert_eq!(s.name(), "SAREXT");
assert!(!s.is_ready());
}
#[test]
fn seed_returns_none_then_emits() {
let mut s = classic();
assert_eq!(s.update(c(11.0, 9.0, 10.0)), None);
assert!(!s.is_ready());
assert!(s.update(c(12.0, 10.0, 11.0)).is_some());
assert!(s.is_ready());
}
#[test]
fn uptrend_is_positive_and_below_lows() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let ok = s
.batch(&candles)
.iter()
.enumerate()
.all(|(i, v)| v.is_none_or(|x| x > 0.0 && x <= candles[i].low + 1e-9));
assert!(ok, "long-phase SAREXT must be positive and below the low");
}
#[test]
fn downtrend_is_negative_and_above_highs() {
let candles: Vec<Candle> = (0..40)
.rev()
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let ok = s
.batch(&candles)
.iter()
.enumerate()
.skip(5)
.all(|(i, v)| v.is_none_or(|x| x < 0.0 && -x >= candles[i].high - 1e-9));
assert!(ok, "short-phase SAREXT must be negative and above the high");
}
#[test]
fn positive_start_value_begins_long() {
// start_value > 0 seeds a long phase: first emitted value is positive.
let mut s = SarExt::new(95.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
assert_eq!(s.update(c(101.0, 99.0, 100.0)), None);
let v = s.update(c(102.0, 100.0, 101.0)).unwrap();
assert!(v > 0.0);
}
#[test]
fn negative_start_value_begins_short() {
// start_value < 0 seeds a short phase: first emitted value is negative.
let mut s = SarExt::new(-105.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2).unwrap();
assert_eq!(s.update(c(101.0, 99.0, 100.0)), None);
let v = s.update(c(100.0, 98.0, 99.0)).unwrap();
assert!(v < 0.0);
}
#[test]
fn offset_on_reverse_pushes_sar_further() {
// A V-shaped path forces a reversal; with an offset the reversal SAR is
// pushed further from price than without one.
let candles: Vec<Candle> = (0..12)
.map(|i| {
let base = if i < 6 {
100.0 - f64::from(i) * 2.0
} else {
88.0 + f64::from(i - 6) * 2.0
};
c(base + 1.0, base - 1.0, base)
})
.collect();
let plain = SarExt::new(0.0, 0.0, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2)
.unwrap()
.batch(&candles);
let offset = SarExt::new(0.0, 0.1, 0.02, 0.02, 0.2, 0.02, 0.02, 0.2)
.unwrap()
.batch(&candles);
// The two configurations must diverge once a reversal with offset fires.
assert_ne!(plain, offset);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
let m = 100.0 + (f64::from(i) * 0.3).sin() * 8.0;
c(m + 1.0, m - 1.0, m)
})
.collect();
let mut a = classic();
let mut b = classic();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
#[test]
fn reset_allows_clean_reuse() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
let base = 100.0 + f64::from(i);
c(base + 0.5, base - 0.5, base)
})
.collect();
let mut s = classic();
let first = s.batch(&candles);
assert!(s.is_ready());
s.reset();
assert!(!s.is_ready());
assert_eq!(first, s.batch(&candles));
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Time Series Forecast (TSF).
use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::traits::Indicator;
/// Time Series Forecast (`TSF`): the rolling least-squares line projected one bar
/// past the window.
///
/// Over the last `period` inputs, indexed `x = 0, 1, …, period 1`, it fits
/// `y = a + b·x` by ordinary least squares and reports the line's value at
/// `x = period` (one step beyond the most recent point):
///
/// ```text
/// b (slope) = (n·Σxy Σx·Σy) / (n·Σxx (Σx)²)
/// a (intercept) = (Σy b·Σx) / n
/// TSF = a + b·period
/// ```
///
/// Where [`LinearRegression`](crate::LinearRegression) evaluates the fit at the
/// current bar (`a + b·(period 1)`), `TSF` advances it one further bar, giving a
/// trend-following one-step-ahead forecast. Each update is O(1).
///
/// # Example
///
/// ```
/// use wickra_core::{Indicator, Tsf};
///
/// let mut indicator = Tsf::new(14).unwrap();
/// let mut last = None;
/// for i in 0..80 {
/// last = indicator.update(f64::from(i));
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct Tsf {
period: usize,
window: VecDeque<f64>,
sum_x: f64,
denom: f64,
sum_y: f64,
sum_xy: f64,
}
impl Tsf {
/// Construct a new rolling time-series forecast over `period` inputs.
///
/// # Errors
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression line is
/// undefined for fewer than two points.
pub fn new(period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidPeriod {
message: "time series forecast needs period >= 2",
});
}
let n = period as f64;
let sum_x = n * (n - 1.0) / 2.0;
let sum_xx = (n - 1.0) * n * (2.0 * n - 1.0) / 6.0;
Ok(Self {
period,
window: VecDeque::with_capacity(period),
sum_x,
denom: n * sum_xx - sum_x * sum_x,
sum_y: 0.0,
sum_xy: 0.0,
})
}
/// Configured period.
pub const fn period(&self) -> usize {
self.period
}
}
impl Indicator for Tsf {
type Input = f64;
type Output = f64;
fn update(&mut self, value: f64) -> Option<f64> {
if self.window.len() == self.period {
let y0 = self.window.pop_front().expect("non-empty");
self.sum_xy = self.sum_xy - self.sum_y + y0;
self.sum_y -= y0;
}
let k = self.window.len() as f64;
self.window.push_back(value);
self.sum_y += value;
self.sum_xy += k * value;
if self.window.len() < self.period {
return None;
}
let n = self.period as f64;
let slope = (n * self.sum_xy - self.sum_x * self.sum_y) / self.denom;
let intercept = (self.sum_y - slope * self.sum_x) / n;
Some(intercept + slope * n)
}
fn reset(&mut self) {
self.window.clear();
self.sum_y = 0.0;
self.sum_xy = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.window.len() == self.period
}
fn name(&self) -> &'static str {
"TSF"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
#[test]
fn rejects_short_period() {
assert!(matches!(Tsf::new(1), Err(Error::InvalidPeriod { .. })));
}
#[test]
fn accessors_report_config() {
let tsf = Tsf::new(5).unwrap();
assert_eq!(tsf.period(), 5);
assert_eq!(tsf.name(), "TSF");
assert_eq!(tsf.warmup_period(), 5);
assert!(!tsf.is_ready());
}
#[test]
fn reference_value() {
// period 3 over [1, 2, 9]: fit y = 0 + 4x, forecast at x = 3 is 12.
let mut tsf = Tsf::new(3).unwrap();
let out: Vec<Option<f64>> = tsf.batch(&[1.0, 2.0, 9.0]);
assert!(out[0].is_none());
assert!(out[1].is_none());
assert_relative_eq!(out[2].unwrap(), 12.0, epsilon = 1e-9);
assert!(tsf.is_ready());
}
#[test]
fn forecasts_a_clean_line_one_step_ahead() {
// Window [10, 12, 14]: y = 10 + 2x, forecast at x = 3 is 16.
let mut tsf = Tsf::new(3).unwrap();
let out: Vec<Option<f64>> = tsf.batch(&[1.0, 10.0, 12.0, 14.0]);
assert_relative_eq!(out[3].unwrap(), 16.0, epsilon = 1e-9);
}
#[test]
fn reset_clears_state() {
let mut tsf = Tsf::new(3).unwrap();
let _ = tsf.batch(&[1.0, 2.0, 9.0]);
assert!(tsf.is_ready());
tsf.reset();
assert!(!tsf.is_ready());
assert_eq!(tsf.update(1.0), None);
}
}
+32 -27
View File
@@ -56,7 +56,7 @@ pub use indicators::{
AbandonedBaby, AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator,
AdaptiveCycle, Adl, AdvanceBlock, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma,
Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AwesomeOscillator,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AvgPrice, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BollingerBands, BollingerBandwidth,
BollingerOutput, Breakaway, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci,
CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop,
@@ -66,7 +66,7 @@ pub use indicators::{
Counterattack, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema,
DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, DojiStar,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration,
DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama,
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput,
@@ -74,35 +74,37 @@ pub use indicators::{
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility,
GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator,
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, KagiBars, Kama,
KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine,
LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
InverseFisherTransform, InvertedHammer, Jma, KagiBars, Kama, KellyCriterion, Keltner,
KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio,
MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, MorningDojiStar,
MorningEveningStar, Natr, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck,
OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex, PairSpreadZScore, PairwiseBeta,
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
PiercingDarkCloud, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread,
RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput,
RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, RogersSatchellVolatility,
RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SeparatingLines,
SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, StalledPattern, StandardError,
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice,
MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, Nvi, OIPriceDivergence,
OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange,
OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN,
PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB,
PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo,
ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, RecoveryFactor,
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollingVwap,
RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeparatingLines, SharpeRatio,
ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio,
SpearmanCorrelation, SpinningTop, StalledPattern, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker,
TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike,
ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, Tii, TpoProfile,
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsf, Tsi, Tsv,
TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator,
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vwap,
@@ -115,6 +117,9 @@ pub use indicators::{
// line so the indicator-count tooling (which scans the braced block above and
// strips only `*Output` companions) does not count it as a separate indicator.
pub use indicators::FootprintLevel;
// `MaType` is a moving-average selector enum used by `MacdExt`, re-exported on
// its own line so the indicator-count tooling does not count it as an indicator.
pub use indicators::MaType;
// Bar element types for the alt-chart builders, re-exported on their own lines so
// the indicator-count tooling (which scans only the braced block above) does not
// count them as separate indicators.
+6
View File
@@ -119,6 +119,12 @@ impl Candle {
(self.high + self.low + 2.0 * self.close) / 4.0
}
/// The average price `(open + high + low + close) / 4`.
#[inline]
pub fn avg_price(&self) -> f64 {
(self.open + self.high + self.low + self.close) / 4.0
}
/// True range of this candle relative to a previous close: `max(H-L, |H-prev|, |L-prev|)`.
/// If no previous close is supplied, falls back to `high - low`.
#[inline]
+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 **295 indicators** across
- A per-indicator deep dive for every one of the **314 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 &
+39 -15
View File
@@ -15,21 +15,7 @@
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta,
BollingerBands,
CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk,
ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev,
DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle,
HistoricalVolatility, Hma, HurstExponent, Indicator, InstantaneousTrendline,
InverseFisherTransform, Jma, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle,
LinRegChannel, LinRegSlope, LinearRegression, MaEnvelope, MacdIndicator, Mama, MaxDrawdown,
McGinleyDynamic, MedianAbsoluteDeviation, Mom, OmegaRatio, PainIndex, PearsonCorrelation,
PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RecoveryFactor, RenkoTrailingStop,
Roc, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma,
SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev,
StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, Trima, Trix, Tsi, UlcerIndex,
ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, T3,
AdaptiveCycle, Alma, AnchoredRsi, Apo, Autocorrelation, AverageDrawdown, BatchExt, Beta, BollingerBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DetrendedStdDev, DoubleBollinger, Dpo, DrawdownDuration, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Fama, FisherTransform, Frama, GainLossRatio, HilbertDominantCycle, HistoricalVolatility, Hma, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, Jma, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, MaEnvelope, MaType, MacdExt, MacdFix, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, Ppo, ProfitFactor, RSquared, RecoveryFactor, RenkoTrailingStop, Roc, Rocp, Rocr, Rocr100, RoofingFilter, Rsi, RviVolatility, SharpeRatio, SineWave, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, Trima, Trix, Tsf, Tsi, UlcerIndex, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, Wma, ZScore, ZeroLagMacd, Zlema, T3
};
/// Drive a single streaming + batch run through one scalar indicator. Marked
@@ -60,6 +46,9 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| Tema::new(14).unwrap(), &data);
drive(|| Hma::new(14).unwrap(), &data);
drive(|| Roc::new(14).unwrap(), &data);
drive(|| Rocp::new(14).unwrap(), &data);
drive(|| Rocr::new(14).unwrap(), &data);
drive(|| Rocr100::new(14).unwrap(), &data);
drive(|| Trix::new(14).unwrap(), &data);
drive(|| Smma::new(14).unwrap(), &data);
drive(|| Trima::new(14).unwrap(), &data);
@@ -88,7 +77,10 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| UlcerIndex::new(14).unwrap(), &data);
drive(|| HistoricalVolatility::new(14, 252).unwrap(), &data);
drive(|| LinearRegression::new(14).unwrap(), &data);
drive(|| MidPoint::new(14).unwrap(), &data);
drive(|| LinRegSlope::new(14).unwrap(), &data);
drive(|| LinRegIntercept::new(14).unwrap(), &data);
drive(|| Tsf::new(14).unwrap(), &data);
drive(|| LinRegAngle::new(14).unwrap(), &data);
drive(|| VerticalHorizontalFilter::new(14).unwrap(), &data);
drive(|| ZScore::new(14).unwrap(), &data);
@@ -146,6 +138,8 @@ fuzz_target!(|data: Vec<f64>| {
drive(|| EhlersStochastic::new(20).unwrap(), &data);
drive(|| EmpiricalModeDecomposition::new(20, 0.5).unwrap(), &data);
drive(HilbertDominantCycle::new, &data);
drive(HtDcPhase::new, &data);
drive(HtTrendMode::new, &data);
drive(AdaptiveCycle::new, &data);
drive(SineWave::new, &data);
drive(|| Fama::new(0.5, 0.05).unwrap(), &data);
@@ -191,6 +185,36 @@ fuzz_target!(|data: Vec<f64>| {
}
let _ = MacdIndicator::new(12, 26, 9).unwrap().batch(&data);
}
// MACDFIX wraps MacdIndicator(12, 26, signal); same multi-output topology.
{
let mut fix = MacdFix::new(9).unwrap();
for &x in &data {
let _ = fix.update(x);
}
let _ = MacdFix::new(9).unwrap().batch(&data);
}
// MACDEXT: selectable MA types per line, multi-output topology.
{
let mut ext = MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma).unwrap();
for &x in &data {
let _ = ext.update(x);
}
let _ = MacdExt::new(12, MaType::Ema, 26, MaType::Ema, 9, MaType::Sma)
.unwrap()
.batch(&data);
}
// HT_PHASOR is scalar-input but emits a {inphase, quadrature} struct, so it
// bypasses the generic `drive` helper.
{
let mut ph = HtPhasor::new();
for &x in &data {
let _ = ph.update(x);
}
let _ = HtPhasor::new().batch(&data);
}
{
let mut bb = BollingerBands::new(20, 2.0).unwrap();
for &x in &data {
+9 -1
View File
@@ -23,7 +23,7 @@
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MedianPrice, Mfi, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag
AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag
};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
@@ -79,6 +79,7 @@ fuzz_target!(|data: Vec<f64>| {
// --- Trailing Stops ---
drive(|| Psar::new(0.02, 0.02, 0.20).unwrap(), &candles);
drive(SarExt::classic, &candles);
drive(|| SuperTrend::new(14, 3.0).unwrap(), &candles);
drive(|| ChandelierExit::new(22, 3.0).unwrap(), &candles);
drive(|| ChandeKrollStop::new(10, 1.0, 9).unwrap(), &candles);
@@ -90,6 +91,11 @@ fuzz_target!(|data: Vec<f64>| {
// --- Trend & Directional ---
drive(|| Adx::new(14).unwrap(), &candles);
drive(|| Adxr::new(14).unwrap(), &candles);
drive(|| PlusDm::new(14).unwrap(), &candles);
drive(|| MinusDm::new(14).unwrap(), &candles);
drive(|| PlusDi::new(14).unwrap(), &candles);
drive(|| MinusDi::new(14).unwrap(), &candles);
drive(|| Dx::new(14).unwrap(), &candles);
drive(|| Aroon::new(14).unwrap(), &candles);
drive(|| Alligator::new(13, 8, 5).unwrap(), &candles);
drive(|| AroonOscillator::new(14).unwrap(), &candles);
@@ -143,6 +149,8 @@ fuzz_target!(|data: Vec<f64>| {
drive(TypicalPrice::new, &candles);
drive(MedianPrice::new, &candles);
drive(WeightedClose::new, &candles);
drive(|| MidPrice::new(14).unwrap(), &candles);
drive(AvgPrice::new, &candles);
// --- Stochastic (multi-output) ---
{