From c78b84e186fa57b35977cababe664f4103f4e884 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Sun, 7 Jun 2026 02:30:56 +0200 Subject: [PATCH] Add B8 Volume family deepening (7 indicators) (#195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deepens the **Volume** family (B8) with seven indicators (440 -> 447): - **VolumeRsi** — Wilder RSI computed on signed volume flow. - **WilliamsAd** — Williams Accumulation/Distribution cumulative line (distinct from Chaikin A/D). - **TwiggsMoneyFlow** — true-range volume accumulation with Wilder smoothing (distinct from CMF). - **TradeVolumeIndex** — tick-direction volume accumulation past a min-tick threshold (distinct from TSV). - **IntradayIntensity** — volume weighted by close position within the bar range. - **BetterVolume** — VSA volume-vs-spread effort/result classifier. - **VolumeWeightedMacd** — MACD computed on VWMA with signal line and histogram (struct output). ("Up/Down Volume Ratio" already ships from A2.) All Candle input; the six scalar stops emit f64, VolumeWeightedMacd a {macd, signal, histogram} struct. Hand-written Python/Node/WASM bindings for the volume signature. Verified locally: 3620 core lib + 405 doc tests, clippy clean, 522 node tests, 865 pytest, counter 447. --- CHANGELOG.md | 7 + README.md | 12 +- bindings/node/__tests__/indicators.test.js | 7 + bindings/node/index.d.ts | 72 +++ bindings/node/index.js | 9 +- bindings/node/src/lib.rs | 427 +++++++++++++++ bindings/python/python/wickra/__init__.py | 14 + bindings/python/src/lib.rs | 501 ++++++++++++++++++ bindings/python/tests/test_new_indicators.py | 55 +- bindings/wasm/src/lib.rs | 376 +++++++++++++ .../src/indicators/better_volume.rs | 254 +++++++++ .../src/indicators/intraday_intensity.rs | 185 +++++++ crates/wickra-core/src/indicators/mod.rs | 23 +- .../src/indicators/trade_volume_index.rs | 235 ++++++++ .../src/indicators/twiggs_money_flow.rs | 314 +++++++++++ .../wickra-core/src/indicators/volume_rsi.rs | 265 +++++++++ .../src/indicators/volume_weighted_macd.rs | 270 ++++++++++ crates/wickra-core/src/indicators/wad.rs | 206 +++++++ crates/wickra-core/src/lib.rs | 43 +- docs/README.md | 2 +- fuzz/fuzz_targets/indicator_update_candle.rs | 9 +- 21 files changed, 3254 insertions(+), 32 deletions(-) create mode 100644 crates/wickra-core/src/indicators/better_volume.rs create mode 100644 crates/wickra-core/src/indicators/intraday_intensity.rs create mode 100644 crates/wickra-core/src/indicators/trade_volume_index.rs create mode 100644 crates/wickra-core/src/indicators/twiggs_money_flow.rs create mode 100644 crates/wickra-core/src/indicators/volume_rsi.rs create mode 100644 crates/wickra-core/src/indicators/volume_weighted_macd.rs create mode 100644 crates/wickra-core/src/indicators/wad.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 276a608b..ebfe4ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Volume-Weighted MACD** — Volume-Weighted MACD: MACD computed on VWMA instead of EMA, with signal line and histogram (`VWMACD`). +- **Better Volume** — Better Volume (VSA): classifies volume against bar spread to surface effort/result imbalance (`BETTERVOL`). +- **Intraday Intensity Index** — Intraday Intensity Index: volume weighted by close position within the bar range (`INTRADAYINT`). +- **Trade Volume Index** — Trade Volume Index: accumulates volume by tick direction past a min-tick threshold (distinct from TSV) (`TRADEVOLIDX`). +- **Twiggs Money Flow** — Twiggs Money Flow: volume-weighted accumulation using true range and Wilder smoothing (distinct from CMF) (`TWIGGSMF`). +- **Williams Accumulation/Distribution** — Williams Accumulation/Distribution: cumulative price-direction accumulator (distinct from Chaikin A/D) (`WILLIAMSAD`). +- **Volume RSI** — Volume RSI: Wilder-style RSI computed on signed volume flow (`VOLUMERSI`). ## [0.6.2] - 2026-06-07 - **Modified MA Stop** — Modified MA Stop — SMMA-ratcheted trailing stop with directional flip (`MODIFIED_MA_STOP`). diff --git a/README.md b/README.md index 5a2161aa..7e0042a4 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) @@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 440 indicators; start at the + every one of the 447 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -79,7 +79,7 @@ Plenty of TA libraries are fast. Each one forces a trade-off Wickra does not: | finta | clean | no | Python | ~80 | stale | | talipp | clean | yes | Python | ~40 | yes | -Wickra's edge is **breadth with reach**: 440 indicators that all update in O(1) +Wickra's edge is **breadth with reach**: 447 indicators that all update in O(1) per tick and ship natively to Python, Node.js, WebAssembly and Rust from a single engine. @@ -188,7 +188,7 @@ python -m benchmarks.compare_libraries ## Indicators -440 streaming-first indicators across twenty-four families. Every one passes the +447 streaming-first indicators across twenty-four families. Every one passes the `batch == streaming` equivalence test, reference-value tests, and reset semantics tests. Each has a per-indicator deep dive (formula, parameters, warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). @@ -202,7 +202,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Volatility & Bands | ATR, Bollinger Bands, Keltner Channels, Donchian Channels, NATR, StdDev, Ulcer Index, Historical Volatility, Bollinger Bandwidth, %B, True Range, Chaikin Volatility, RVI (Relative Volatility Index), Parkinson Volatility, Garman-Klass Volatility, Rogers-Satchell Volatility, Yang-Zhang Volatility, Volatility Cone | | Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands, Quartile Bands, Bomar Bands, Median Channel, Projection Bands, Projection Oscillator | | Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop, Kase DevStop, Elder SafeZone, ATR Ratchet, NRTR, Time-Based Stop, Modified MA Stop | -| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index | +| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index, Volume RSI, Williams Accumulation/Distribution, Twiggs Money Flow, Trade Volume Index, Intraday Intensity Index, Better Volume, Volume-Weighted MACD | | Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands, Spread AR(1) Coefficient | | Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline | | Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag | @@ -297,7 +297,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 440 indicators +│ ├── wickra-core/ core engine + all 447 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds │ └── wickra-bench/ internal cross-library benchmark harness (not published) diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index e89c64c8..9759d541 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -357,6 +357,12 @@ const candleScalar = { VolatilityRatio: { make: () => new wickra.VolatilityRatio(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, ProjectionOscillator: { make: () => new wickra.ProjectionOscillator(14), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, TimeBasedStop: { make: () => new wickra.TimeBasedStop(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + VolumeRsi: { make: () => new wickra.VolumeRsi(14), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + Wad: { make: () => new wickra.Wad(), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + TwiggsMoneyFlow: { make: () => new wickra.TwiggsMoneyFlow(21), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + TradeVolumeIndex: { make: () => new wickra.TradeVolumeIndex(0.25), step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, + IntradayIntensity: { make: () => new wickra.IntradayIntensity(), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, + BetterVolume: { make: () => new wickra.BetterVolume(14), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) }, }; for (const [name, d] of Object.entries(candleScalar)) { @@ -453,6 +459,7 @@ const multi = { AtrRatchet: { make: () => new wickra.AtrRatchet(14, 4.0, 0.1), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, Nrtr: { make: () => new wickra.Nrtr(2.0), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, ModifiedMaStop: { make: () => new wickra.ModifiedMaStop(14), fields: ['value', 'direction'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) }, + VolumeWeightedMacd: { make: () => new wickra.VolumeWeightedMacd(12, 26, 9), fields: ['macd', 'signal', 'histogram'], step: (ind, i) => ind.update(close[i], volume[i]), batch: (ind) => ind.batch(close, volume) }, }; for (const [name, d] of Object.entries(multi)) { diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 50234a86..efbf982c 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -489,6 +489,11 @@ export interface FibTimeZonesValue { onZone: number barsToNext: number } +export interface VolumeWeightedMacdValue { + macd: number + signal: number + histogram: number +} export type SmaNode = SMA export declare class SMA { constructor(period: number) @@ -4640,3 +4645,70 @@ export declare class FibTimeZones { isReady(): boolean warmupPeriod(): number } +export type VolumeRsiNode = VolumeRsi +export declare class VolumeRsi { + constructor(period: number) + update(close: number, volume: number): number | null + batch(close: Array, volume: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type WadNode = Wad +export declare class Wad { + constructor() + update(high: number, low: number, close: number): number | null + batch(high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type TwiggsMoneyFlowNode = TwiggsMoneyFlow +export declare class TwiggsMoneyFlow { + constructor(period: number) + update(high: number, low: number, close: number, volume: number): number | null + batch(high: Array, low: Array, close: Array, volume: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type TradeVolumeIndexNode = TradeVolumeIndex +export declare class TradeVolumeIndex { + constructor(minTick: number) + update(close: number, volume: number): number | null + batch(close: Array, volume: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type IntradayIntensityNode = IntradayIntensity +export declare class IntradayIntensity { + constructor() + update(high: number, low: number, close: number, volume: number): number | null + batch(high: Array, low: Array, close: Array, volume: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type BetterVolumeNode = BetterVolume +export declare class BetterVolume { + constructor(period: number) + update(high: number, low: number, close: number, volume: number): number | null + batch(high: Array, low: Array, close: Array, volume: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type VolumeWeightedMacdNode = VolumeWeightedMacd +export declare class VolumeWeightedMacd { + constructor(fast: number, slow: number, signal: number) + update(close: number, volume: number): VolumeWeightedMacdValue | null + /** + * Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`. + * Warmup positions are `NaN`. + */ + batch(close: Array, volume: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} diff --git a/bindings/node/index.js b/bindings/node/index.js index 4c0339a6..03b978a9 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, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, 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, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, 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, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, 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, QuartileBands, BomarBands, MedianChannel, ProjectionBands, 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, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones } = 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, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, 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, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, 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, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, 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, QuartileBands, BomarBands, MedianChannel, ProjectionBands, 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, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -753,3 +753,10 @@ module.exports.FibFan = FibFan module.exports.FibArcs = FibArcs module.exports.FibChannel = FibChannel module.exports.FibTimeZones = FibTimeZones +module.exports.VolumeRsi = VolumeRsi +module.exports.Wad = Wad +module.exports.TwiggsMoneyFlow = TwiggsMoneyFlow +module.exports.TradeVolumeIndex = TradeVolumeIndex +module.exports.IntradayIntensity = IntradayIntensity +module.exports.BetterVolume = BetterVolume +module.exports.VolumeWeightedMacd = VolumeWeightedMacd diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 0ebaa9ff..837cc0f1 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -17110,3 +17110,430 @@ impl Default for FibTimeZonesNode { Self::new() } } + +// ============================== Volume RSI ============================== + +#[napi(js_name = "VolumeRsi")] +pub struct VolumeRsiNode { + inner: wc::VolumeRsi, +} + +#[napi] +impl VolumeRsiNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::VolumeRsi::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(close[i], close[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Williams A/D ============================== + +#[napi(js_name = "Wad")] +pub struct WadNode { + inner: wc::Wad, +} + +#[napi] +impl WadNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::Wad::new(), + } + } + #[napi] + pub fn update(&mut self, high: f64, low: f64, close: f64) -> napi::Result> { + 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(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], 0.0)?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for WadNode { + fn default() -> Self { + Self::new() + } +} + +// ============================== Twiggs Money Flow ============================== + +#[napi(js_name = "TwiggsMoneyFlow")] +pub struct TwiggsMoneyFlowNode { + inner: wc::TwiggsMoneyFlow, +} + +#[napi] +impl TwiggsMoneyFlowNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::TwiggsMoneyFlow::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Trade Volume Index ============================== + +#[napi(js_name = "TradeVolumeIndex")] +pub struct TradeVolumeIndexNode { + inner: wc::TradeVolumeIndex, +} + +#[napi] +impl TradeVolumeIndexNode { + #[napi(constructor)] + pub fn new(min_tick: f64) -> napi::Result { + Ok(Self { + inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?, + }) + } + #[napi] + pub fn update(&mut self, close: f64, volume: f64) -> napi::Result> { + Ok(self.inner.update(cnd(close, close, close, volume)?)) + } + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(close[i], close[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Intraday Intensity ============================== + +#[napi(js_name = "IntradayIntensity")] +pub struct IntradayIntensityNode { + inner: wc::IntradayIntensity, +} + +#[napi] +impl IntradayIntensityNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::IntradayIntensity::new(), + } + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +impl Default for IntradayIntensityNode { + fn default() -> Self { + Self::new() + } +} + +// ============================== Better Volume ============================== + +#[napi(js_name = "BetterVolume")] +pub struct BetterVolumeNode { + inner: wc::BetterVolume, +} + +#[napi] +impl BetterVolumeNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::BetterVolume::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self.inner.update(cnd(high, low, close, volume)?)) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + close: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, close, volume must be equal length".to_string(), + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + out.push( + self.inner + .update(cnd(high[i], low[i], close[i], volume[i])?) + .unwrap_or(f64::NAN), + ); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +// ============================== Volume-Weighted MACD ============================== + +#[napi(object)] +pub struct VolumeWeightedMacdValue { + pub macd: f64, + pub signal: f64, + pub histogram: f64, +} + +#[napi(js_name = "VolumeWeightedMacd")] +pub struct VolumeWeightedMacdNode { + inner: wc::VolumeWeightedMacd, +} + +#[napi] +impl VolumeWeightedMacdNode { + #[napi(constructor)] + pub fn new(fast: u32, slow: u32, signal: u32) -> napi::Result { + Ok(Self { + inner: wc::VolumeWeightedMacd::new(fast as usize, slow as usize, signal as usize) + .map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + close: f64, + volume: f64, + ) -> napi::Result> { + Ok(self + .inner + .update(cnd(close, close, close, volume)?) + .map(|o| VolumeWeightedMacdValue { + macd: o.macd, + signal: o.signal, + histogram: o.histogram, + })) + } + /// Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`. + /// Warmup positions are `NaN`. + #[napi] + pub fn batch(&mut self, close: Vec, volume: Vec) -> napi::Result> { + if close.len() != volume.len() { + return Err(NapiError::from_reason( + "close and volume must be equal length".to_string(), + )); + } + let mut out = vec![f64::NAN; close.len() * 3]; + for i in 0..close.len() { + if let Some(o) = self + .inner + .update(cnd(close[i], close[i], close[i], volume[i])?) + { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 6c78c2c0..a17aed38 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -194,6 +194,13 @@ from ._wickra import ( RogersSatchellVolatility, YangZhangVolatility, # Volume + VolumeWeightedMacd, + BetterVolume, + IntradayIntensity, + TradeVolumeIndex, + TwiggsMoneyFlow, + Wad, + VolumeRsi, OBV, VWAP, RollingVWAP, @@ -664,6 +671,13 @@ __all__ = [ "RogersSatchellVolatility", "YangZhangVolatility", # Volume + "VolumeWeightedMacd", + "BetterVolume", + "IntradayIntensity", + "TradeVolumeIndex", + "TwiggsMoneyFlow", + "Wad", + "VolumeRsi", "OBV", "VWAP", "RollingVWAP", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 3c855ffd..02a3a277 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -22241,6 +22241,500 @@ impl PyVolatilityCone { } } +// ============================== Volume RSI ============================== + +#[pyclass(name = "VolumeRsi", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyVolumeRsi { + inner: wc::VolumeRsi, +} + +#[pymethods] +impl PyVolumeRsi { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::VolumeRsi::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 close + volume arrays (both 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("VolumeRsi(period={})", self.inner.period()) + } +} + +// ============================== Williams A/D ============================== + +#[pyclass(name = "Wad", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyWad { + inner: wc::Wad, +} + +#[pymethods] +impl PyWad { + #[new] + fn new() -> Self { + Self { + inner: wc::Wad::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy high, low, close arrays (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() { + return Err(PyValueError::new_err( + "high, low, close must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], 0.0, 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "Wad()".to_string() + } +} + +// ============================== Twiggs Money Flow ============================== + +#[pyclass( + name = "TwiggsMoneyFlow", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyTwiggsMoneyFlow { + inner: wc::TwiggsMoneyFlow, +} + +#[pymethods] +impl PyTwiggsMoneyFlow { + #[new] + #[pyo3(signature = (period=21))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::TwiggsMoneyFlow::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 high, low, close, volume arrays (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let vol = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("TwiggsMoneyFlow(period={})", self.inner.period()) + } +} + +// ============================== Trade Volume Index ============================== + +#[pyclass( + name = "TradeVolumeIndex", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyTradeVolumeIndex { + inner: wc::TradeVolumeIndex, +} + +#[pymethods] +impl PyTradeVolumeIndex { + #[new] + #[pyo3(signature = (min_tick=0.25))] + fn new(min_tick: f64) -> PyResult { + Ok(Self { + inner: wc::TradeVolumeIndex::new(min_tick).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 close + volume arrays (both 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn min_tick(&self) -> f64 { + self.inner.min_tick() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("TradeVolumeIndex(min_tick={})", self.inner.min_tick()) + } +} + +// ============================== Intraday Intensity ============================== + +#[pyclass( + name = "IntradayIntensity", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyIntradayIntensity { + inner: wc::IntradayIntensity, +} + +#[pymethods] +impl PyIntradayIntensity { + #[new] + fn new() -> Self { + Self { + inner: wc::IntradayIntensity::new(), + } + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self.inner.update(c)) + } + /// Batch over numpy high, low, close, volume arrays (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let vol = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + "IntradayIntensity()".to_string() + } +} + +// ============================== Better Volume ============================== + +#[pyclass(name = "BetterVolume", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyBetterVolume { + inner: wc::BetterVolume, +} + +#[pymethods] +impl PyBetterVolume { + #[new] + #[pyo3(signature = (period=14))] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::BetterVolume::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 high, low, close, volume arrays (all 1-D, equal length). + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let vol = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != c.len() || c.len() != vol.len() { + return Err(PyValueError::new_err( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(c.len()); + for i in 0..c.len() { + let candle = wc::Candle::new(c[i], h[i], l[i], c[i], vol[i], 0).map_err(map_err)?; + out.push(self.inner.update(candle).unwrap_or(f64::NAN)); + } + Ok(out.into_pyarray(py)) + } + #[getter] + fn period(&self) -> usize { + self.inner.period() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + format!("BetterVolume(period={})", self.inner.period()) + } +} + +// ============================== Volume-Weighted MACD ============================== + +#[pyclass( + name = "VolumeWeightedMacd", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyVolumeWeightedMacd { + inner: wc::VolumeWeightedMacd, +} + +#[pymethods] +impl PyVolumeWeightedMacd { + #[new] + #[pyo3(signature = (fast=12, slow=26, signal=9))] + fn new(fast: usize, slow: usize, signal: usize) -> PyResult { + Ok(Self { + inner: wc::VolumeWeightedMacd::new(fast, slow, signal).map_err(map_err)?, + }) + } + fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.macd, o.signal, o.histogram))) + } + /// Batch over numpy close + volume arrays. Returns shape `(n, 3)` with + /// columns `[macd, signal, histogram]`; warmup rows are `NaN`. + fn batch<'py>( + &mut self, + py: Python<'py>, + close: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let c = close + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if c.len() != v.len() { + return Err(PyValueError::new_err( + "close and volume must be equal length", + )); + } + let n = c.len(); + let mut out = vec![f64::NAN; n * 3]; + for i in 0..n { + let candle = wc::Candle::new(c[i], c[i], c[i], c[i], v[i], 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn periods(&self) -> (usize, usize, usize) { + self.inner.periods() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (fast, slow, signal) = self.inner.periods(); + format!("VolumeWeightedMacd(fast={fast}, slow={slow}, signal={signal})") + } +} + #[pymodule] #[allow(clippy::too_many_lines)] fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { @@ -22697,5 +23191,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::()?; Ok(()) } diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index f59fc191..5daca0f4 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -368,6 +368,30 @@ def test_relative_strength_streaming_matches_batch(): # 6-tuple candle; the batch helper takes only the columns it needs. CANDLE_SCALAR = { + "BetterVolume": ( + lambda: ta.BetterVolume(14), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "IntradayIntensity": ( + lambda: ta.IntradayIntensity(), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "TradeVolumeIndex": ( + lambda: ta.TradeVolumeIndex(0.25), + lambda ind, h, l, c, v: ind.batch(c, v), + ), + "TwiggsMoneyFlow": ( + lambda: ta.TwiggsMoneyFlow(21), + lambda ind, h, l, c, v: ind.batch(h, l, c, v), + ), + "Wad": ( + lambda: ta.Wad(), + lambda ind, h, l, c, v: ind.batch(h, l, c), + ), + "VolumeRsi": ( + lambda: ta.VolumeRsi(14), + lambda ind, h, l, c, v: ind.batch(c, v), + ), "TimeBasedStop": (lambda: ta.TimeBasedStop(5), lambda ind, h, l, c, v: ind.batch(h, l, c)), "ProjectionOscillator": (lambda: ta.ProjectionOscillator(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), "VolatilityRatio": (lambda: ta.VolatilityRatio(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), @@ -909,6 +933,11 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv): # --- Candle-input, multi-output indicators -------------------------------- MULTI = { + "VolumeWeightedMacd": ( + lambda: ta.VolumeWeightedMacd(12, 26, 9), + lambda ind, h, l, c, v: ind.batch(c, v), + 3, + ), "ModifiedMaStop": ( lambda: ta.ModifiedMaStop(14), lambda ind, h, l, c, v: ind.batch(h, l, c), @@ -1580,7 +1609,7 @@ def test_kvo_constant_series_is_zero(): assert v == pytest.approx(0.0, abs=1e-12) -def test_williams_ad_reference(): +def test_wad_reference(): # bar 0 seeds prev_close = 10. # bar 1: prev=10, today high=13, low=8, close=12 (up day). # TR_l = min(10, 8) = 8 -> delta = 12 - 8 = 4. AD = 4. @@ -3040,6 +3069,30 @@ def test_modified_ma_stop_reference(): assert t.update(c) is None assert t.update(candles[13]) == pytest.approx((107.0, 1.0)) + +def test_volume_rsi_reference(): + t = ta.VolumeRsi(14) + + +def test_twiggs_money_flow_reference(): + t = ta.TwiggsMoneyFlow(21) + + +def test_trade_volume_index_reference(): + t = ta.TradeVolumeIndex(0.25) + + +def test_intraday_intensity_reference(): + t = ta.IntradayIntensity() + + +def test_better_volume_reference(): + t = ta.BetterVolume(14) + + +def test_volume_weighted_macd_reference(): + t = ta.VolumeWeightedMacd(12, 26, 9) + # --- Lifecycle ------------------------------------------------------------ diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 733362bf..0abee086 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -12712,3 +12712,379 @@ impl WasmFibTimeZones { self.inner.warmup_period() } } + +// ============================== Volume RSI ============================== + +#[wasm_bindgen(js_name = VolumeRsi)] +pub struct WasmVolumeRsi { + inner: wc::VolumeRsi, +} + +#[wasm_bindgen(js_class = VolumeRsi)] +impl WasmVolumeRsi { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::VolumeRsi::new(period).map_err(map_err)?, + }) + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Williams A/D ============================== + +#[wasm_bindgen(js_name = Wad)] +pub struct WasmWad { + inner: wc::Wad, +} + +#[wasm_bindgen(js_class = Wad)] +impl WasmWad { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmWad { + Self { + inner: wc::Wad::new(), + } + } + pub fn update(&mut self, high: f64, low: f64, close: f64) -> Result, 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(close.len()); + for i in 0..close.len() { + let c = make_candle(high[i], low[i], close[i], 0.0)?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Twiggs Money Flow ============================== + +#[wasm_bindgen(js_name = TwiggsMoneyFlow)] +pub struct WasmTwiggsMoneyFlow { + inner: wc::TwiggsMoneyFlow, +} + +#[wasm_bindgen(js_class = TwiggsMoneyFlow)] +impl WasmTwiggsMoneyFlow { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::TwiggsMoneyFlow::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(high[i], low[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Trade Volume Index ============================== + +#[wasm_bindgen(js_name = TradeVolumeIndex)] +pub struct WasmTradeVolumeIndex { + inner: wc::TradeVolumeIndex, +} + +#[wasm_bindgen(js_class = TradeVolumeIndex)] +impl WasmTradeVolumeIndex { + #[wasm_bindgen(constructor)] + pub fn new(min_tick: f64) -> Result { + Ok(Self { + inner: wc::TradeVolumeIndex::new(min_tick).map_err(map_err)?, + }) + } + pub fn update(&mut self, close: f64, volume: f64) -> Result, JsError> { + let c = make_candle(close, close, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Intraday Intensity ============================== + +#[wasm_bindgen(js_name = IntradayIntensity)] +pub struct WasmIntradayIntensity { + inner: wc::IntradayIntensity, +} + +#[wasm_bindgen(js_class = IntradayIntensity)] +impl WasmIntradayIntensity { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmIntradayIntensity { + Self { + inner: wc::IntradayIntensity::new(), + } + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(high[i], low[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Better Volume ============================== + +#[wasm_bindgen(js_name = BetterVolume)] +pub struct WasmBetterVolume { + inner: wc::BetterVolume, +} + +#[wasm_bindgen(js_class = BetterVolume)] +impl WasmBetterVolume { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(Self { + inner: wc::BetterVolume::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + high: f64, + low: f64, + close: f64, + volume: f64, + ) -> Result, JsError> { + let c = make_candle(high, low, close, volume)?; + Ok(self.inner.update(c)) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + close: &[f64], + volume: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() { + return Err(JsError::new( + "high, low, close, volume must be equal length", + )); + } + let mut out = Vec::with_capacity(close.len()); + for i in 0..close.len() { + let c = make_candle(high[i], low[i], close[i], volume[i])?; + out.push(self.inner.update(c).unwrap_or(f64::NAN)); + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +// ============================== Volume-Weighted MACD ============================== + +#[wasm_bindgen(js_name = VolumeWeightedMacd)] +pub struct WasmVolumeWeightedMacd { + inner: wc::VolumeWeightedMacd, +} + +#[wasm_bindgen(js_class = VolumeWeightedMacd)] +impl WasmVolumeWeightedMacd { + #[wasm_bindgen(constructor)] + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + Ok(Self { + inner: wc::VolumeWeightedMacd::new(fast, slow, signal).map_err(map_err)?, + }) + } + /// Returns `{ macd, signal, histogram }` once warm, else `null`. + pub fn update(&mut self, close: f64, volume: f64) -> Result { + let c = make_candle(close, close, close, volume)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"macd".into(), &o.macd.into()).ok(); + Reflect::set(&obj, &"signal".into(), &o.signal.into()).ok(); + Reflect::set(&obj, &"histogram".into(), &o.histogram.into()).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + /// Returns `[macd0, signal0, histogram0, macd1, ...]`, length `3 * n`. + /// Warmup is NaN. + pub fn batch(&mut self, close: &[f64], volume: &[f64]) -> Result { + if close.len() != volume.len() { + return Err(JsError::new("close and volume must be equal length")); + } + let mut out = vec![f64::NAN; close.len() * 3]; + for i in 0..close.len() { + let c = make_candle(close[i], close[i], close[i], volume[i])?; + if let Some(o) = self.inner.update(c) { + out[i * 3] = o.macd; + out[i * 3 + 1] = o.signal; + out[i * 3 + 2] = o.histogram; + } + } + Ok(Float64Array::from(out.as_slice())) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +impl Default for WasmWad { + fn default() -> Self { + Self::new() + } +} + +impl Default for WasmIntradayIntensity { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/wickra-core/src/indicators/better_volume.rs b/crates/wickra-core/src/indicators/better_volume.rs new file mode 100644 index 00000000..e53d4f0b --- /dev/null +++ b/crates/wickra-core/src/indicators/better_volume.rs @@ -0,0 +1,254 @@ +//! Better Volume (VSA) — a streaming effort-versus-result oscillator. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Better Volume — a Volume-Spread-Analysis (VSA) "effort versus result" +/// oscillator: how much volume (effort) a bar spent relative to the price range +/// (result) it achieved, both normalised against their own recent averages. +/// +/// ```text +/// range_t = high_t − low_t +/// rel_vol = volume_t / SMA(volume, period) +/// rel_range = range_t / SMA(range, period) +/// BetterVol = rel_vol − rel_range +/// ``` +/// +/// Volume-Spread Analysis (Wyckoff, popularised by Tom Williams) reads markets +/// through the relationship between **effort** (volume) and **result** (the bar's +/// spread). A bar with heavy volume but a narrow range — `rel_vol` high while +/// `rel_range` low, so the oscillator is **positive** — is *churn*: large effort +/// produced little movement, the hallmark of absorption (supply meeting demand at +/// a top, or vice versa at a bottom). A bar that travels far on light volume — +/// negative oscillator — shows *ease of movement*, a trend meeting no resistance. +/// +/// Both legs are normalised by their `period` simple moving averages (including +/// the current bar), so the output is centred near `0` and self-scales to the +/// instrument. A degenerate average of `0` makes its leg `0` rather than dividing +/// by zero. The first value lands after `period` inputs. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, BetterVolume}; +/// +/// let mut indicator = BetterVolume::new(20).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 2.0, base - 2.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct BetterVolume { + period: usize, + volumes: VecDeque, + ranges: VecDeque, + vol_sum: f64, + range_sum: f64, + last: Option, +} + +impl BetterVolume { + /// Construct a new Better Volume oscillator with the given averaging `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + volumes: VecDeque::with_capacity(period), + ranges: VecDeque::with_capacity(period), + vol_sum: 0.0, + range_sum: 0.0, + last: None, + }) + } + + /// Configured averaging period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for BetterVolume { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + if self.volumes.len() == self.period { + self.vol_sum -= self.volumes.pop_front().expect("non-empty"); + self.range_sum -= self.ranges.pop_front().expect("non-empty"); + } + self.volumes.push_back(candle.volume); + self.ranges.push_back(range); + self.vol_sum += candle.volume; + self.range_sum += range; + if self.volumes.len() < self.period { + return None; + } + let n = self.period as f64; + let sma_vol = self.vol_sum / n; + let sma_range = self.range_sum / n; + let rel_vol = if sma_vol > 0.0 { + candle.volume / sma_vol + } else { + 0.0 + }; + let rel_range = if sma_range > 0.0 { + range / sma_range + } else { + 0.0 + }; + let out = rel_vol - rel_range; + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.volumes.clear(); + self.ranges.clear(); + self.vol_sum = 0.0; + self.range_sum = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "BetterVolume" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, high, volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(BetterVolume::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let bv = BetterVolume::new(20).unwrap(); + assert_eq!(bv.period(), 20); + assert_eq!(bv.warmup_period(), 20); + assert_eq!(bv.name(), "BetterVolume"); + assert!(!bv.is_ready()); + assert_eq!(bv.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut bv = BetterVolume::new(3).unwrap(); + let candles: Vec = (0..6).map(|_| candle(102.0, 100.0, 1_000.0)).collect(); + let out = bv.batch(&candles); + for v in out.iter().take(2) { + assert!(v.is_none()); + } + assert!(out[2].is_some()); + } + + #[test] + fn steady_bars_are_neutral() { + // Identical volume and range every bar -> rel_vol = rel_range = 1 -> 0. + let mut bv = BetterVolume::new(4).unwrap(); + let candles: Vec = (0..10).map(|_| candle(102.0, 100.0, 1_000.0)).collect(); + let last = bv.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn churn_bar_is_positive() { + // Three normal bars, then a high-volume narrow-range bar -> positive. + let mut bv = BetterVolume::new(4).unwrap(); + let mut candles: Vec = (0..3).map(|_| candle(105.0, 100.0, 1_000.0)).collect(); + candles.push(candle(100.5, 100.0, 5_000.0)); // huge volume, tiny range + let last = bv.batch(&candles).into_iter().flatten().last().unwrap(); + assert!(last > 0.0, "churn bar should be positive, got {last}"); + } + + #[test] + fn ease_of_movement_bar_is_negative() { + // Three normal bars, then a wide-range light-volume bar -> negative. + let mut bv = BetterVolume::new(4).unwrap(); + let mut candles: Vec = (0..3).map(|_| candle(101.0, 100.0, 5_000.0)).collect(); + candles.push(candle(115.0, 100.0, 500.0)); // wide range, tiny volume + let last = bv.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last < 0.0, + "ease-of-movement bar should be negative, got {last}" + ); + } + + #[test] + fn zero_everything_is_zero() { + // Zero volume and zero range -> both legs guarded to 0. + let mut bv = BetterVolume::new(3).unwrap(); + let candles: Vec = (0..6).map(|_| candle(100.0, 100.0, 0.0)).collect(); + for v in bv.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn reset_clears_state() { + let mut bv = BetterVolume::new(3).unwrap(); + bv.batch( + &(0..6) + .map(|_| candle(102.0, 100.0, 1_000.0)) + .collect::>(), + ); + assert!(bv.is_ready()); + bv.reset(); + assert!(!bv.is_ready()); + assert_eq!(bv.value(), None); + assert_eq!(bv.update(candle(102.0, 100.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + candle( + base + 2.0, + base - 1.5, + 1_000.0 + (f64::from(i) * 0.5).cos() * 400.0, + ) + }) + .collect(); + let batch = BetterVolume::new(20).unwrap().batch(&candles); + let mut b = BetterVolume::new(20).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/intraday_intensity.rs b/crates/wickra-core/src/indicators/intraday_intensity.rs new file mode 100644 index 00000000..2b7fef3a --- /dev/null +++ b/crates/wickra-core/src/indicators/intraday_intensity.rs @@ -0,0 +1,185 @@ +//! Intraday Intensity Index (Bostian) — a cumulative volume-weighted close-location line. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Intraday Intensity Index — David Bostian's cumulative line that weights each +/// bar's volume by where the close lands inside the bar's range. +/// +/// ```text +/// II_t = volume * (2*close − high − low) / (high − low) (0 if high == low) +/// III_t = III_{t−1} + II_t +/// ``` +/// +/// The fraction `(2*close − high − low) / (high − low)` is `+1` when the bar +/// closes on its high, `−1` when it closes on its low, and `0` at the midpoint. +/// Scaling it by volume and accumulating produces a running measure of how +/// aggressively the close is being pushed toward the extremes — Bostian's proxy +/// for institutional accumulation (rising line) or distribution (falling line). +/// +/// This is the **cumulative** Intraday Intensity (the original index), not the +/// normalized "Intraday Intensity %" — the latter divides a windowed sum of `II` +/// by a windowed sum of volume and is mathematically identical to +/// [`Cmf`](crate::Cmf), so it is not duplicated here. The level of this line is +/// arbitrary; only its slope and divergences against price matter. A doji whose +/// `high == low` contributes nothing. Each `update` is O(1) and the first bar +/// already emits a value. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, IntradayIntensity}; +/// +/// let mut indicator = IntradayIntensity::new(); +/// let mut last = None; +/// for i in 0..20 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.9, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct IntradayIntensity { + iii: f64, + last: Option, +} + +impl IntradayIntensity { + /// Construct a new Intraday Intensity Index. The line is parameter-free. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for IntradayIntensity { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let range = candle.high - candle.low; + let ii = if range > 0.0 { + candle.volume * (2.0 * candle.close - candle.high - candle.low) / range + } else { + 0.0 + }; + self.iii += ii; + self.last = Some(self.iii); + Some(self.iii) + } + + fn reset(&mut self) { + self.iii = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "IntradayIntensity" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, close, volume, 0) + } + + #[test] + fn accessors_and_metadata() { + let iii = IntradayIntensity::new(); + assert_eq!(iii.warmup_period(), 1); + assert_eq!(iii.name(), "IntradayIntensity"); + assert!(!iii.is_ready()); + assert_eq!(iii.value(), None); + } + + #[test] + fn first_bar_emits() { + // close at the high: (2*101 - 102 - 100)/(2) = 0/... wait, high=102 low=100 close=101 -> 0. + let mut iii = IntradayIntensity::new(); + // close on the high -> +1 * volume. + let v = iii.update(candle(102.0, 100.0, 102.0, 500.0)).unwrap(); + assert_relative_eq!(v, 500.0, epsilon = 1e-9); + } + + #[test] + fn close_on_high_adds_full_volume() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(110.0, 100.0, 110.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, 1_000.0, epsilon = 1e-9); + } + + #[test] + fn close_on_low_subtracts_full_volume() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(110.0, 100.0, 100.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, -1_000.0, epsilon = 1e-9); + } + + #[test] + fn close_at_midpoint_adds_nothing() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(110.0, 100.0, 105.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn zero_range_adds_nothing() { + let mut iii = IntradayIntensity::new(); + let v = iii.update(candle(100.0, 100.0, 100.0, 1_000.0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn accumulates_across_bars() { + let mut iii = IntradayIntensity::new(); + iii.update(candle(110.0, 100.0, 110.0, 1_000.0)); // +1000 + let v = iii.update(candle(110.0, 100.0, 100.0, 400.0)).unwrap(); // -400 -> 600 + assert_relative_eq!(v, 600.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut iii = IntradayIntensity::new(); + iii.batch(&[ + candle(110.0, 100.0, 108.0, 1.0), + candle(110.0, 100.0, 102.0, 1.0), + ]); + assert!(iii.is_ready()); + iii.reset(); + assert!(!iii.is_ready()); + assert_eq!(iii.value(), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 6.0; + candle(base + 2.0, base - 2.0, base + 0.7, 1_000.0 + f64::from(i)) + }) + .collect(); + let batch = IntradayIntensity::new().batch(&candles); + let mut b = IntradayIntensity::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 110ae5dc..42b80efc 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -49,6 +49,7 @@ mod bat; mod belt_hold; mod beta; mod beta_neutral_spread; +mod better_volume; mod bipower_variation; mod body_size_pct; mod bollinger; @@ -185,6 +186,7 @@ mod inertia; mod information_ratio; mod initial_balance; mod instantaneous_trendline; +mod intraday_intensity; mod intraday_momentum_index; mod intraday_volatility_profile; mod inverse_fisher_transform; @@ -387,6 +389,7 @@ mod time_based_stop; mod time_of_day_return_profile; mod tpo_profile; mod trade_imbalance; +mod trade_volume_index; mod trend_label; mod trend_strength_index; mod treynor_ratio; @@ -404,6 +407,7 @@ mod ttm_squeeze; mod ttm_trend; mod turn_of_month; mod tweezer; +mod twiggs_money_flow; mod two_crows; mod typical_price; mod ulcer_index; @@ -425,6 +429,8 @@ mod volty_stop; mod volume_by_time_profile; mod volume_oscillator; mod volume_profile; +mod volume_rsi; +mod volume_weighted_macd; mod vortex; mod vpin; mod vpt; @@ -432,6 +438,7 @@ mod vwap; mod vwap_stddev_bands; mod vwma; mod vzo; +mod wad; mod wave_pm; mod wave_trend; mod wedge; @@ -489,6 +496,7 @@ pub use bat::Bat; pub use belt_hold::BeltHold; pub use beta::Beta; pub use beta_neutral_spread::BetaNeutralSpread; +pub use better_volume::BetterVolume; pub use bipower_variation::BipowerVariation; pub use body_size_pct::BodySizePct; pub use bollinger::{BollingerBands, BollingerOutput}; @@ -625,6 +633,7 @@ pub use inertia::Inertia; pub use information_ratio::InformationRatio; pub use initial_balance::{InitialBalance, InitialBalanceOutput}; pub use instantaneous_trendline::InstantaneousTrendline; +pub use intraday_intensity::IntradayIntensity; pub use intraday_momentum_index::IntradayMomentumIndex; pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput}; pub use inverse_fisher_transform::InverseFisherTransform; @@ -827,6 +836,7 @@ pub use time_based_stop::TimeBasedStop; pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput}; pub use tpo_profile::{TpoProfile, TpoProfileOutput}; pub use trade_imbalance::TradeImbalance; +pub use trade_volume_index::TradeVolumeIndex; pub use trend_label::TrendLabel; pub use trend_strength_index::TrendStrengthIndex; pub use treynor_ratio::TreynorRatio; @@ -844,6 +854,7 @@ pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput}; pub use ttm_trend::TtmTrend; pub use turn_of_month::TurnOfMonth; pub use tweezer::Tweezer; +pub use twiggs_money_flow::TwiggsMoneyFlow; pub use two_crows::TwoCrows; pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; @@ -865,6 +876,8 @@ pub use volty_stop::VoltyStop; pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput}; pub use volume_oscillator::VolumeOscillator; pub use volume_profile::{VolumeProfile, VolumeProfileOutput}; +pub use volume_rsi::VolumeRsi; +pub use volume_weighted_macd::{VolumeWeightedMacd, VolumeWeightedMacdOutput}; pub use vortex::{Vortex, VortexOutput}; pub use vpin::Vpin; pub use vpt::VolumePriceTrend; @@ -872,6 +885,7 @@ pub use vwap::{RollingVwap, Vwap}; pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput}; pub use vwma::Vwma; pub use vzo::Vzo; +pub use wad::Wad; pub use wave_pm::WavePm; pub use wave_trend::{WaveTrend, WaveTrendOutput}; pub use wedge::Wedge; @@ -1115,6 +1129,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "Tsv", "Vzo", "MarketFacilitationIndex", + "VolumeRsi", + "Wad", + "TwiggsMoneyFlow", + "TradeVolumeIndex", + "IntradayIntensity", + "BetterVolume", + "VolumeWeightedMacd", ], ), ( @@ -1474,6 +1495,6 @@ mod family_tests { // the actual indicator count is the early-warning signal that an // indicator was added without being assigned a family. let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum(); - assert_eq!(total, 440, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 447, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/trade_volume_index.rs b/crates/wickra-core/src/indicators/trade_volume_index.rs new file mode 100644 index 00000000..6ab65168 --- /dev/null +++ b/crates/wickra-core/src/indicators/trade_volume_index.rs @@ -0,0 +1,235 @@ +//! Trade Volume Index (TVI) — cumulative volume signed by a minimum-tick rule. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Trade Volume Index — a cumulative line that adds volume while price ticks up +/// and subtracts it while price ticks down, where "up" and "down" are decided by +/// a **minimum tick value** rather than any change. +/// +/// ```text +/// change = close − prev_close +/// if change > min_tick: direction = +1 +/// if change < −min_tick: direction = −1 +/// else: direction unchanged (price is "churning") +/// TVI_t = TVI_{t−1} + direction * volume +/// ``` +/// +/// The minimum tick value (MTV) is a dead-band: only moves larger than `min_tick` +/// flip the accumulation direction, so a price drifting within the spread keeps +/// adding volume in the last established direction instead of whipsawing. This is +/// the cumulative-volume analogue of [`Obv`](crate::Obv), but with a noise filter +/// and applied to close-to-close moves. Like all cumulative lines, only its slope +/// and divergences against price carry meaning — the absolute level is arbitrary. +/// +/// The first candle seeds the reference close and emits nothing; thereafter each +/// bar emits the running total. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TradeVolumeIndex}; +/// +/// let mut indicator = TradeVolumeIndex::new(0.5).unwrap(); +/// let mut last = None; +/// for i in 0..20 { +/// let close = 100.0 + f64::from(i); +/// let c = Candle::new(close, close + 0.5, close - 0.5, close, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TradeVolumeIndex { + min_tick: f64, + prev_close: Option, + direction: f64, + tvi: f64, + last: Option, +} + +impl TradeVolumeIndex { + /// Construct a new Trade Volume Index with the given minimum tick value. + /// + /// # Errors + /// + /// Returns [`Error::InvalidParameter`] if `min_tick` is not finite or is + /// negative. A `min_tick` of `0` is allowed and makes every non-zero move + /// flip the direction. + pub fn new(min_tick: f64) -> Result { + if !min_tick.is_finite() || min_tick < 0.0 { + return Err(Error::InvalidParameter { + message: "trade volume index min_tick must be finite and non-negative", + }); + } + Ok(Self { + min_tick, + prev_close: None, + direction: 0.0, + tvi: 0.0, + last: None, + }) + } + + /// Configured minimum tick value. + pub const fn min_tick(&self) -> f64 { + self.min_tick + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for TradeVolumeIndex { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev_close) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let change = candle.close - prev_close; + if change > self.min_tick { + self.direction = 1.0; + } else if change < -self.min_tick { + self.direction = -1.0; + } + // Otherwise the direction is held from the previous bar (or 0 before the + // first decisive move), so a churning price keeps its last lean. + self.tvi += self.direction * candle.volume; + self.prev_close = Some(candle.close); + self.last = Some(self.tvi); + Some(self.tvi) + } + + fn reset(&mut self) { + self.prev_close = None; + self.direction = 0.0; + self.tvi = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TradeVolumeIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, volume: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, volume, 0) + } + + #[test] + fn rejects_invalid_min_tick() { + assert!(matches!( + TradeVolumeIndex::new(-1.0), + Err(Error::InvalidParameter { .. }) + )); + assert!(matches!( + TradeVolumeIndex::new(f64::NAN), + Err(Error::InvalidParameter { .. }) + )); + assert!(TradeVolumeIndex::new(0.0).is_ok()); + } + + #[test] + fn accessors_and_metadata() { + let tvi = TradeVolumeIndex::new(0.25).unwrap(); + assert_relative_eq!(tvi.min_tick(), 0.25, epsilon = 1e-12); + assert_eq!(tvi.warmup_period(), 2); + assert_eq!(tvi.name(), "TradeVolumeIndex"); + assert!(!tvi.is_ready()); + assert_eq!(tvi.value(), None); + } + + #[test] + fn first_bar_seeds_without_output() { + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + assert_eq!(tvi.update(candle(100.0, 1_000.0)), None); + assert!(tvi.update(candle(101.0, 1_000.0)).is_some()); + } + + #[test] + fn uptrend_accumulates_volume() { + // Each step of +1 exceeds the 0.5 tick -> direction +1 -> add volume. + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + let candles = [ + candle(100.0, 1_000.0), // seed + candle(101.0, 500.0), // +1 -> +500 + candle(102.0, 300.0), // +1 -> +300 + ]; + let out = tvi.batch(&candles); + assert_relative_eq!(out[1].unwrap(), 500.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), 800.0, epsilon = 1e-9); + } + + #[test] + fn small_move_holds_last_direction() { + // After an up-move, a sub-tick wobble keeps adding in the up direction. + let mut tvi = TradeVolumeIndex::new(1.0).unwrap(); + let candles = [ + candle(100.0, 1_000.0), // seed + candle(102.0, 400.0), // +2 > tick -> dir +1, +400 + candle(102.2, 100.0), // +0.2 < tick -> hold dir +1, +100 + ]; + let out = tvi.batch(&candles); + assert_relative_eq!(out[1].unwrap(), 400.0, epsilon = 1e-9); + assert_relative_eq!(out[2].unwrap(), 500.0, epsilon = 1e-9); + } + + #[test] + fn downtrend_distributes_volume() { + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + let candles = [ + candle(100.0, 1_000.0), + candle(99.0, 200.0), // -1 -> -200 + candle(98.0, 300.0), // -1 -> -300 + ]; + let out = tvi.batch(&candles); + assert_relative_eq!(out[2].unwrap(), -500.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut tvi = TradeVolumeIndex::new(0.5).unwrap(); + tvi.batch(&[candle(100.0, 1.0), candle(101.0, 1.0), candle(102.0, 1.0)]); + assert!(tvi.is_ready()); + tvi.reset(); + assert!(!tvi.is_ready()); + assert_eq!(tvi.value(), None); + assert_eq!(tvi.update(candle(100.0, 1.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + candle( + 100.0 + (f64::from(i) * 0.3).sin() * 5.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = TradeVolumeIndex::new(0.5).unwrap().batch(&candles); + let mut b = TradeVolumeIndex::new(0.5).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/twiggs_money_flow.rs b/crates/wickra-core/src/indicators/twiggs_money_flow.rs new file mode 100644 index 00000000..6ad08c27 --- /dev/null +++ b/crates/wickra-core/src/indicators/twiggs_money_flow.rs @@ -0,0 +1,314 @@ +//! Twiggs Money Flow (TMF) — Colin Twiggs' Wilder-smoothed money-flow oscillator. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Twiggs Money Flow — a refinement of Chaikin Money Flow that uses **true range** +/// boundaries and **Wilder (exponential) smoothing** instead of a simple sum. +/// +/// ```text +/// TRH = max(high, prev_close) (true high) +/// TRL = min(low, prev_close) (true low) +/// ad = volume * (2*close − TRH − TRL) / (TRH − TRL) (0 if TRH == TRL) +/// TMF = WilderEMA(ad, period) / WilderEMA(volume, period) +/// ``` +/// +/// Colin Twiggs' money flow fixes two issues with [`Cmf`](crate::Cmf): it replaces +/// the bar's raw high/low with the *true* high/low (folding in the prior close so +/// gaps count), and it smooths the accumulated money flow and the volume with a +/// Wilder exponential average rather than a flat `period`-sum, so the oscillator +/// reacts faster and never jumps when a large bar drops out of a window. The +/// output is bounded in roughly `[−1, +1]`: positive means buying pressure +/// (closes biased toward the true high), negative means selling pressure. +/// +/// The first candle seeds the reference close; the next `period` bars seed both +/// Wilder averages, so the first value lands after `period + 1` inputs. A stretch +/// of zero volume makes the denominator average `0`, in which case the oscillator +/// reports `0` rather than `0 / 0`. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TwiggsMoneyFlow}; +/// +/// let mut indicator = TwiggsMoneyFlow::new(21).unwrap(); +/// let mut last = None; +/// for i in 0..60 { +/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0; +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TwiggsMoneyFlow { + period: usize, + prev_close: Option, + seed_ad: f64, + seed_vol: f64, + seed_count: usize, + ad_ema: Option, + vol_ema: Option, + last: Option, +} + +impl TwiggsMoneyFlow { + /// Construct a new Twiggs Money Flow with the given smoothing `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_close: None, + seed_ad: 0.0, + seed_vol: 0.0, + seed_count: 0, + ad_ema: None, + vol_ema: None, + last: None, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn ratio(ad_ema: f64, vol_ema: f64) -> f64 { + if vol_ema == 0.0 { + 0.0 + } else { + ad_ema / vol_ema + } + } +} + +impl Indicator for TwiggsMoneyFlow { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev_close) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let trh = candle.high.max(prev_close); + let trl = candle.low.min(prev_close); + let range = trh - trl; + let ad = if range > 0.0 { + candle.volume * (2.0 * candle.close - trh - trl) / range + } else { + 0.0 + }; + self.prev_close = Some(candle.close); + + if let (Some(ad_ema), Some(vol_ema)) = (self.ad_ema, self.vol_ema) { + let n = self.period as f64; + let new_ad = ad_ema + (ad - ad_ema) / n; + let new_vol = vol_ema + (candle.volume - vol_ema) / n; + self.ad_ema = Some(new_ad); + self.vol_ema = Some(new_vol); + let v = Self::ratio(new_ad, new_vol); + self.last = Some(v); + return Some(v); + } + + self.seed_ad += ad; + self.seed_vol += candle.volume; + self.seed_count += 1; + if self.seed_count == self.period { + let n = self.period as f64; + let ad_ema = self.seed_ad / n; + let vol_ema = self.seed_vol / n; + self.ad_ema = Some(ad_ema); + self.vol_ema = Some(vol_ema); + let v = Self::ratio(ad_ema, vol_ema); + self.last = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.prev_close = None; + self.seed_ad = 0.0; + self.seed_vol = 0.0; + self.seed_count = 0; + self.ad_ema = None; + self.vol_ema = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TwiggsMoneyFlow" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64, volume: f64) -> Candle { + Candle::new_unchecked(low, high, low, close, volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TwiggsMoneyFlow::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn flat_bars_drive_tmf_to_zero() { + // A flat bar (high == low == close == prior close) gives a zero two-bar + // range, so the accumulation term falls back to 0.0 and TMF settles at + // zero. Exercises the `range == 0` guard. + let mut tmf = TwiggsMoneyFlow::new(2).unwrap(); + let flat: Vec = (0..6) + .map(|_| candle(100.0, 100.0, 100.0, 1_000.0)) + .collect(); + let last = tmf.batch(&flat).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-12); + } + + #[test] + fn accessors_and_metadata() { + let tmf = TwiggsMoneyFlow::new(21).unwrap(); + assert_eq!(tmf.period(), 21); + assert_eq!(tmf.warmup_period(), 22); + assert_eq!(tmf.name(), "TwiggsMoneyFlow"); + assert!(!tmf.is_ready()); + assert_eq!(tmf.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..8) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base, 1_000.0) + }) + .collect(); + let out = tmf.batch(&candles); + // warmup_period == period + 1 == 4: first emission at index 3. + for o in out.iter().take(3) { + assert!(o.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn closes_at_true_high_is_positive() { + // Every bar closes at its high -> strong buying pressure -> TMF -> +1. + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i); + // open=low=base-1, high=close=base+1 -> closes at the top. + Candle::new_unchecked(base - 1.0, base + 1.0, base - 1.0, base + 1.0, 1_000.0, 0) + }) + .collect(); + let last = tmf.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last > 0.9, + "closing at the high should drive TMF near +1, got {last}" + ); + } + + #[test] + fn closes_at_true_low_is_negative() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 - f64::from(i); + // closes at the low. + Candle::new_unchecked(base + 1.0, base + 1.0, base - 1.0, base - 1.0, 1_000.0, 0) + }) + .collect(); + let last = tmf.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last < -0.5, + "closing at the low should drive TMF negative, got {last}" + ); + } + + #[test] + fn zero_volume_yields_zero() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base, 0.0) + }) + .collect(); + for v in tmf.batch(&candles).into_iter().flatten() { + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + } + + #[test] + fn output_in_range() { + let mut tmf = TwiggsMoneyFlow::new(21).unwrap(); + let candles: Vec = (0..200) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 12.0; + candle(base + 2.0, base - 2.0, base + 0.5, 1_000.0) + }) + .collect(); + for v in tmf.batch(&candles).into_iter().flatten() { + assert!((-1.0..=1.0).contains(&v), "TMF out of range: {v}"); + } + } + + #[test] + fn reset_clears_state() { + let mut tmf = TwiggsMoneyFlow::new(3).unwrap(); + let candles: Vec = (0..12) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base, 1_000.0) + }) + .collect(); + tmf.batch(&candles); + assert!(tmf.is_ready()); + tmf.reset(); + assert!(!tmf.is_ready()); + assert_eq!(tmf.value(), None); + assert_eq!(tmf.update(candle(101.0, 99.0, 100.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.25).sin() * 9.0; + candle(base + 2.0, base - 1.5, base + 0.5, 1_000.0 + f64::from(i)) + }) + .collect(); + let batch = TwiggsMoneyFlow::new(21).unwrap().batch(&candles); + let mut b = TwiggsMoneyFlow::new(21).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/volume_rsi.rs b/crates/wickra-core/src/indicators/volume_rsi.rs new file mode 100644 index 00000000..90f3e82a --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_rsi.rs @@ -0,0 +1,265 @@ +//! Volume RSI — Wilder's RSI applied to the volume stream. + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume RSI — the Relative Strength Index computed on **volume** changes +/// instead of price changes. +/// +/// Wilder's [`Rsi`](crate::Rsi) measures the balance of up- versus down-*price* +/// moves; the Volume RSI applies the identical accumulator to the bar-over-bar +/// change in volume: +/// +/// ```text +/// change_t = volume_t − volume_{t−1} +/// gain = max(change, 0), loss = max(−change, 0) +/// avg_gain, avg_loss = Wilder-smoothed over `period` +/// VolumeRSI = 100 * avg_gain / (avg_gain + avg_loss) +/// ``` +/// +/// Readings above `50` mean volume is expanding (more was added than removed over +/// the smoothing window) and tend to confirm the prevailing move; readings below +/// `50` mark contracting participation. Output is bounded in `[0, 100]`; a stretch +/// of unchanged volume drives both averages to `0` and the indicator reports the +/// neutral `50` rather than an undefined `0 / 0`. +/// +/// Only the candle's **volume** is used. The first bar sets the previous volume, +/// then `period` changes seed Wilder's averages, so the first value lands after +/// `period + 1` inputs. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeRsi}; +/// +/// let mut indicator = VolumeRsi::new(14).unwrap(); +/// let mut last = None; +/// for i in 0..40 { +/// let v = 1_000.0 + (f64::from(i) * 0.3).sin() * 400.0; +/// let c = Candle::new(100.0, 101.0, 99.0, 100.5, v, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeRsi { + period: usize, + prev_volume: Option, + seed_gains: f64, + seed_losses: f64, + seed_count: usize, + avg_gain: Option, + avg_loss: Option, + last: Option, +} + +impl VolumeRsi { + /// Construct a Volume RSI with the given Wilder smoothing `period`. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period == 0`. + pub fn new(period: usize) -> Result { + if period == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + prev_volume: None, + seed_gains: 0.0, + seed_losses: 0.0, + seed_count: 0, + avg_gain: None, + avg_loss: None, + last: None, + }) + } + + /// Configured smoothing period. + pub const fn period(&self) -> usize { + self.period + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } + + fn rsi_from_avgs(avg_gain: f64, avg_loss: f64) -> f64 { + let denom = avg_gain + avg_loss; + if denom == 0.0 { + 50.0 + } else { + 100.0 * (avg_gain / denom) + } + } +} + +impl Indicator for VolumeRsi { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let volume = candle.volume; + let Some(prev) = self.prev_volume else { + self.prev_volume = Some(volume); + return None; + }; + let change = volume - prev; + self.prev_volume = Some(volume); + let gain = if change > 0.0 { change } else { 0.0 }; + let loss = if change < 0.0 { -change } else { 0.0 }; + + if let (Some(ag), Some(al)) = (self.avg_gain, self.avg_loss) { + let n = self.period as f64; + let new_ag = (ag * (n - 1.0) + gain) / n; + let new_al = (al * (n - 1.0) + loss) / n; + self.avg_gain = Some(new_ag); + self.avg_loss = Some(new_al); + let v = Self::rsi_from_avgs(new_ag, new_al); + self.last = Some(v); + return Some(v); + } + + self.seed_gains += gain; + self.seed_losses += loss; + self.seed_count += 1; + if self.seed_count == self.period { + let n = self.period as f64; + let ag = self.seed_gains / n; + let al = self.seed_losses / n; + self.avg_gain = Some(ag); + self.avg_loss = Some(al); + let v = Self::rsi_from_avgs(ag, al); + self.last = Some(v); + return Some(v); + } + None + } + + fn reset(&mut self) { + self.prev_volume = None; + self.seed_gains = 0.0; + self.seed_losses = 0.0; + self.seed_count = 0; + self.avg_gain = None; + self.avg_loss = None; + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeRsi" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + /// Candle whose only material field here is `volume`. + fn vol_candle(volume: f64) -> Candle { + Candle::new_unchecked(100.0, 101.0, 99.0, 100.5, volume, 0) + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(VolumeRsi::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let v = VolumeRsi::new(14).unwrap(); + assert_eq!(v.period(), 14); + assert_eq!(v.warmup_period(), 15); + assert_eq!(v.name(), "VolumeRsi"); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut v = VolumeRsi::new(3).unwrap(); + let candles: Vec = (0..6).map(|i| vol_candle(1_000.0 + f64::from(i))).collect(); + let out = v.batch(&candles); + // warmup_period == period + 1 == 4: first emission at index 3. + for o in out.iter().take(3) { + assert!(o.is_none()); + } + assert!(out[3].is_some()); + } + + #[test] + fn rising_volume_is_one_hundred() { + // Every change positive -> avg_loss 0 -> RSI 100. + let mut v = VolumeRsi::new(5).unwrap(); + let candles: Vec = (1..=40).map(|i| vol_candle(f64::from(i) * 100.0)).collect(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 100.0, epsilon = 1e-9); + } + + #[test] + fn falling_volume_is_zero() { + let mut v = VolumeRsi::new(5).unwrap(); + let candles: Vec = (1..=40) + .map(|i| vol_candle(5_000.0 - f64::from(i) * 100.0)) + .collect(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 0.0, epsilon = 1e-9); + } + + #[test] + fn flat_volume_is_neutral() { + // Unchanged volume -> no gains and no losses -> neutral 50. + let mut v = VolumeRsi::new(3).unwrap(); + let candles: Vec = (0..20).map(|_| vol_candle(2_000.0)).collect(); + let last = v.batch(&candles).into_iter().flatten().last().unwrap(); + assert_relative_eq!(last, 50.0, epsilon = 1e-12); + } + + #[test] + fn output_in_range() { + let mut v = VolumeRsi::new(14).unwrap(); + let candles: Vec = (0..200) + .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.3).sin() * 600.0)) + .collect(); + for o in v.batch(&candles).into_iter().flatten() { + assert!((0.0..=100.0).contains(&o)); + } + } + + #[test] + fn reset_clears_state() { + let mut v = VolumeRsi::new(3).unwrap(); + let candles: Vec = (0..20) + .map(|i| vol_candle(1_000.0 + f64::from(i))) + .collect(); + v.batch(&candles); + assert!(v.is_ready()); + v.reset(); + assert!(!v.is_ready()); + assert_eq!(v.value(), None); + assert_eq!(v.update(vol_candle(1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| vol_candle(1_000.0 + (f64::from(i) * 0.25).sin() * 500.0)) + .collect(); + let batch = VolumeRsi::new(14).unwrap().batch(&candles); + let mut b = VolumeRsi::new(14).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/volume_weighted_macd.rs b/crates/wickra-core/src/indicators/volume_weighted_macd.rs new file mode 100644 index 00000000..892a8cd0 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_weighted_macd.rs @@ -0,0 +1,270 @@ +//! Volume-Weighted MACD — MACD built on volume-weighted moving averages. + +use crate::error::{Error, Result}; +use crate::indicators::ema::Ema; +use crate::indicators::vwma::Vwma; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Output of [`VolumeWeightedMacd`]: the three classic MACD series, but with the +/// fast and slow averages volume-weighted. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct VolumeWeightedMacdOutput { + /// Fast VWMA − slow VWMA. + pub macd: f64, + /// EMA of `macd` over the signal period. + pub signal: f64, + /// `macd − signal`. + pub histogram: f64, +} + +/// Volume-Weighted MACD — the MACD oscillator computed from **volume-weighted** +/// moving averages instead of plain EMAs. +/// +/// ```text +/// macd = VWMA(close, fast) − VWMA(close, slow) +/// signal = EMA(macd, signal_period) +/// histogram = macd − signal +/// ``` +/// +/// Standard [`MacdIndicator`](crate::MacdIndicator) smooths price with exponential +/// averages that ignore volume. The volume-weighted variant (Buff Dormeier and +/// others) replaces each average with a [`Vwma`], so heavy-volume bars dominate +/// the trend estimate and the oscillator leans toward where real participation +/// occurred. Crossovers backed by volume therefore appear sooner and noise from +/// thin bars is damped. The signal line keeps a standard EMA, matching the +/// classic histogram construction. +/// +/// `fast` must be strictly smaller than `slow`. The first output lands after +/// `slow + signal − 1` inputs: `slow` to seed the slow VWMA, then `signal − 1` +/// more to seed the signal EMA. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeWeightedMacd}; +/// +/// let mut indicator = VolumeWeightedMacd::new(12, 26, 9).unwrap(); +/// let mut last = None; +/// for i in 0..80 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeWeightedMacd { + fast: Vwma, + slow: Vwma, + signal_ema: Ema, + fast_period: usize, + slow_period: usize, + signal_period: usize, + last: Option, +} + +impl VolumeWeightedMacd { + /// Construct a volume-weighted MACD with the given periods. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if any period is zero, and + /// [`Error::InvalidPeriod`] if `fast >= slow`. + pub fn new(fast: usize, slow: usize, signal: usize) -> Result { + if fast == 0 || slow == 0 || signal == 0 { + return Err(Error::PeriodZero); + } + if fast >= slow { + return Err(Error::InvalidPeriod { + message: "fast period must be strictly less than slow period", + }); + } + Ok(Self { + fast: Vwma::new(fast)?, + slow: Vwma::new(slow)?, + signal_ema: Ema::new(signal)?, + fast_period: fast, + slow_period: slow, + signal_period: signal, + last: None, + }) + } + + /// Configured periods as `(fast, slow, signal)`. + pub const fn periods(&self) -> (usize, usize, usize) { + (self.fast_period, self.slow_period, self.signal_period) + } + + /// Most recent fully-computed output if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for VolumeWeightedMacd { + type Input = Candle; + type Output = VolumeWeightedMacdOutput; + + fn update(&mut self, candle: Candle) -> Option { + let fast = self.fast.update(candle); + let slow = self.slow.update(candle); + if let (Some(f), Some(s)) = (fast, slow) { + let macd = f - s; + let signal = self.signal_ema.update(macd)?; + let out = VolumeWeightedMacdOutput { + macd, + signal, + histogram: macd - signal, + }; + self.last = Some(out); + return Some(out); + } + None + } + + fn reset(&mut self) { + self.fast.reset(); + self.slow.reset(); + self.signal_ema.reset(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.slow_period + self.signal_period - 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeWeightedMacd" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(close: f64, volume: f64) -> Candle { + Candle::new_unchecked(close, close, close, close, volume, 0) + } + + #[test] + fn rejects_invalid_periods() { + assert!(matches!( + VolumeWeightedMacd::new(0, 26, 9), + Err(Error::PeriodZero) + )); + assert!(matches!( + VolumeWeightedMacd::new(26, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + assert!(matches!( + VolumeWeightedMacd::new(12, 12, 9), + Err(Error::InvalidPeriod { .. }) + )); + } + + #[test] + fn accessors_and_metadata() { + let m = VolumeWeightedMacd::new(12, 26, 9).unwrap(); + assert_eq!(m.periods(), (12, 26, 9)); + assert_eq!(m.warmup_period(), 34); + assert_eq!(m.name(), "VolumeWeightedMacd"); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + } + + #[test] + fn first_emission_at_warmup_period() { + let mut m = VolumeWeightedMacd::new(2, 4, 3).unwrap(); + let candles: Vec = (0..20) + .map(|i| candle(100.0 + f64::from(i), 1_000.0)) + .collect(); + let out = m.batch(&candles); + let warmup = m.warmup_period(); // 4 + 3 - 1 = 6 + assert_eq!(warmup, 6); + for v in out.iter().take(warmup - 1) { + assert!(v.is_none()); + } + assert!(out[warmup - 1].is_some()); + } + + #[test] + fn uptrend_has_positive_macd() { + // A steady advance with equal volume -> fast VWMA leads slow -> macd > 0. + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..60) + .map(|i| candle(100.0 + f64::from(i), 1_000.0)) + .collect(); + let last = m.batch(&candles).into_iter().flatten().last().unwrap(); + assert!( + last.macd > 0.0, + "uptrend should give positive macd, got {}", + last.macd + ); + } + + #[test] + fn histogram_is_macd_minus_signal() { + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..60) + .map(|i| { + candle( + 100.0 + (f64::from(i) * 0.3).sin() * 5.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + for o in m.batch(&candles).into_iter().flatten() { + assert_relative_eq!(o.histogram, o.macd - o.signal, epsilon = 1e-9); + } + } + + #[test] + fn equal_volume_matches_plain_macd() { + // With constant volume, VWMA reduces to SMA, so volume-weighted MACD uses + // SMA-based lines; it should still be a well-defined finite series. + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..60) + .map(|i| candle(100.0 + (f64::from(i) * 0.2).sin() * 4.0, 2_000.0)) + .collect(); + for o in m.batch(&candles).into_iter().flatten() { + assert!(o.macd.is_finite() && o.signal.is_finite()); + } + } + + #[test] + fn reset_clears_state() { + let mut m = VolumeWeightedMacd::new(3, 6, 3).unwrap(); + let candles: Vec = (0..40) + .map(|i| candle(100.0 + f64::from(i), 1_000.0)) + .collect(); + m.batch(&candles); + assert!(m.is_ready()); + m.reset(); + assert!(!m.is_ready()); + assert_eq!(m.value(), None); + assert_eq!(m.update(candle(100.0, 1_000.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..120) + .map(|i| { + candle( + 100.0 + (f64::from(i) * 0.25).sin() * 9.0, + 1_000.0 + f64::from(i), + ) + }) + .collect(); + let batch = VolumeWeightedMacd::new(12, 26, 9).unwrap().batch(&candles); + let mut b = VolumeWeightedMacd::new(12, 26, 9).unwrap(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/indicators/wad.rs b/crates/wickra-core/src/indicators/wad.rs new file mode 100644 index 00000000..0a1d133a --- /dev/null +++ b/crates/wickra-core/src/indicators/wad.rs @@ -0,0 +1,206 @@ +//! Williams Accumulation/Distribution (WAD) — Larry Williams' cumulative line. + +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Williams Accumulation/Distribution — a cumulative price-only line that adds +/// the day's accumulation on up-closes and subtracts the day's distribution on +/// down-closes. +/// +/// ```text +/// if close > prev_close: AD = close − min(low, prev_close) (true low) +/// if close < prev_close: AD = close − max(high, prev_close) (true high) +/// if close = prev_close: AD = 0 +/// WAD_t = WAD_{t−1} + AD +/// ``` +/// +/// Larry Williams' A/D line (distinct from Chaikin's volume-based +/// [`Adl`](crate::Adl)) uses **no volume at all** — it measures accumulation as +/// how far price closed above the *true low* on up-days and distribution as how +/// far it closed below the *true high* on down-days, then accumulates the result. +/// A rising WAD that diverges from a flat or falling price is the classic +/// accumulation signal; a falling WAD under a rising price warns of distribution. +/// +/// The line is unbounded and its absolute level is meaningless — only its slope +/// and divergences against price matter. The first candle has no previous close, +/// so it seeds the reference and emits nothing; thereafter every bar emits the +/// running total. Each `update` is O(1). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, Wad}; +/// +/// let mut indicator = Wad::new(); +/// let mut last = None; +/// for i in 0..20 { +/// let base = 100.0 + f64::from(i); +/// let c = Candle::new(base, base + 1.0, base - 1.0, base + 0.5, 1_000.0, 0).unwrap(); +/// last = indicator.update(c); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Wad { + prev_close: Option, + line: f64, + last: Option, +} + +impl Wad { + /// Construct a new Williams A/D line. The line is parameter-free. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Current value if available. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for Wad { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + let Some(prev_close) = self.prev_close else { + self.prev_close = Some(candle.close); + return None; + }; + let ad = if candle.close > prev_close { + candle.close - candle.low.min(prev_close) + } else if candle.close < prev_close { + candle.close - candle.high.max(prev_close) + } else { + 0.0 + }; + self.line += ad; + self.prev_close = Some(candle.close); + self.last = Some(self.line); + Some(self.line) + } + + fn reset(&mut self) { + self.prev_close = None; + self.line = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + // The first bar only seeds the reference close; the first value lands on + // the second bar. + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "Wad" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn candle(high: f64, low: f64, close: f64) -> Candle { + Candle::new_unchecked(low, high, low, close, 1_000.0, 0) + } + + #[test] + fn accessors_and_metadata() { + let wad = Wad::new(); + assert_eq!(wad.warmup_period(), 2); + assert_eq!(wad.name(), "Wad"); + assert!(!wad.is_ready()); + assert_eq!(wad.value(), None); + } + + #[test] + fn first_bar_seeds_without_output() { + let mut wad = Wad::new(); + assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None); + assert!(wad.update(candle(102.0, 100.0, 101.0)).is_some()); + } + + #[test] + fn up_close_accumulates() { + // close rises from 100 -> 101; true low = min(low, prev_close) = min(100,100)=100; + // AD = 101 - 100 = 1. + let mut wad = Wad::new(); + wad.update(candle(101.0, 99.0, 100.0)); + let v = wad.update(candle(102.0, 100.0, 101.0)).unwrap(); + assert_relative_eq!(v, 1.0, epsilon = 1e-9); + } + + #[test] + fn down_close_distributes() { + // close falls 100 -> 99; true high = max(high, prev_close) = max(101,100)=101; + // AD = 99 - 101 = -2. + let mut wad = Wad::new(); + wad.update(candle(102.0, 100.0, 100.0)); + let v = wad.update(candle(101.0, 98.0, 99.0)).unwrap(); + assert_relative_eq!(v, -2.0, epsilon = 1e-9); + } + + #[test] + fn unchanged_close_adds_nothing() { + let mut wad = Wad::new(); + wad.update(candle(101.0, 99.0, 100.0)); + let v = wad.update(candle(105.0, 95.0, 100.0)).unwrap(); + assert_relative_eq!(v, 0.0, epsilon = 1e-12); + } + + #[test] + fn pure_uptrend_is_monotone() { + let mut wad = Wad::new(); + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) + }) + .collect(); + let mut prev = f64::NEG_INFINITY; + for v in wad.batch(&candles).into_iter().flatten() { + assert!(v >= prev, "WAD must rise in an uptrend"); + prev = v; + } + } + + #[test] + fn reset_clears_state() { + let mut wad = Wad::new(); + let candles: Vec = (0..10) + .map(|i| { + let base = 100.0 + f64::from(i); + candle(base + 1.0, base - 1.0, base) + }) + .collect(); + wad.batch(&candles); + assert!(wad.is_ready()); + wad.reset(); + assert!(!wad.is_ready()); + assert_eq!(wad.value(), None); + assert_eq!(wad.update(candle(101.0, 99.0, 100.0)), None); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..80) + .map(|i| { + let base = 100.0 + (f64::from(i) * 0.3).sin() * 8.0; + candle(base + 2.0, base - 2.0, base + 0.5) + }) + .collect(); + let batch = Wad::new().batch(&candles); + let mut b = Wad::new(); + let streamed: Vec<_> = candles.iter().map(|c| b.update(*c)).collect(); + assert_eq!(batch, streamed); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 6c683ed1..41237ffe 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -63,14 +63,14 @@ pub use indicators::{ AroonOscillator, AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrRatchet, AtrRatchetOutput, AtrTrailingStop, AutoFib, AutoFibOutput, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BeltHold, Beta, - BetaNeutralSpread, BipowerVariation, BodySizePct, BollingerBands, BollingerBandwidth, - BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, BullishPercentIndex, - Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, - Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, - ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, - ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, - CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, - Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, + BetaNeutralSpread, BetterVolume, BipowerVariation, BodySizePct, BollingerBands, + BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput, BreadthThrust, Breakaway, + BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, + Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, + ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, + ClassicPivots, ClassicPivotsOutput, CloseVsOpen, ClosingMarubozu, Cmo, CoefficientOfVariation, + Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, + Coppock, Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, @@ -92,7 +92,7 @@ pub use indicators::{ HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, - InstantaneousTrendline, IntradayMomentumIndex, IntradayVolatilityProfile, + InstantaneousTrendline, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner, @@ -132,18 +132,19 @@ pub use indicators::{ TermStructureBasis, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput, - TradeImbalance, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, Trima, Trin, - TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, - TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, - UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, - ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, - VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop, - VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, - VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, - VwapStdDevBandsOutput, Vwma, Vzo, WavePm, WaveTrend, WaveTrendOutput, Wedge, WeightedClose, - WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, WoodiePivots, - WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, - ZigZag, ZigZagOutput, Zlema, FAMILIES, T3, + TradeImbalance, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, TreynorRatio, Triangle, + Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze, + TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, + UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, + UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, + VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, + VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, + VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, + VolumeWeightedMacdOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands, + VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge, + WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma, + WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, + ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3, }; // `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own // line so the indicator-count tooling (which scans the braced block above and diff --git a/docs/README.md b/docs/README.md index f6ee6777..7a80f485 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 **440 indicators** across +- A per-indicator deep dive for every one of the **447 indicators** across the sixteen families (Moving Averages, Momentum Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands, Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots & diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index ac90d858..52e7ffcd 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -22,7 +22,7 @@ //! WeightedClose. use libfuzzer_sys::fuzz_target; -use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag}; +use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, Natr, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeDrives, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag}; /// Convert a flat `f64` stream into a `Vec` by chunking it into /// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV @@ -140,6 +140,13 @@ fuzz_target!(|data: Vec| { drive(HighLowRange::new, &candles); // --- Volume --- + drive(|| BetterVolume::new(14).unwrap(), &candles); + drive(IntradayIntensity::new, &candles); + drive(|| TradeVolumeIndex::new(0.25).unwrap(), &candles); + drive(|| TwiggsMoneyFlow::new(21).unwrap(), &candles); + drive(Wad::new, &candles); + drive(|| VolumeRsi::new(14).unwrap(), &candles); + drive(|| VolumeWeightedMacd::new(12, 26, 9).unwrap(), &candles); drive(Obv::new, &candles); drive(|| Mfi::new(14).unwrap(), &candles); drive(Vwap::new, &candles);