diff --git a/CHANGELOG.md b/CHANGELOG.md index e4118fcb..be7add87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7026647c..370083b4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Wickra — streaming-first technical indicators + Wickra — streaming-first technical indicators

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 295 indicators; start at the + every one of the 314 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries ## Indicators -295 streaming-first indicators across nineteen families. Every one passes the +314 streaming-first indicators across nineteen families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. Each has a per-indicator deep dive (formula, parameters, warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). @@ -239,7 +239,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 295 indicators +│ ├── wickra-core/ core engine + all 314 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index b4793866..ae650766 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -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) }, diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 8e18e191..070cf185 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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): Array + 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, low: Array, close: Array): Array + 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, low: Array, close: Array): Array + 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, low: Array, close: Array): Array + 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, low: Array, close: Array): Array + 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, low: Array, close: Array): Array + 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, low: Array, close: Array): Array + 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, high: Array, low: Array, close: Array): Array + 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, low: Array, close: Array): Array + 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): Array + 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): Array + 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): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type HilbertDominantCycleNode = HilbertDominantCycle export declare class HilbertDominantCycle { constructor() diff --git a/bindings/node/index.js b/bindings/node/index.js index b8027d23..bd79395c 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -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 diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index b5c191b1..b7281bed 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -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 { + Ok(Self { + inner: wc::MacdFix::new(signal as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, value: f64) -> Option { + 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) -> Vec { + 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 { + 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 { + 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) -> Vec { + 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 { + 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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 { + 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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 { + 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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 { + 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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 { + 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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 { + 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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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> { + Ok(self.inner.update(cnd4(open, high, low, close)?)) + } + #[napi] + pub fn batch( + &mut self, + open: Vec, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 { + 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> { + Ok(self.inner.update(cnd(high, low, close, 0.0)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + ) -> napi::Result> { + 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 { + 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) -> Vec { + 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 { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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 { + self.inner.update(value) + } + #[napi] + pub fn batch(&mut self, prices: Vec) -> Vec { + 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, diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index b4045784..a66eea14 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -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", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 19955e2a..5160def9 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -508,6 +508,1070 @@ impl PyAtr { } } +// ============================== Plus DM ============================== + +#[pyclass(name = "PLUS_DM", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyPlusDm { + inner: wc::PlusDm, +} + +#[pymethods] +impl PyPlusDm { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::PlusDm::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("PLUS_DM(period={})", self.inner.period()) + } +} + +// ============================== Minus DM ============================== + +#[pyclass(name = "MINUS_DM", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMinusDm { + inner: wc::MinusDm, +} + +#[pymethods] +impl PyMinusDm { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::MinusDm::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("MINUS_DM(period={})", self.inner.period()) + } +} + +// ============================== PlusDi ============================== + +#[pyclass(name = "PLUS_DI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyPlusDi { + inner: wc::PlusDi, +} + +#[pymethods] +impl PyPlusDi { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::PlusDi::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("PLUS_DI(period={})", self.inner.period()) + } +} + +// ============================== MinusDi ============================== + +#[pyclass(name = "MINUS_DI", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMinusDi { + inner: wc::MinusDi, +} + +#[pymethods] +impl PyMinusDi { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::MinusDi::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("MINUS_DI(period={})", self.inner.period()) + } +} + +// ============================== Dx ============================== + +#[pyclass(name = "DX", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyDx { + inner: wc::Dx, +} + +#[pymethods] +impl PyDx { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Dx::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("DX(period={})", self.inner.period()) + } +} + +// ============================== MidPrice ============================== + +#[pyclass(name = "MIDPRICE", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMidPrice { + inner: wc::MidPrice, +} + +#[pymethods] +impl PyMidPrice { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::MidPrice::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("MIDPRICE(period={})", self.inner.period()) + } +} + +// ============================== MidPoint ============================== + +#[pyclass(name = "MIDPOINT", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMidPoint { + inner: wc::MidPoint, +} + +#[pymethods] +impl PyMidPoint { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::MidPoint::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("MIDPOINT(period={})", self.inner.period()) + } +} + +// ============================== Avg Price ============================== + +#[pyclass(name = "AVGPRICE", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAvgPrice { + inner: wc::AvgPrice, +} + +#[pymethods] +impl PyAvgPrice { + #[new] + fn new() -> Self { + Self { + inner: wc::AvgPrice::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: open, high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + open: PyReadonlyArray1<'py, f64>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let o = open + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if !(o.len() == h.len() && h.len() == l.len() && l.len() == c.len()) { + return Err(PyValueError::new_err( + "open, high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(o[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "AVGPRICE()".to_string() + } +} + +// ============================== Rocp ============================== + +#[pyclass(name = "ROCP", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRocp { + inner: wc::Rocp, +} + +#[pymethods] +impl PyRocp { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Rocp::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("ROCP(period={})", self.inner.period()) + } +} + +// ============================== Rocr ============================== + +#[pyclass(name = "ROCR", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRocr { + inner: wc::Rocr, +} + +#[pymethods] +impl PyRocr { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Rocr::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("ROCR(period={})", self.inner.period()) + } +} + +// ============================== Rocr100 ============================== + +#[pyclass(name = "ROCR100", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyRocr100 { + inner: wc::Rocr100, +} + +#[pymethods] +impl PyRocr100 { + #[new] + #[pyo3(signature = (period=10))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Rocr100::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("ROCR100(period={})", self.inner.period()) + } +} + +// ============================== LinRegIntercept ============================== + +#[pyclass( + name = "LINEARREG_INTERCEPT", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyLinRegIntercept { + inner: wc::LinRegIntercept, +} + +#[pymethods] +impl PyLinRegIntercept { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::LinRegIntercept::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("LINEARREG_INTERCEPT(period={})", self.inner.period()) + } +} + +// ============================== Tsf ============================== + +#[pyclass(name = "TSF", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTsf { + inner: wc::Tsf, +} + +#[pymethods] +impl PyTsf { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::Tsf::new(period).map_err(map_err)?, + }) + } + fn update(&mut self, value: f64) -> Option { + self.inner.update(value) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let s = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + Ok(flatten(self.inner.batch(s)).into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("TSF(period={})", self.inner.period()) + } +} + +// ============================== MACD Fix ============================== + +#[pyclass(name = "MACDFIX", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMacdFix { + inner: wc::MacdFix, +} + +#[pymethods] +impl PyMacdFix { + #[new] + #[pyo3(signature = (signal=9))] + fn new(signal: usize) -> PyResult { + Ok(Self { + inner: wc::MacdFix::new(signal).map_err(map_err)?, + }) + } + /// Returns `(macd, signal, histogram)` or `None` during warmup. + fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.macd, o.signal, o.histogram)) + } + /// Batch over a numpy array of closes. Returns a 2D array of shape `(n, 3)` + /// with columns `[macd, signal, histogram]`. Warmup rows are NaN. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn signal_period(&self) -> usize { + self.inner.signal_period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("MACDFIX(signal={})", self.inner.signal_period()) + } +} + +// ============================== SAR Extended ============================== + +#[pyclass(name = "SAREXT", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PySarExt { + inner: wc::SarExt, +} + +#[pymethods] +impl PySarExt { + #[new] + #[pyo3(signature = ( + start_value=0.0, + offset_on_reverse=0.0, + accel_init_long=0.02, + accel_long=0.02, + accel_max_long=0.2, + accel_init_short=0.02, + accel_short=0.02, + accel_max_short=0.2, + ))] + #[allow(clippy::too_many_arguments)] + 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, + ) -> PyResult { + 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)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy columns: high, low, close (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(h.len()); + for i in 0..h.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "SAREXT()".to_string() + } +} + +// ============================== MACD Extended ============================== + +#[pyclass(name = "MACDEXT", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyMacdExt { + inner: wc::MacdExt, +} + +#[pymethods] +impl PyMacdExt { + /// Moving-average types are TA-Lib `MA_Type` codes `0..=5` + /// (SMA, EMA, WMA, DEMA, TEMA, TRIMA). + #[new] + #[pyo3(signature = ( + fast=12, + fast_matype=0, + slow=26, + slow_matype=0, + signal=9, + signal_matype=0, + ))] + fn new( + fast: usize, + fast_matype: u32, + slow: usize, + slow_matype: u32, + signal: usize, + signal_matype: u32, + ) -> PyResult { + 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)?, + }) + } + /// Returns `(macd, signal, histogram)` or `None` during warmup. + fn update(&mut self, value: f64) -> Option<(f64, f64, f64)> { + self.inner + .update(value) + .map(|o| (o.macd, o.signal, o.histogram)) + } + /// Batch over a numpy array of closes. Returns a 2D array of shape `(n, 3)` + /// with columns `[macd, signal, histogram]`. Warmup rows are NaN. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 3]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "MACDEXT()".to_string() + } +} + +// ============================== HT Phasor ============================== + +#[pyclass(name = "HT_PHASOR", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyHtPhasor { + inner: wc::HtPhasor, +} + +#[pymethods] +impl PyHtPhasor { + #[new] + fn new() -> Self { + Self { + inner: wc::HtPhasor::new(), + } + } + /// Returns `(inphase, quadrature)` or `None` during warmup. + fn update(&mut self, value: f64) -> Option<(f64, f64)> { + self.inner.update(value).map(|o| (o.inphase, o.quadrature)) + } + /// Batch over a numpy array of closes. Returns a 2D array of shape `(n, 2)` + /// with columns `[inphase, quadrature]`. Warmup rows are NaN. + fn batch<'py>( + &mut self, + py: Python<'py>, + prices: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let slice = prices + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let n = slice.len(); + let mut out = vec![f64::NAN; n * 2]; + for (i, p) in slice.iter().enumerate() { + if let Some(o) = self.inner.update(*p) { + out[i * 2] = o.inphase; + out[i * 2 + 1] = o.quadrature; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out) + .expect("shape consistent") + .into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "HT_PHASOR()".to_string() + } +} + // ============================== Stochastic ============================== #[pyclass(name = "Stochastic", module = "wickra._wickra", skip_from_py_object)] @@ -9823,6 +10887,8 @@ py_no_params_scalar!( wc::HilbertDominantCycle ); py_no_params_scalar!(PyAdaptiveCycle, "AdaptiveCycle", wc::AdaptiveCycle); +py_no_params_scalar!(PyHtDcPhase, "HT_DCPHASE", wc::HtDcPhase); +py_no_params_scalar!(PyHtTrendMode, "HT_TRENDMODE", wc::HtTrendMode); // SineWave needs a `lead` accessor in addition to scalar value, but otherwise // matches the parameterless surface. @@ -14420,6 +15486,9 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -14440,9 +15509,12 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -14578,6 +15650,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -14720,5 +15794,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index fe9136b0..1c0b5567 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -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. diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 9118cd8b..89ac25b6 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -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 { + Ok(Self { + inner: wc::PlusDm::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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 { + Ok(Self { + inner: wc::MinusDm::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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 { + Ok(Self { + inner: wc::PlusDi::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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 { + Ok(Self { + inner: wc::MinusDi::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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 { + Ok(Self { + inner: wc::Dx::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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 { + Ok(Self { + inner: wc::MidPrice::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 --- diff --git a/crates/wickra-core/src/indicators/adx.rs b/crates/wickra-core/src/indicators/adx.rs index ab02593b..66913622 100644 --- a/crates/wickra-core/src/indicators/adx.rs +++ b/crates/wickra-core/src/indicators/adx.rs @@ -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 }; diff --git a/crates/wickra-core/src/indicators/avg_price.rs b/crates/wickra-core/src/indicators/avg_price.rs new file mode 100644 index 00000000..41121da7 --- /dev/null +++ b/crates/wickra-core/src/indicators/avg_price.rs @@ -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 { + 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()); + } +} diff --git a/crates/wickra-core/src/indicators/dx.rs b/crates/wickra-core/src/indicators/dx.rs new file mode 100644 index 00000000..3acb2edd --- /dev/null +++ b/crates/wickra-core/src/indicators/dx.rs @@ -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, + plus_dm_seed: f64, + minus_dm_seed: f64, + tr_seed: f64, + seed_count: usize, + plus_dm_smooth: Option, + minus_dm_smooth: Option, + tr_smooth: Option, +} + +impl Dx { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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> = 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 = (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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/ht_dcphase.rs b/crates/wickra-core/src/indicators/ht_dcphase.rs new file mode 100644 index 00000000..c66e09c8 --- /dev/null +++ b/crates/wickra-core/src/indicators/ht_dcphase.rs @@ -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, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + // 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, + prev_i2: f64, + prev_q2: f64, + prev_re: f64, + prev_im: f64, + prev_period: f64, + prev_smooth_period: f64, + count: usize, + last_value: Option, +} + +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 { + self.last_value + } + + fn push_front(buf: &mut Vec, 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 { + 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 { + (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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/ht_phasor.rs b/crates/wickra-core/src/indicators/ht_phasor.rs new file mode 100644 index 00000000..68380729 --- /dev/null +++ b/crates/wickra-core/src/indicators/ht_phasor.rs @@ -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, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + 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, 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 { + 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 { + (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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/ht_trendmode.rs b/crates/wickra-core/src/indicators/ht_trendmode.rs new file mode 100644 index 00000000..238b5263 --- /dev/null +++ b/crates/wickra-core/src/indicators/ht_trendmode.rs @@ -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, + detrender_buf: Vec, + q1_buf: Vec, + i1_buf: Vec, + smooth_price: Vec, + 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, +} + +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 { + self.last_value + } + + fn push_front(buf: &mut Vec, 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 { + 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 { + 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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/linreg_intercept.rs b/crates/wickra-core/src/indicators/linreg_intercept.rs new file mode 100644 index 00000000..ca320421 --- /dev/null +++ b/crates/wickra-core/src/indicators/linreg_intercept.rs @@ -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, + 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 { + 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 { + 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> = 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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/macd_ext.rs b/crates/wickra-core/src/indicators/macd_ext.rs new file mode 100644 index 00000000..96ffa479 --- /dev/null +++ b/crates/wickra-core/src/indicators/macd_ext.rs @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 = (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> = 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 = (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()); + } +} diff --git a/crates/wickra-core/src/indicators/macd_fix.rs b/crates/wickra-core/src/indicators/macd_fix.rs new file mode 100644 index 00000000..3fe75b69 --- /dev/null +++ b/crates/wickra-core/src/indicators/macd_fix.rs @@ -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 { + 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 { + 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 = (0..80) + .map(|i| 100.0 + (f64::from(i) * 0.3).sin() * 5.0) + .collect(); + let fix: Vec> = MacdFix::new(9).unwrap().batch(&prices); + let classic: Vec> = + 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 = (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()); + } +} diff --git a/crates/wickra-core/src/indicators/mid_point.rs b/crates/wickra-core/src/indicators/mid_point.rs new file mode 100644 index 00000000..0b5b6fd9 --- /dev/null +++ b/crates/wickra-core/src/indicators/mid_point.rs @@ -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, +} + +impl MidPoint { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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> = 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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/mid_price.rs b/crates/wickra-core/src/indicators/mid_price.rs new file mode 100644 index 00000000..26b3326f --- /dev/null +++ b/crates/wickra-core/src/indicators/mid_price.rs @@ -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, +} + +impl MidPrice { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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> = 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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/minus_di.rs b/crates/wickra-core/src/indicators/minus_di.rs new file mode 100644 index 00000000..44cb5304 --- /dev/null +++ b/crates/wickra-core/src/indicators/minus_di.rs @@ -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, + dm_seed: f64, + tr_seed: f64, + seed_count: usize, + dm_smooth: Option, + tr_smooth: Option, +} + +impl MinusDi { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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> = 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/minus_dm.rs b/crates/wickra-core/src/indicators/minus_dm.rs new file mode 100644 index 00000000..35a2cc11 --- /dev/null +++ b/crates/wickra-core/src/indicators/minus_dm.rs @@ -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, + seed: f64, + seed_count: usize, + smooth: Option, +} + +impl MinusDm { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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> = 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 3fcd321e..daa86977 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -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"); } } diff --git a/crates/wickra-core/src/indicators/plus_di.rs b/crates/wickra-core/src/indicators/plus_di.rs new file mode 100644 index 00000000..5574ecdd --- /dev/null +++ b/crates/wickra-core/src/indicators/plus_di.rs @@ -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, + dm_seed: f64, + tr_seed: f64, + seed_count: usize, + dm_smooth: Option, + tr_smooth: Option, +} + +impl PlusDi { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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> = 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/plus_dm.rs b/crates/wickra-core/src/indicators/plus_dm.rs new file mode 100644 index 00000000..23646e26 --- /dev/null +++ b/crates/wickra-core/src/indicators/plus_dm.rs @@ -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, + seed: f64, + seed_count: usize, + smooth: Option, +} + +impl PlusDm { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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 = (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> = 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 = (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 = (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); + } +} diff --git a/crates/wickra-core/src/indicators/rocp.rs b/crates/wickra-core/src/indicators/rocp.rs new file mode 100644 index 00000000..b74ba51b --- /dev/null +++ b/crates/wickra-core/src/indicators/rocp.rs @@ -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, + last: Option, +} + +impl Rocp { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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> = 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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/rocr.rs b/crates/wickra-core/src/indicators/rocr.rs new file mode 100644 index 00000000..1675b1f8 --- /dev/null +++ b/crates/wickra-core/src/indicators/rocr.rs @@ -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, + last: Option, +} + +impl Rocr { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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> = 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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/rocr100.rs b/crates/wickra-core/src/indicators/rocr100.rs new file mode 100644 index 00000000..3a0c9f9b --- /dev/null +++ b/crates/wickra-core/src/indicators/rocr100.rs @@ -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, + last: Option, +} + +impl Rocr100 { + /// # Errors + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + 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 { + 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> = 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> = 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); + } +} diff --git a/crates/wickra-core/src/indicators/sar_ext.rs b/crates/wickra-core/src/indicators/sar_ext.rs new file mode 100644 index 00000000..bc4b8c54 --- /dev/null +++ b/crates/wickra-core/src/indicators/sar_ext.rs @@ -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 { + 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 { + 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 { + 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 = (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 = (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 = (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 = (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::>() + ); + } + + #[test] + fn reset_allows_clean_reuse() { + let candles: Vec = (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)); + } +} diff --git a/crates/wickra-core/src/indicators/tsf.rs b/crates/wickra-core/src/indicators/tsf.rs new file mode 100644 index 00000000..527c5434 --- /dev/null +++ b/crates/wickra-core/src/indicators/tsf.rs @@ -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, + 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 { + 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 { + 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> = 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> = 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); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 688ec434..3e1b6fa5 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -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. diff --git a/crates/wickra-core/src/ohlcv.rs b/crates/wickra-core/src/ohlcv.rs index f3bbce17..168f76fc 100644 --- a/crates/wickra-core/src/ohlcv.rs +++ b/crates/wickra-core/src/ohlcv.rs @@ -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] diff --git a/docs/README.md b/docs/README.md index a387d61d..e48e2fdc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 & diff --git a/fuzz/fuzz_targets/indicator_update.rs b/fuzz/fuzz_targets/indicator_update.rs index e439df10..3447e162 100644 --- a/fuzz/fuzz_targets/indicator_update.rs +++ b/fuzz/fuzz_targets/indicator_update.rs @@ -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| { 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| { 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| { 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| { } 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 { diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index cd617d5b..bf5c6a7f 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -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` by chunking it into @@ -79,6 +79,7 @@ fuzz_target!(|data: Vec| { // --- 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| { // --- 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| { 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) --- {