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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
[](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) },
|
||||
|
||||
Vendored
+191
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 &
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) ---
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user