diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a1cb96..ac95db63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ 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] +- **TICK Index** — instantaneous net advancing-minus-declining issues (`TICK_INDEX`). +- **Absolute Breadth Index** — absolute value of net advancing-minus-declining issues (`ABSOLUTE_BREADTH_INDEX`). +- **Cumulative Volume Index** — running total of volume-normalised net advancing volume (`CUMULATIVE_VOLUME_INDEX`). +- **Bullish Percent Index** — percentage of the universe on a point-and-figure buy signal (`BULLISH_PERCENT_INDEX`). +- **Up/Down Volume Ratio** — advancing volume divided by declining volume (`UP_DOWN_VOLUME_RATIO`). +- **Percent Above Moving Average** — percentage of the universe trading above its reference moving average (`PERCENT_ABOVE_MA`). +- **High-Low Index** — moving average of the record-high percentage (`HIGH_LOW_INDEX`). +- **New Highs - New Lows** — net count of new period highs minus new period lows (`NEW_HIGHS_NEW_LOWS`). +- **Breadth Thrust** — moving average of the advancing-issues share (Zweig) (`BREADTH_THRUST`). +- **TRIN / Arms Index** — advance-decline ratio divided by the up-down volume ratio (`TRIN`). +- **McClellan Summation Index** — running cumulative total of the McClellan Oscillator (`MCCLELLAN_SUMMATION_INDEX`). +- **McClellan Oscillator** — spread between a 19- and 39-period EMA of ratio-adjusted net advances (`MCCLELLAN_OSCILLATOR`). +- **Advance/Decline Volume Line** — cumulative net advancing-minus-declining volume across the universe (`AD_VOLUME_LINE`). +- **Advance/Decline Ratio** — advancing issues divided by declining issues across the universe (`ADVANCE_DECLINE_RATIO`). ## [0.4.7] - 2026-06-03 diff --git a/README.md b/README.md index d843d4ba..3be58014 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

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

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**: [Node](https://docs.wickra.org/Quickstart-Node), [WASM](https://docs.wickra.org/Quickstart-WASM). - **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for - every one of the 325 indicators; start at the + every one of the 339 indicators; start at the [indicators overview](https://docs.wickra.org/Indicators-Overview). - **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods), [streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch), @@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries ## Indicators -325 streaming-first indicators across twenty families. Every one passes the +339 streaming-first indicators across twenty 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). @@ -160,7 +160,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint | | Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread | | Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range | -| Market Breadth | Advance/Decline Line | +| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index | | Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) | Every candlestick pattern emits a signed per-bar value — `+1.0` bullish, @@ -240,7 +240,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 325 indicators +│ ├── wickra-core/ core engine + all 339 indicators │ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/ │ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds ├── bindings/ diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 650ecfbf..107c17c2 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -1284,6 +1284,112 @@ test('market breadth: AdvanceDecline rejects ragged universe', () => { ); }); +test('market breadth: 14 indicators reference values + batch parity', () => { + const flags4 = [false, false, false, false]; + + // Advance/Decline Ratio: 3/1 = 3 ; 0 advancers -> 0. + const adr = new wickra.AdvanceDeclineRatio(); + assert.equal(adr.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4), 3.0); + assert.equal(adr.update([-1, -1, -1, -1], [10, 10, 10, 10], flags4, flags4), 0.0); + assert.deepEqual( + Array.from( + new wickra.AdvanceDeclineRatio().batch( + [[1, 1, 1, -1], [-1, -1, -1, -1]], + [[10, 10, 10, 10], [10, 10, 10, 10]], + [flags4, flags4], + [flags4, flags4], + ), + ), + [3.0, 0.0], + ); + + // AD Volume Line: cumulative net advancing volume. + const adv = new wickra.AdVolumeLine(); + assert.equal(adv.update([1, -1], [150, 50], [false, false], [false, false]), 100.0); + assert.equal(adv.update([1, -1], [60, 60], [false, false], [false, false]), 100.0); + + // McClellan Oscillator + Summation: seed 0, then -50. + const osc = new wickra.McClellanOscillator(); + assert.ok(Math.abs(osc.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9); + assert.ok(Math.abs(osc.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9); + const msi = new wickra.McClellanSummationIndex(); + assert.ok(Math.abs(msi.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9); + assert.ok(Math.abs(msi.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9); + + // TRIN: balanced breadth -> 1. + assert.ok( + Math.abs(new wickra.Trin().update([1, 1, 1, -1], [50, 50, 50, 50], flags4, flags4) - 1.0) < 1e-9, + ); + + // Breadth Thrust(2): warmup null, then SMA(2) of [0.8, 0.6] = 0.7. + const bt = new wickra.BreadthThrust(2); + const up10 = Array(10).fill(false); + assert.equal(bt.update([...Array(8).fill(1), -1, -1], Array(10).fill(10), up10, up10), null); + assert.ok( + Math.abs(bt.update([...Array(6).fill(1), -1, -1, -1, -1], Array(10).fill(10), up10, up10) - 0.7) < 1e-9, + ); + + // New Highs - New Lows: 2 - 1 = 1. + assert.equal( + new wickra.NewHighsNewLows().update([1, 1, -1], [10, 10, 10], [true, true, false], [false, false, true]), + 1.0, + ); + + // High-Low Index(2): warmup null, then SMA(2) of [80, 60] = 70. + const hli = new wickra.HighLowIndex(2); + assert.equal( + hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(8).fill(true), false, false], [...Array(8).fill(false), true, true]), + null, + ); + assert.ok( + Math.abs( + hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(6).fill(true), false, false, false, false], [...Array(6).fill(false), true, true, true, true]) - 70.0, + ) < 1e-9, + ); + + // Percent Above MA: 3/4 -> 75 (5-array update with aboveMa). + assert.equal( + new wickra.PercentAboveMa().update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, true, false]), + 75.0, + ); + + // Up/Down Volume Ratio: 150/50 = 3. + assert.equal( + new wickra.UpDownVolumeRatio().update([1, -1], [150, 50], [false, false], [false, false]), + 3.0, + ); + + // Bullish Percent Index: 2/4 -> 50 (5-array update with onBuySignal). + assert.equal( + new wickra.BullishPercentIndex().update([1, 1, -1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, false, false]), + 50.0, + ); + + // Cumulative Volume Index: (100/200) -> 0.5. + assert.ok( + Math.abs(new wickra.CumulativeVolumeIndex().update([1, -1], [150, 50], [false, false], [false, false]) - 0.5) < 1e-9, + ); + + // Absolute Breadth Index: |2 - 3| = 1. + assert.equal( + new wickra.AbsoluteBreadthIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)), + 1.0, + ); + + // TICK Index: 2 - 3 = -1. + assert.equal( + new wickra.TickIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)), + -1.0, + ); +}); + +test('market breadth: rejects ragged universe', () => { + assert.throws(() => new wickra.Trin().update([1, -1], [10], [false, false], [false, false])); + assert.throws(() => + new wickra.PercentAboveMa().update([1, -1], [10, 10], [false, false], [false, false], [true]), + ); +}); + test('OI / flow / liquidation indicators reference values', () => { // OI +10% while price flat -> divergence +0.1. const div = new wickra.OIPriceDivergence(1); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 8e5cce3b..c68b9a50 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -3253,6 +3253,132 @@ export declare class AdvanceDecline { isReady(): boolean warmupPeriod(): number } +export type AdvanceDeclineRatioNode = AdvanceDeclineRatio +export declare class AdvanceDeclineRatio { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type AdVolumeLineNode = AdVolumeLine +export declare class AdVolumeLine { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type McClellanOscillatorNode = McClellanOscillator +export declare class McClellanOscillator { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type McClellanSummationIndexNode = McClellanSummationIndex +export declare class McClellanSummationIndex { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type TrinNode = Trin +export declare class Trin { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type BreadthThrustNode = BreadthThrust +export declare class BreadthThrust { + constructor(period: number) + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type NewHighsNewLowsNode = NewHighsNewLows +export declare class NewHighsNewLows { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type HighLowIndexNode = HighLowIndex +export declare class HighLowIndex { + constructor(period: number) + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type PercentAboveMaNode = PercentAboveMa +export declare class PercentAboveMa { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array, aboveMa: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>, aboveMa: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type UpDownVolumeRatioNode = UpDownVolumeRatio +export declare class UpDownVolumeRatio { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type BullishPercentIndexNode = BullishPercentIndex +export declare class BullishPercentIndex { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array, onBuySignal: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>, onBuySignal: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type CumulativeVolumeIndexNode = CumulativeVolumeIndex +export declare class CumulativeVolumeIndex { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type AbsoluteBreadthIndexNode = AbsoluteBreadthIndex +export declare class AbsoluteBreadthIndex { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type TickIndexNode = TickIndex +export declare class TickIndex { + constructor() + update(change: Array, volume: Array, newHigh: Array, newLow: Array): number | null + batch(change: Array>, volume: Array>, newHigh: Array>, newLow: Array>): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type SharpeRatioNode = SharpeRatio export declare class SharpeRatio { constructor(period: number, riskFree: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index 4fcaf21d..2a1a14ca 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, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, 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, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding +const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, 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, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, 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 } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -618,6 +618,20 @@ module.exports.LiquidationFeatures = LiquidationFeatures module.exports.TermStructureBasis = TermStructureBasis module.exports.CalendarSpread = CalendarSpread module.exports.AdvanceDecline = AdvanceDecline +module.exports.AdvanceDeclineRatio = AdvanceDeclineRatio +module.exports.AdVolumeLine = AdVolumeLine +module.exports.McClellanOscillator = McClellanOscillator +module.exports.McClellanSummationIndex = McClellanSummationIndex +module.exports.Trin = Trin +module.exports.BreadthThrust = BreadthThrust +module.exports.NewHighsNewLows = NewHighsNewLows +module.exports.HighLowIndex = HighLowIndex +module.exports.PercentAboveMa = PercentAboveMa +module.exports.UpDownVolumeRatio = UpDownVolumeRatio +module.exports.BullishPercentIndex = BullishPercentIndex +module.exports.CumulativeVolumeIndex = CumulativeVolumeIndex +module.exports.AbsoluteBreadthIndex = AbsoluteBreadthIndex +module.exports.TickIndex = TickIndex module.exports.SharpeRatio = SharpeRatio module.exports.SortinoRatio = SortinoRatio module.exports.CalmarRatio = CalmarRatio diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 18d15608..d49af0b3 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -11526,6 +11526,1032 @@ impl AdvanceDeclineNode { } } +fn build_cross_section_above_ma( + change: &[f64], + volume: &[f64], + new_high: &[bool], + new_low: &[bool], + above_ma: &[bool], +) -> napi::Result { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != above_ma.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh, newLow and aboveMa must be equal length".to_string(), + )); + } + let members = (0..change.len()) + .map(|i| { + wc::Member::with_signals( + change[i], + volume[i], + new_high[i], + new_low[i], + above_ma[i], + false, + ) + }) + .collect(); + wc::CrossSection::new(members, 0).map_err(map_err) +} + +fn build_cross_section_buy( + change: &[f64], + volume: &[f64], + new_high: &[bool], + new_low: &[bool], + on_buy_signal: &[bool], +) -> napi::Result { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != on_buy_signal.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh, newLow and onBuySignal must be equal length".to_string(), + )); + } + let members = (0..change.len()) + .map(|i| { + wc::Member::with_signals( + change[i], + volume[i], + new_high[i], + new_low[i], + false, + on_buy_signal[i], + ) + }) + .collect(); + wc::CrossSection::new(members, 0).map_err(map_err) +} + +#[napi(js_name = "AdvanceDeclineRatio")] +pub struct AdvanceDeclineRatioNode { + inner: wc::AdvanceDeclineRatio, +} + +impl Default for AdvanceDeclineRatioNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl AdvanceDeclineRatioNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::AdvanceDeclineRatio::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "AdVolumeLine")] +pub struct AdVolumeLineNode { + inner: wc::AdVolumeLine, +} + +impl Default for AdVolumeLineNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl AdVolumeLineNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::AdVolumeLine::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "McClellanOscillator")] +pub struct McClellanOscillatorNode { + inner: wc::McClellanOscillator, +} + +impl Default for McClellanOscillatorNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl McClellanOscillatorNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::McClellanOscillator::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "McClellanSummationIndex")] +pub struct McClellanSummationIndexNode { + inner: wc::McClellanSummationIndex, +} + +impl Default for McClellanSummationIndexNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl McClellanSummationIndexNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::McClellanSummationIndex::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "Trin")] +pub struct TrinNode { + inner: wc::Trin, +} + +impl Default for TrinNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl TrinNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::Trin::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "BreadthThrust")] +pub struct BreadthThrustNode { + inner: wc::BreadthThrust, +} + +#[napi] +impl BreadthThrustNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::BreadthThrust::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "NewHighsNewLows")] +pub struct NewHighsNewLowsNode { + inner: wc::NewHighsNewLows, +} + +impl Default for NewHighsNewLowsNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl NewHighsNewLowsNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::NewHighsNewLows::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "HighLowIndex")] +pub struct HighLowIndexNode { + inner: wc::HighLowIndex, +} + +#[napi] +impl HighLowIndexNode { + #[napi(constructor)] + pub fn new(period: u32) -> napi::Result { + Ok(Self { + inner: wc::HighLowIndex::new(period as usize).map_err(map_err)?, + }) + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "PercentAboveMa")] +pub struct PercentAboveMaNode { + inner: wc::PercentAboveMa, +} + +impl Default for PercentAboveMaNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl PercentAboveMaNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::PercentAboveMa::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + above_ma: Vec, + ) -> napi::Result> { + Ok(self.inner.update(build_cross_section_above_ma( + &change, &volume, &new_high, &new_low, &above_ma, + )?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + above_ma: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != above_ma.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh, newLow and aboveMa must have the same number of ticks" + .to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section_above_ma( + &change[i], + &volume[i], + &new_high[i], + &new_low[i], + &above_ma[i], + )?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "UpDownVolumeRatio")] +pub struct UpDownVolumeRatioNode { + inner: wc::UpDownVolumeRatio, +} + +impl Default for UpDownVolumeRatioNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl UpDownVolumeRatioNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::UpDownVolumeRatio::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "BullishPercentIndex")] +pub struct BullishPercentIndexNode { + inner: wc::BullishPercentIndex, +} + +impl Default for BullishPercentIndexNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl BullishPercentIndexNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::BullishPercentIndex::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + on_buy_signal: Vec, + ) -> napi::Result> { + Ok(self.inner.update(build_cross_section_buy( + &change, + &volume, + &new_high, + &new_low, + &on_buy_signal, + )?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + on_buy_signal: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != on_buy_signal.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh, newLow and onBuySignal must have the same number of ticks" + .to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section_buy( + &change[i], + &volume[i], + &new_high[i], + &new_low[i], + &on_buy_signal[i], + )?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "CumulativeVolumeIndex")] +pub struct CumulativeVolumeIndexNode { + inner: wc::CumulativeVolumeIndex, +} + +impl Default for CumulativeVolumeIndexNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl CumulativeVolumeIndexNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::CumulativeVolumeIndex::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "AbsoluteBreadthIndex")] +pub struct AbsoluteBreadthIndexNode { + inner: wc::AbsoluteBreadthIndex, +} + +impl Default for AbsoluteBreadthIndexNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl AbsoluteBreadthIndexNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::AbsoluteBreadthIndex::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).unwrap_or(f64::NAN)); + } + Ok(out) + } + #[napi] + pub fn reset(&mut self) { + self.inner.reset(); + } + #[napi(js_name = "isReady")] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[napi(js_name = "warmupPeriod")] + pub fn warmup_period(&self) -> u32 { + self.inner.warmup_period() as u32 + } +} + +#[napi(js_name = "TickIndex")] +pub struct TickIndexNode { + inner: wc::TickIndex, +} + +impl Default for TickIndexNode { + fn default() -> Self { + Self::new() + } +} + +#[napi] +impl TickIndexNode { + #[napi(constructor)] + pub fn new() -> Self { + Self { + inner: wc::TickIndex::new(), + } + } + #[napi] + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> napi::Result> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + #[napi] + pub fn batch( + &mut self, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> napi::Result> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(NapiError::from_reason( + "change, volume, newHigh and newLow must have the same number of ticks".to_string(), + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 + } +} + // ============================== Family 15: Risk / Performance ============================== // Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 38df5b2a..4d96cc64 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -352,6 +352,20 @@ from ._wickra import ( TermStructureBasis, CalendarSpread, # Market Breadth + TickIndex, + AbsoluteBreadthIndex, + CumulativeVolumeIndex, + BullishPercentIndex, + UpDownVolumeRatio, + PercentAboveMa, + HighLowIndex, + NewHighsNewLows, + BreadthThrust, + Trin, + McClellanSummationIndex, + McClellanOscillator, + AdVolumeLine, + AdvanceDeclineRatio, AdvanceDecline, # Risk / Performance SharpeRatio, @@ -702,6 +716,20 @@ __all__ = [ "TermStructureBasis", "CalendarSpread", # Market Breadth + "TickIndex", + "AbsoluteBreadthIndex", + "CumulativeVolumeIndex", + "BullishPercentIndex", + "UpDownVolumeRatio", + "PercentAboveMa", + "HighLowIndex", + "NewHighsNewLows", + "BreadthThrust", + "Trin", + "McClellanSummationIndex", + "McClellanOscillator", + "AdVolumeLine", + "AdvanceDeclineRatio", "AdvanceDecline", # Risk / Performance "SharpeRatio", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index eff36715..61fd62aa 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -15161,6 +15161,994 @@ impl PyAdvanceDecline { } } +fn build_cross_section_above_ma( + change: &[f64], + volume: &[f64], + new_high: &[bool], + new_low: &[bool], + above_ma: &[bool], +) -> PyResult { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != above_ma.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high, new_low and above_ma must be equal length", + )); + } + let members = (0..change.len()) + .map(|i| { + wc::Member::with_signals( + change[i], + volume[i], + new_high[i], + new_low[i], + above_ma[i], + false, + ) + }) + .collect(); + wc::CrossSection::new(members, 0).map_err(map_err) +} + +fn build_cross_section_buy( + change: &[f64], + volume: &[f64], + new_high: &[bool], + new_low: &[bool], + on_buy_signal: &[bool], +) -> PyResult { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != on_buy_signal.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high, new_low and on_buy_signal must be equal length", + )); + } + let members = (0..change.len()) + .map(|i| { + wc::Member::with_signals( + change[i], + volume[i], + new_high[i], + new_low[i], + false, + on_buy_signal[i], + ) + }) + .collect(); + wc::CrossSection::new(members, 0).map_err(map_err) +} + +#[pyclass( + name = "AdvanceDeclineRatio", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyAdvanceDeclineRatio { + inner: wc::AdvanceDeclineRatio, +} + +#[pymethods] +impl PyAdvanceDeclineRatio { + #[new] + fn new() -> Self { + Self { + inner: wc::AdvanceDeclineRatio::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "AdvanceDeclineRatio()".to_string() + } +} + +#[pyclass(name = "AdVolumeLine", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyAdVolumeLine { + inner: wc::AdVolumeLine, +} + +#[pymethods] +impl PyAdVolumeLine { + #[new] + fn new() -> Self { + Self { + inner: wc::AdVolumeLine::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "AdVolumeLine()".to_string() + } +} + +#[pyclass( + name = "McClellanOscillator", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyMcClellanOscillator { + inner: wc::McClellanOscillator, +} + +#[pymethods] +impl PyMcClellanOscillator { + #[new] + fn new() -> Self { + Self { + inner: wc::McClellanOscillator::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "McClellanOscillator()".to_string() + } +} + +#[pyclass( + name = "McClellanSummationIndex", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyMcClellanSummationIndex { + inner: wc::McClellanSummationIndex, +} + +#[pymethods] +impl PyMcClellanSummationIndex { + #[new] + fn new() -> Self { + Self { + inner: wc::McClellanSummationIndex::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "McClellanSummationIndex()".to_string() + } +} + +#[pyclass(name = "Trin", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTrin { + inner: wc::Trin, +} + +#[pymethods] +impl PyTrin { + #[new] + fn new() -> Self { + Self { + inner: wc::Trin::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "Trin()".to_string() + } +} + +#[pyclass(name = "BreadthThrust", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyBreadthThrust { + inner: wc::BreadthThrust, +} + +#[pymethods] +impl PyBreadthThrust { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::BreadthThrust::new(period).map_err(map_err)?, + }) + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + format!("BreadthThrust(period={})", self.inner.period()) + } +} + +#[pyclass( + name = "NewHighsNewLows", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyNewHighsNewLows { + inner: wc::NewHighsNewLows, +} + +#[pymethods] +impl PyNewHighsNewLows { + #[new] + fn new() -> Self { + Self { + inner: wc::NewHighsNewLows::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "NewHighsNewLows()".to_string() + } +} + +#[pyclass(name = "HighLowIndex", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyHighLowIndex { + inner: wc::HighLowIndex, +} + +#[pymethods] +impl PyHighLowIndex { + #[new] + fn new(period: usize) -> PyResult { + Ok(Self { + inner: wc::HighLowIndex::new(period).map_err(map_err)?, + }) + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + format!("HighLowIndex(period={})", self.inner.period()) + } +} + +#[pyclass( + name = "PercentAboveMa", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyPercentAboveMa { + inner: wc::PercentAboveMa, +} + +#[pymethods] +impl PyPercentAboveMa { + #[new] + fn new() -> Self { + Self { + inner: wc::PercentAboveMa::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + above_ma: Vec, + ) -> PyResult> { + Ok(self.inner.update(build_cross_section_above_ma( + &change, &volume, &new_high, &new_low, &above_ma, + )?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + above_ma: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != above_ma.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high, new_low and above_ma must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section_above_ma( + &change[i], + &volume[i], + &new_high[i], + &new_low[i], + &above_ma[i], + )?; + out.push(self.inner.update(section).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 { + "PercentAboveMa()".to_string() + } +} + +#[pyclass( + name = "UpDownVolumeRatio", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyUpDownVolumeRatio { + inner: wc::UpDownVolumeRatio, +} + +#[pymethods] +impl PyUpDownVolumeRatio { + #[new] + fn new() -> Self { + Self { + inner: wc::UpDownVolumeRatio::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "UpDownVolumeRatio()".to_string() + } +} + +#[pyclass( + name = "BullishPercentIndex", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyBullishPercentIndex { + inner: wc::BullishPercentIndex, +} + +#[pymethods] +impl PyBullishPercentIndex { + #[new] + fn new() -> Self { + Self { + inner: wc::BullishPercentIndex::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + on_buy_signal: Vec, + ) -> PyResult> { + Ok(self.inner.update(build_cross_section_buy( + &change, + &volume, + &new_high, + &new_low, + &on_buy_signal, + )?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + on_buy_signal: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != on_buy_signal.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high, new_low and on_buy_signal must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section_buy( + &change[i], + &volume[i], + &new_high[i], + &new_low[i], + &on_buy_signal[i], + )?; + out.push(self.inner.update(section).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 { + "BullishPercentIndex()".to_string() + } +} + +#[pyclass( + name = "CumulativeVolumeIndex", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyCumulativeVolumeIndex { + inner: wc::CumulativeVolumeIndex, +} + +#[pymethods] +impl PyCumulativeVolumeIndex { + #[new] + fn new() -> Self { + Self { + inner: wc::CumulativeVolumeIndex::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "CumulativeVolumeIndex()".to_string() + } +} + +#[pyclass( + name = "AbsoluteBreadthIndex", + module = "wickra._wickra", + skip_from_py_object +)] +#[derive(Clone)] +struct PyAbsoluteBreadthIndex { + inner: wc::AbsoluteBreadthIndex, +} + +#[pymethods] +impl PyAbsoluteBreadthIndex { + #[new] + fn new() -> Self { + Self { + inner: wc::AbsoluteBreadthIndex::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "AbsoluteBreadthIndex()".to_string() + } +} + +#[pyclass(name = "TickIndex", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTickIndex { + inner: wc::TickIndex, +} + +#[pymethods] +impl PyTickIndex { + #[new] + fn new() -> Self { + Self { + inner: wc::TickIndex::new(), + } + } + fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> PyResult> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + fn batch<'py>( + &mut self, + py: Python<'py>, + change: Vec>, + volume: Vec>, + new_high: Vec>, + new_low: Vec>, + ) -> PyResult>> { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + { + return Err(PyValueError::new_err( + "change, volume, new_high and new_low must have the same number of ticks", + )); + } + let mut out = Vec::with_capacity(change.len()); + for i in 0..change.len() { + let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?; + out.push(self.inner.update(section).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 { + "TickIndex()".to_string() + } +} + // ============================== Family 15: Risk / Performance ============================== #[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)] @@ -16565,6 +17553,20 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; // Family 15: Risk / Performance metrics. m.add_class::()?; m.add_class::()?; diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index aeaa6e26..fe959d02 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -2783,6 +2783,190 @@ def test_advance_decline_rejects_ragged_universe(): ad.update([1.0, -1.0], [10.0], [False, False], [False, False]) +def _breadth_streaming_equals_batch(indicator, change, volume, new_high, new_low): + """Assert a 4-array breadth indicator's batch matches its streaming output.""" + batch = indicator().batch(change, volume, new_high, new_low) + streamer = indicator() + streamed = np.array( + [ + streamer.update(change[i], volume[i], new_high[i], new_low[i]) + for i in range(len(change)) + ], + dtype=np.float64, + ) + assert batch.shape == (len(change),) + assert _eq_nan(batch, streamed) + return batch + + +def test_advance_decline_ratio_breadth(): + change = [[1.0, 1.0, 1.0, -1.0], [1.0, 0.0, 0.0, 0.0], [-1.0, -1.0, -1.0, -1.0]] + volume = [[10.0] * 4 for _ in range(3)] + flags = [[False] * 4 for _ in range(3)] + batch = _breadth_streaming_equals_batch(ta.AdvanceDeclineRatio, change, volume, flags, flags) + # 3/1 = 3 ; 1/max(0,1) = 1 ; 0/3 = 0. + assert list(batch) == [3.0, 1.0, 0.0] + + +def test_ad_volume_line_breadth(): + change = [[1.0, -1.0], [1.0, -1.0], [1.0, 0.0]] + volume = [[150.0, 50.0], [60.0, 60.0], [30.0, 0.0]] + flags = [[False] * 2 for _ in range(3)] + batch = _breadth_streaming_equals_batch(ta.AdVolumeLine, change, volume, flags, flags) + # net +100 -> 100 ; net 0 -> 100 ; net +30 -> 130. + assert list(batch) == [100.0, 100.0, 130.0] + + +def test_mcclellan_oscillator_breadth(): + change = [[1.0, 1.0, 1.0, -1.0], [-1.0, -1.0, -1.0, 1.0], [1.0, 1.0, -1.0, -1.0]] + volume = [[10.0] * 4 for _ in range(3)] + flags = [[False] * 4 for _ in range(3)] + batch = _breadth_streaming_equals_batch(ta.McClellanOscillator, change, volume, flags, flags) + # seed 0 ; -50 ; -67.5. + assert abs(batch[0]) < 1e-9 + assert abs(batch[1] - (-50.0)) < 1e-9 + assert abs(batch[2] - (-67.5)) < 1e-9 + + +def test_mcclellan_summation_index_breadth(): + change = [[1.0, 1.0, 1.0, -1.0], [-1.0, -1.0, -1.0, 1.0], [1.0, 1.0, -1.0, -1.0]] + volume = [[10.0] * 4 for _ in range(3)] + flags = [[False] * 4 for _ in range(3)] + batch = _breadth_streaming_equals_batch(ta.McClellanSummationIndex, change, volume, flags, flags) + # 0 ; -50 ; -117.5. + assert abs(batch[0]) < 1e-9 + assert abs(batch[1] - (-50.0)) < 1e-9 + assert abs(batch[2] - (-117.5)) < 1e-9 + + +def test_trin_breadth(): + change = [[1.0, 1.0, 1.0, -1.0], [1.0, 1.0, -1.0, -1.0]] + volume = [[50.0, 50.0, 50.0, 50.0], [10.0, 10.0, 40.0, 40.0]] + flags = [[False] * 4 for _ in range(2)] + batch = _breadth_streaming_equals_batch(ta.Trin, change, volume, flags, flags) + # (3/1)/(150/50) = 1 ; (2/2)/(20/80) = 4. + assert abs(batch[0] - 1.0) < 1e-9 + assert abs(batch[1] - 4.0) < 1e-9 + + +def test_breadth_thrust_breadth(): + change = [[1.0] * 8 + [-1.0] * 2, [1.0] * 6 + [-1.0] * 4] + volume = [[10.0] * 10 for _ in range(2)] + flags = [[False] * 10 for _ in range(2)] + batch = ta.BreadthThrust(2).batch(change, volume, flags, flags) + streamer = ta.BreadthThrust(2) + streamed = np.array( + [streamer.update(change[i], volume[i], flags[i], flags[i]) for i in range(2)], + dtype=np.float64, + ) + assert _eq_nan(batch, streamed) + # 0.8 (warmup -> NaN) ; SMA(2) of [0.8, 0.6] = 0.7. + assert math.isnan(batch[0]) + assert abs(batch[1] - 0.7) < 1e-9 + + +def test_new_highs_new_lows_breadth(): + change = [[1.0, 1.0, -1.0], [1.0, -1.0, -1.0]] + volume = [[10.0] * 3 for _ in range(2)] + new_high = [[True, True, False], [True, False, False]] + new_low = [[False, False, True], [False, True, True]] + batch = _breadth_streaming_equals_batch(ta.NewHighsNewLows, change, volume, new_high, new_low) + # 2 - 1 = 1 ; 1 - 2 = -1. + assert list(batch) == [1.0, -1.0] + + +def test_high_low_index_breadth(): + change = [[1.0] * 10, [1.0] * 10] + volume = [[10.0] * 10 for _ in range(2)] + new_high = [[True] * 8 + [False] * 2, [True] * 6 + [False] * 4] + new_low = [[False] * 8 + [True] * 2, [False] * 6 + [True] * 4] + batch = ta.HighLowIndex(2).batch(change, volume, new_high, new_low) + streamer = ta.HighLowIndex(2) + streamed = np.array( + [streamer.update(change[i], volume[i], new_high[i], new_low[i]) for i in range(2)], + dtype=np.float64, + ) + assert _eq_nan(batch, streamed) + # 80% (warmup) ; SMA(2) of [80, 60] = 70. + assert math.isnan(batch[0]) + assert abs(batch[1] - 70.0) < 1e-9 + + +def test_percent_above_ma_breadth(): + change = [[1.0, 1.0, 1.0, -1.0], [1.0, 1.0, -1.0, -1.0]] + volume = [[10.0] * 4 for _ in range(2)] + flags = [[False] * 4 for _ in range(2)] + above_ma = [[True, True, True, False], [True, False, False, False]] + batch = ta.PercentAboveMa().batch(change, volume, flags, flags, above_ma) + streamer = ta.PercentAboveMa() + streamed = np.array( + [streamer.update(change[i], volume[i], flags[i], flags[i], above_ma[i]) for i in range(2)], + dtype=np.float64, + ) + assert _eq_nan(batch, streamed) + # 3/4 -> 75 ; 1/4 -> 25. + assert list(batch) == [75.0, 25.0] + + +def test_up_down_volume_ratio_breadth(): + change = [[1.0, -1.0], [1.0, 0.0]] + volume = [[150.0, 50.0], [100.0, 0.0]] + flags = [[False] * 2 for _ in range(2)] + batch = _breadth_streaming_equals_batch(ta.UpDownVolumeRatio, change, volume, flags, flags) + # 150/50 = 3 ; 100/max(0,1) = 100. + assert list(batch) == [3.0, 100.0] + + +def test_bullish_percent_index_breadth(): + change = [[1.0, 1.0, -1.0, -1.0], [1.0, 1.0, 1.0, 1.0]] + volume = [[10.0] * 4 for _ in range(2)] + flags = [[False] * 4 for _ in range(2)] + on_buy = [[True, True, False, False], [True, True, True, True]] + batch = ta.BullishPercentIndex().batch(change, volume, flags, flags, on_buy) + streamer = ta.BullishPercentIndex() + streamed = np.array( + [streamer.update(change[i], volume[i], flags[i], flags[i], on_buy[i]) for i in range(2)], + dtype=np.float64, + ) + assert _eq_nan(batch, streamed) + # 2/4 -> 50 ; 4/4 -> 100. + assert list(batch) == [50.0, 100.0] + + +def test_cumulative_volume_index_breadth(): + change = [[1.0, -1.0], [1.0, -1.0], [0.0]] + volume = [[150.0, 50.0], [60.0, 60.0], [0.0]] + new_high = [[False, False], [False, False], [False]] + new_low = [[False, False], [False, False], [False]] + batch = ta.CumulativeVolumeIndex().batch(change, volume, new_high, new_low) + streamer = ta.CumulativeVolumeIndex() + streamed = np.array( + [streamer.update(change[i], volume[i], new_high[i], new_low[i]) for i in range(3)], + dtype=np.float64, + ) + assert _eq_nan(batch, streamed) + # (100/200) -> 0.5 ; net 0 -> 0.5 ; zero-volume tick -> 0.5. + assert list(batch) == [0.5, 0.5, 0.5] + + +def test_absolute_breadth_index_breadth(): + change = [[1.0, 1.0, -1.0, -1.0, -1.0], [1.0, 1.0, 1.0, -1.0, -1.0]] + volume = [[10.0] * 5 for _ in range(2)] + flags = [[False] * 5 for _ in range(2)] + batch = _breadth_streaming_equals_batch(ta.AbsoluteBreadthIndex, change, volume, flags, flags) + # |2 - 3| = 1 ; |3 - 2| = 1. + assert list(batch) == [1.0, 1.0] + + +def test_tick_index_breadth(): + change = [[1.0, 1.0, -1.0, -1.0, -1.0], [1.0, 1.0, 1.0, -1.0, -1.0]] + volume = [[10.0] * 5 for _ in range(2)] + flags = [[False] * 5 for _ in range(2)] + batch = _breadth_streaming_equals_batch(ta.TickIndex, change, volume, flags, flags) + # 2 - 3 = -1 ; 3 - 2 = 1. + assert list(batch) == [-1.0, 1.0] + + def test_funding_basis_streaming_equals_batch(): n = 40 index = np.array([100.0 + 0.5 * math.sin(i * 0.2) for i in range(n)], dtype=np.float64) diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 1e44c62b..909d2f12 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -8437,6 +8437,664 @@ impl WasmAdvanceDecline { } } +fn build_cross_section_above_ma( + change: &[f64], + volume: &[f64], + new_high: &[f64], + new_low: &[f64], + above_ma: &[f64], +) -> Result { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != above_ma.len() + { + return Err(JsError::new( + "change, volume, newHigh, newLow and aboveMa must be equal length", + )); + } + let members = (0..change.len()) + .map(|i| { + wc::Member::with_signals( + change[i], + volume[i], + new_high[i] != 0.0, + new_low[i] != 0.0, + above_ma[i] != 0.0, + false, + ) + }) + .collect(); + wc::CrossSection::new(members, 0).map_err(map_err) +} + +fn build_cross_section_buy( + change: &[f64], + volume: &[f64], + new_high: &[f64], + new_low: &[f64], + on_buy_signal: &[f64], +) -> Result { + if change.len() != volume.len() + || change.len() != new_high.len() + || change.len() != new_low.len() + || change.len() != on_buy_signal.len() + { + return Err(JsError::new( + "change, volume, newHigh, newLow and onBuySignal must be equal length", + )); + } + let members = (0..change.len()) + .map(|i| { + wc::Member::with_signals( + change[i], + volume[i], + new_high[i] != 0.0, + new_low[i] != 0.0, + false, + on_buy_signal[i] != 0.0, + ) + }) + .collect(); + wc::CrossSection::new(members, 0).map_err(map_err) +} + +#[wasm_bindgen(js_name = AdvanceDeclineRatio)] +pub struct WasmAdvanceDeclineRatio { + inner: wc::AdvanceDeclineRatio, +} + +impl Default for WasmAdvanceDeclineRatio { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = AdvanceDeclineRatio)] +impl WasmAdvanceDeclineRatio { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmAdvanceDeclineRatio { + Self { + inner: wc::AdvanceDeclineRatio::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = AdVolumeLine)] +pub struct WasmAdVolumeLine { + inner: wc::AdVolumeLine, +} + +impl Default for WasmAdVolumeLine { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = AdVolumeLine)] +impl WasmAdVolumeLine { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmAdVolumeLine { + Self { + inner: wc::AdVolumeLine::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = McClellanOscillator)] +pub struct WasmMcClellanOscillator { + inner: wc::McClellanOscillator, +} + +impl Default for WasmMcClellanOscillator { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = McClellanOscillator)] +impl WasmMcClellanOscillator { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmMcClellanOscillator { + Self { + inner: wc::McClellanOscillator::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = McClellanSummationIndex)] +pub struct WasmMcClellanSummationIndex { + inner: wc::McClellanSummationIndex, +} + +impl Default for WasmMcClellanSummationIndex { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = McClellanSummationIndex)] +impl WasmMcClellanSummationIndex { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmMcClellanSummationIndex { + Self { + inner: wc::McClellanSummationIndex::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = Trin)] +pub struct WasmTrin { + inner: wc::Trin, +} + +impl Default for WasmTrin { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = Trin)] +impl WasmTrin { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmTrin { + Self { + inner: wc::Trin::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = BreadthThrust)] +pub struct WasmBreadthThrust { + inner: wc::BreadthThrust, +} + +#[wasm_bindgen(js_class = BreadthThrust)] +impl WasmBreadthThrust { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(WasmBreadthThrust { + inner: wc::BreadthThrust::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = NewHighsNewLows)] +pub struct WasmNewHighsNewLows { + inner: wc::NewHighsNewLows, +} + +impl Default for WasmNewHighsNewLows { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = NewHighsNewLows)] +impl WasmNewHighsNewLows { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmNewHighsNewLows { + Self { + inner: wc::NewHighsNewLows::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = HighLowIndex)] +pub struct WasmHighLowIndex { + inner: wc::HighLowIndex, +} + +#[wasm_bindgen(js_class = HighLowIndex)] +impl WasmHighLowIndex { + #[wasm_bindgen(constructor)] + pub fn new(period: usize) -> Result { + Ok(WasmHighLowIndex { + inner: wc::HighLowIndex::new(period).map_err(map_err)?, + }) + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = PercentAboveMa)] +pub struct WasmPercentAboveMa { + inner: wc::PercentAboveMa, +} + +impl Default for WasmPercentAboveMa { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = PercentAboveMa)] +impl WasmPercentAboveMa { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmPercentAboveMa { + Self { + inner: wc::PercentAboveMa::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + above_ma: Vec, + ) -> Result, JsError> { + Ok(self.inner.update(build_cross_section_above_ma( + &change, &volume, &new_high, &new_low, &above_ma, + )?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = UpDownVolumeRatio)] +pub struct WasmUpDownVolumeRatio { + inner: wc::UpDownVolumeRatio, +} + +impl Default for WasmUpDownVolumeRatio { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = UpDownVolumeRatio)] +impl WasmUpDownVolumeRatio { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmUpDownVolumeRatio { + Self { + inner: wc::UpDownVolumeRatio::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = BullishPercentIndex)] +pub struct WasmBullishPercentIndex { + inner: wc::BullishPercentIndex, +} + +impl Default for WasmBullishPercentIndex { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = BullishPercentIndex)] +impl WasmBullishPercentIndex { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmBullishPercentIndex { + Self { + inner: wc::BullishPercentIndex::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + on_buy_signal: Vec, + ) -> Result, JsError> { + Ok(self.inner.update(build_cross_section_buy( + &change, + &volume, + &new_high, + &new_low, + &on_buy_signal, + )?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = CumulativeVolumeIndex)] +pub struct WasmCumulativeVolumeIndex { + inner: wc::CumulativeVolumeIndex, +} + +impl Default for WasmCumulativeVolumeIndex { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = CumulativeVolumeIndex)] +impl WasmCumulativeVolumeIndex { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmCumulativeVolumeIndex { + Self { + inner: wc::CumulativeVolumeIndex::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = AbsoluteBreadthIndex)] +pub struct WasmAbsoluteBreadthIndex { + inner: wc::AbsoluteBreadthIndex, +} + +impl Default for WasmAbsoluteBreadthIndex { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = AbsoluteBreadthIndex)] +impl WasmAbsoluteBreadthIndex { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmAbsoluteBreadthIndex { + Self { + inner: wc::AbsoluteBreadthIndex::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + pub fn reset(&mut self) { + self.inner.reset(); + } + #[wasm_bindgen(js_name = isReady)] + pub fn is_ready(&self) -> bool { + self.inner.is_ready() + } + #[wasm_bindgen(js_name = warmupPeriod)] + pub fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } +} + +#[wasm_bindgen(js_name = TickIndex)] +pub struct WasmTickIndex { + inner: wc::TickIndex, +} + +impl Default for WasmTickIndex { + fn default() -> Self { + Self::new() + } +} + +#[wasm_bindgen(js_class = TickIndex)] +impl WasmTickIndex { + #[wasm_bindgen(constructor)] + pub fn new() -> WasmTickIndex { + Self { + inner: wc::TickIndex::new(), + } + } + pub fn update( + &mut self, + change: Vec, + volume: Vec, + new_high: Vec, + new_low: Vec, + ) -> Result, JsError> { + Ok(self + .inner + .update(build_cross_section(&change, &volume, &new_high, &new_low)?)) + } + 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() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/clippy.toml b/clippy.toml index b2af704d..de988ae9 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,4 +1,4 @@ # Proper nouns that appear in indicator documentation. They are real names, # not code identifiers, so `clippy::doc_markdown` must not demand backticks. # `..` keeps clippy's built-in default identifier list in addition to these. -doc-valid-idents = ["LeBeau", ".."] +doc-valid-idents = ["LeBeau", "McClellan", ".."] diff --git a/crates/wickra-core/src/cross_section.rs b/crates/wickra-core/src/cross_section.rs index 5b2c4aa0..d26b2231 100644 --- a/crates/wickra-core/src/cross_section.rs +++ b/crates/wickra-core/src/cross_section.rs @@ -9,9 +9,10 @@ //! //! Each [`Member`] precomputes the per-symbol signals the breadth indicators //! need — a signed price `change` (whose sign classifies the symbol as -//! advancing, declining or unchanged), the period `volume`, and the -//! `new_high` / `new_low` extreme flags — so the indicators stay stateless per -//! tick and never have to track per-symbol history. +//! advancing, declining or unchanged), the period `volume`, the +//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal` +//! state flags — so the indicators stay stateless per tick and never have to +//! track per-symbol history. //! //! [`DerivativesTick`]: crate::DerivativesTick //! [`OrderBook`]: crate::OrderBook @@ -28,9 +29,16 @@ use crate::error::{Error, Result}; /// - `volume` is finite and non-negative. /// /// `new_high` / `new_low` are caller-supplied flags marking whether the symbol -/// printed a new period extreme; they carry no numeric invariant. +/// printed a new period extreme; `above_ma` / `on_buy_signal` are caller-supplied +/// per-symbol state signals (whether the symbol trades above its reference moving +/// average, and whether it is on a point-and-figure buy signal). None of the four +/// flags carries a numeric invariant. #[non_exhaustive] #[derive(Debug, Clone, Copy, PartialEq)] +#[allow( + clippy::struct_excessive_bools, + reason = "the four flags are independent per-symbol breadth signals, not a state machine" +)] pub struct Member { /// Price change versus the previous close. Sign classifies the symbol: /// positive is advancing, negative is declining, zero is unchanged. @@ -41,10 +49,17 @@ pub struct Member { pub new_high: bool, /// Whether the symbol printed a new period low. pub new_low: bool, + /// Whether the symbol is trading above its reference moving average + /// (consumed by the `% Above Moving Average` breadth indicator). + pub above_ma: bool, + /// Whether the symbol is on a point-and-figure buy signal + /// (consumed by the `Bullish Percent Index` breadth indicator). + pub on_buy_signal: bool, } impl Member { - /// Assemble a cross-section member. + /// Assemble a cross-section member from its core signals, leaving the + /// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared. /// /// The field invariants documented on [`Member`] are validated centrally by /// [`CrossSection::new`] when the member is placed into a tick; this @@ -57,6 +72,37 @@ impl Member { volume, new_high, new_low, + above_ma: false, + on_buy_signal: false, + } + } + + /// Assemble a cross-section member including the extended per-symbol state + /// signals `above_ma` and `on_buy_signal`. + /// + /// Use this constructor for the breadth indicators that read per-symbol + /// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new) + /// is the shorthand that leaves both flags `false`. + #[must_use] + #[allow( + clippy::fn_params_excessive_bools, + reason = "mirrors the four independent per-symbol flag fields of Member" + )] + pub const fn with_signals( + change: f64, + volume: f64, + new_high: bool, + new_low: bool, + above_ma: bool, + on_buy_signal: bool, + ) -> Self { + Self { + change, + volume, + new_high, + new_low, + above_ma, + on_buy_signal, } } } @@ -126,6 +172,56 @@ impl CrossSection { pub fn decliners(&self) -> usize { self.members.iter().filter(|m| m.change < 0.0).count() } + + /// Total volume traded by advancing symbols (those with positive `change`). + #[must_use] + pub fn advancing_volume(&self) -> f64 { + self.members + .iter() + .filter(|m| m.change > 0.0) + .map(|m| m.volume) + .sum() + } + + /// Total volume traded by declining symbols (those with negative `change`). + #[must_use] + pub fn declining_volume(&self) -> f64 { + self.members + .iter() + .filter(|m| m.change < 0.0) + .map(|m| m.volume) + .sum() + } + + /// Total volume traded across the whole universe. + #[must_use] + pub fn total_volume(&self) -> f64 { + self.members.iter().map(|m| m.volume).sum() + } + + /// Number of symbols that printed a new period high. + #[must_use] + pub fn new_highs(&self) -> usize { + self.members.iter().filter(|m| m.new_high).count() + } + + /// Number of symbols that printed a new period low. + #[must_use] + pub fn new_lows(&self) -> usize { + self.members.iter().filter(|m| m.new_low).count() + } + + /// Number of symbols trading above their reference moving average. + #[must_use] + pub fn above_ma_count(&self) -> usize { + self.members.iter().filter(|m| m.above_ma).count() + } + + /// Number of symbols on a point-and-figure buy signal. + #[must_use] + pub fn on_buy_signal_count(&self) -> usize { + self.members.iter().filter(|m| m.on_buy_signal).count() + } } #[cfg(test)] @@ -223,4 +319,69 @@ mod tests { assert_eq!(cs.advancers(), 0); assert_eq!(cs.decliners(), 0); } + + #[test] + fn new_leaves_extended_flags_cleared() { + let m = Member::new(1.0, 10.0, true, false); + assert!(!m.above_ma); + assert!(!m.on_buy_signal); + } + + #[test] + fn with_signals_assembles_all_fields() { + let m = Member::with_signals(2.0, 10.0, true, false, true, true); + assert_eq!(m.change, 2.0); + assert_eq!(m.volume, 10.0); + assert!(m.new_high); + assert!(!m.new_low); + assert!(m.above_ma); + assert!(m.on_buy_signal); + } + + #[test] + fn volume_helpers_bucket_by_change_sign() { + let cs = CrossSection::new( + vec![ + Member::new(1.5, 100.0, false, false), // advancing + Member::new(2.0, 40.0, false, false), // advancing + Member::new(-0.5, 50.0, false, false), // declining + Member::new(0.0, 7.0, false, false), // unchanged + ], + 0, + ) + .unwrap(); + assert_eq!(cs.advancing_volume(), 140.0); + assert_eq!(cs.declining_volume(), 50.0); + assert_eq!(cs.total_volume(), 197.0); + } + + #[test] + fn high_low_helpers_count_flags() { + let cs = CrossSection::new( + vec![ + Member::new(1.0, 1.0, true, false), + Member::new(1.0, 1.0, true, false), + Member::new(-1.0, 1.0, false, true), + ], + 0, + ) + .unwrap(); + assert_eq!(cs.new_highs(), 2); + assert_eq!(cs.new_lows(), 1); + } + + #[test] + fn state_helpers_count_extended_flags() { + let cs = CrossSection::new( + vec![ + Member::with_signals(1.0, 1.0, false, false, true, true), + Member::with_signals(1.0, 1.0, false, false, true, false), + Member::with_signals(-1.0, 1.0, false, false, false, true), + ], + 0, + ) + .unwrap(); + assert_eq!(cs.above_ma_count(), 2); + assert_eq!(cs.on_buy_signal_count(), 2); + } } diff --git a/crates/wickra-core/src/indicators/absolute_breadth_index.rs b/crates/wickra-core/src/indicators/absolute_breadth_index.rs new file mode 100644 index 00000000..08020d06 --- /dev/null +++ b/crates/wickra-core/src/indicators/absolute_breadth_index.rs @@ -0,0 +1,144 @@ +//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues, +/// `|advancers - decliners|`. +/// +/// The ABI ignores the *direction* of breadth and measures only its *magnitude*: +/// a high reading means the universe moved decisively one way or the other (high +/// internal activity / volatility), while a low reading means advances and +/// declines were nearly balanced (a quiet, directionless market). It is sometimes +/// called a "market thermometer" because elevated readings often cluster around +/// turning points. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member}; +/// +/// let mut abi = AbsoluteBreadthIndex::new(); +/// // 2 advancers, 5 decliners -> |2 - 5| = 3. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(abi.update(tick), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AbsoluteBreadthIndex { + has_emitted: bool, +} + +impl AbsoluteBreadthIndex { + /// Construct a new Absolute Breadth Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for AbsoluteBreadthIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancers() as f64 - section.decliners() as f64; + self.has_emitted = true; + Some(net.abs()) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AbsoluteBreadthIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let abi = AbsoluteBreadthIndex::new(); + assert_eq!(abi.name(), "AbsoluteBreadthIndex"); + assert_eq!(abi.warmup_period(), 1); + assert!(!abi.is_ready()); + } + + #[test] + fn magnitude_ignores_direction() { + let mut abi = AbsoluteBreadthIndex::new(); + assert_eq!(abi.update(section(2, 5)), Some(3.0)); + // Same magnitude with the direction reversed. + let mut abi2 = AbsoluteBreadthIndex::new(); + assert_eq!(abi2.update(section(5, 2)), Some(3.0)); + } + + #[test] + fn balanced_universe_yields_zero() { + let mut abi = AbsoluteBreadthIndex::new(); + assert_eq!(abi.update(section(3, 3)), Some(0.0)); + assert!(abi.is_ready()); + } + + #[test] + fn reset_clears_state() { + let mut abi = AbsoluteBreadthIndex::new(); + abi.update(section(2, 5)); + assert!(abi.is_ready()); + abi.reset(); + assert!(!abi.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(2, 5), section(5, 2), section(3, 3)]; + let mut a = AbsoluteBreadthIndex::new(); + let mut b = AbsoluteBreadthIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/ad_volume_line.rs b/crates/wickra-core/src/indicators/ad_volume_line.rs new file mode 100644 index 00000000..ad40c3ad --- /dev/null +++ b/crates/wickra-core/src/indicators/ad_volume_line.rs @@ -0,0 +1,157 @@ +//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of +/// net advancing volume across a universe. +/// +/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`, +/// where advancing volume is the total volume of symbols with a positive change +/// and declining volume the total volume of symbols with a negative change. The +/// line accumulates this net over time, so a rising line means volume is flowing +/// into advancing issues (healthy participation) while a falling line warns that +/// declining issues are carrying the volume — the volume-weighted analogue of the +/// plain Advance/Decline Line. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the +/// first tick). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member}; +/// +/// let mut adv = AdVolumeLine::new(); +/// // advancing volume 150, declining volume 50 -> net +100. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 150.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(adv.update(tick), Some(100.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdVolumeLine { + line: f64, + has_emitted: bool, +} + +impl AdVolumeLine { + /// Construct a new Advance/Decline Volume Line indicator. + #[must_use] + pub const fn new() -> Self { + Self { + line: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for AdVolumeLine { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancing_volume() - section.declining_volume(); + self.line += net; + self.has_emitted = true; + Some(self.line) + } + + fn reset(&mut self) { + self.line = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AdVolumeLine" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let adv = AdVolumeLine::new(); + assert_eq!(adv.name(), "AdVolumeLine"); + assert_eq!(adv.warmup_period(), 1); + assert!(!adv.is_ready()); + } + + #[test] + fn first_tick_emits_net_volume() { + let mut adv = AdVolumeLine::new(); + assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0)); + assert!(adv.is_ready()); + } + + #[test] + fn line_accumulates_across_ticks() { + let mut adv = AdVolumeLine::new(); + assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0)); + assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0)); + assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0)); + } + + #[test] + fn unchanged_volume_is_ignored() { + let mut adv = AdVolumeLine::new(); + // Unchanged symbols (zero change) contribute to neither bucket. + assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0)); + } + + #[test] + fn reset_clears_state() { + let mut adv = AdVolumeLine::new(); + adv.update(tick(&[(1.0, 100.0)])); + assert!(adv.is_ready()); + adv.reset(); + assert!(!adv.is_ready()); + assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 150.0), (-1.0, 50.0)]), + tick(&[(1.0, 60.0), (-1.0, 60.0)]), + tick(&[(1.0, 30.0)]), + ]; + let mut a = AdVolumeLine::new(); + let mut b = AdVolumeLine::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/advance_decline_ratio.rs b/crates/wickra-core/src/indicators/advance_decline_ratio.rs new file mode 100644 index 00000000..1619121e --- /dev/null +++ b/crates/wickra-core/src/indicators/advance_decline_ratio.rs @@ -0,0 +1,151 @@ +//! Advance/Decline Ratio — advancing issues divided by declining issues. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the +/// number of declining symbols across a universe. +/// +/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading +/// above one means advancing issues outnumber declining ones (broad strength), +/// while a reading below one signals broad weakness. Because it is a ratio rather +/// than a difference, the ADR is comparable across universes of different sizes. +/// +/// When a tick has no declining symbols the denominator is floored to one, so the +/// ratio degrades gracefully to the advancer count instead of dividing by zero. +/// +/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first +/// tick, so `warmup_period == 1` and the indicator is ready after one update. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member}; +/// +/// let mut adr = AdvanceDeclineRatio::new(); +/// // 3 advancers, 1 decliner -> ratio 3.0. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(0.5, 10.0, false, false), +/// Member::new(2.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(adr.update(tick), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct AdvanceDeclineRatio { + has_emitted: bool, +} + +impl AdvanceDeclineRatio { + /// Construct a new Advance/Decline Ratio indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for AdvanceDeclineRatio { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancers = section.advancers() as f64; + let decliners = section.decliners().max(1) as f64; + self.has_emitted = true; + Some(advancers / decliners) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "AdvanceDeclineRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + // A non-empty unchanged member guarantees a valid universe when both + // counts are zero. + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let adr = AdvanceDeclineRatio::new(); + assert_eq!(adr.name(), "AdvanceDeclineRatio"); + assert_eq!(adr.warmup_period(), 1); + assert!(!adr.is_ready()); + } + + #[test] + fn first_tick_emits_ratio() { + let mut adr = AdvanceDeclineRatio::new(); + assert_eq!(adr.update(section(3, 1)), Some(3.0)); + assert!(adr.is_ready()); + } + + #[test] + fn zero_decliners_floors_denominator() { + let mut adr = AdvanceDeclineRatio::new(); + // 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0. + assert_eq!(adr.update(section(4, 0)), Some(4.0)); + } + + #[test] + fn no_advancers_yields_zero() { + let mut adr = AdvanceDeclineRatio::new(); + assert_eq!(adr.update(section(0, 5)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut adr = AdvanceDeclineRatio::new(); + adr.update(section(3, 1)); + assert!(adr.is_ready()); + adr.reset(); + assert!(!adr.is_ready()); + assert_eq!(adr.update(section(2, 1)), Some(2.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)]; + let mut a = AdvanceDeclineRatio::new(); + let mut b = AdvanceDeclineRatio::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/breadth_thrust.rs b/crates/wickra-core/src/indicators/breadth_thrust.rs new file mode 100644 index 00000000..99d9549d --- /dev/null +++ b/crates/wickra-core/src/indicators/breadth_thrust.rs @@ -0,0 +1,165 @@ +//! Breadth Thrust (Zweig) — a moving average of the advancing-issues share. + +use crate::cross_section::CrossSection; +use crate::error::Result; +use crate::traits::Indicator; +use crate::Sma; + +/// Breadth Thrust (Zweig) — a simple moving average of the advancing-issues +/// share, `advancers / (advancers + decliners)`. +/// +/// Martin Zweig's breadth thrust smooths the fraction of participating issues +/// that are advancing over a short window (the classic period is 10). A "thrust" +/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth) +/// to above ~0.615 within about ten sessions — historically a rare, reliable +/// signal that a powerful new advance has begun with broad participation. +/// +/// Each tick's share floors the participating count to one, so a tick with no +/// advancing or declining issues contributes a defined `0.0` instead of dividing +/// by zero. The reading is `None` until `period` ticks have been seen. +/// +/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`), +/// `warmup_period == period`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member}; +/// +/// let mut bt = BreadthThrust::new(2).unwrap(); +/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap(); +/// assert_eq!(bt.update(up.clone()), None); // warming up +/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing +/// ``` +#[derive(Debug, Clone)] +pub struct BreadthThrust { + sma: Sma, +} + +impl BreadthThrust { + /// Construct a new Breadth Thrust over the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + sma: Sma::new(period)?, + }) + } + + /// Configured window length. + #[must_use] + pub const fn period(&self) -> usize { + self.sma.period() + } +} + +impl Indicator for BreadthThrust { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancers = section.advancers(); + let decliners = section.decliners(); + let participating = (advancers + decliners).max(1) as f64; + let share = advancers as f64 / participating; + self.sma.update(share) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma.period() + } + + fn is_ready(&self) -> bool { + self.sma.value().is_some() + } + + fn name(&self) -> &'static str { + "BreadthThrust" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::error::Error; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let bt = BreadthThrust::new(10).unwrap(); + assert_eq!(bt.name(), "BreadthThrust"); + assert_eq!(bt.warmup_period(), 10); + assert_eq!(bt.period(), 10); + assert!(!bt.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn averages_the_advancing_share() { + let mut bt = BreadthThrust::new(2).unwrap(); + // share = 8 / 10 = 0.8 ; window not full yet. + assert_eq!(bt.update(section(8, 2)), None); + // share = 6 / 10 = 0.6 ; SMA(2) = (0.8 + 0.6) / 2 = 0.7. + let value = bt.update(section(6, 4)).unwrap(); + assert!((value - 0.7).abs() < 1e-9); + assert!(bt.is_ready()); + // share = 5 / 10 = 0.5 ; SMA(2) = (0.6 + 0.5) / 2 = 0.55. + let value = bt.update(section(5, 5)).unwrap(); + assert!((value - 0.55).abs() < 1e-9); + } + + #[test] + fn empty_participation_floors_to_zero_share() { + let mut bt = BreadthThrust::new(1).unwrap(); + // No advancers or decliners -> 0 / max(0, 1) = 0.0. + assert_eq!(bt.update(section(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut bt = BreadthThrust::new(2).unwrap(); + bt.update(section(8, 2)); + bt.update(section(6, 4)); + assert!(bt.is_ready()); + bt.reset(); + assert!(!bt.is_ready()); + assert_eq!(bt.update(section(8, 2)), None); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)]; + let mut a = BreadthThrust::new(2).unwrap(); + let mut b = BreadthThrust::new(2).unwrap(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/bullish_percent_index.rs b/crates/wickra-core/src/indicators/bullish_percent_index.rs new file mode 100644 index 00000000..5eb5049b --- /dev/null +++ b/crates/wickra-core/src/indicators/bullish_percent_index.rs @@ -0,0 +1,147 @@ +//! Bullish Percent Index — share of a universe on a point-and-figure buy signal. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are +/// currently on a point-and-figure buy signal. +/// +/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count / +/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller +/// evaluates each symbol's point-and-figure chart when it builds the tick). It is +/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend. +/// Readings above 70 are considered overbought (broad strength, but a crowded +/// market) and below 30 oversold; reversals from those zones are classic BPI +/// buy/sell triggers. +/// +/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`), +/// `warmup_period == 1`. The universe is non-empty by construction, so the share +/// is always defined. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member}; +/// +/// let mut bpi = BullishPercentIndex::new(); +/// // 2 of 4 symbols on a buy signal -> 50%. +/// let tick = CrossSection::new( +/// vec![ +/// Member::with_signals(1.0, 10.0, false, false, false, true), +/// Member::with_signals(1.0, 10.0, false, false, false, true), +/// Member::with_signals(-1.0, 10.0, false, false, false, false), +/// Member::with_signals(-1.0, 10.0, false, false, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(bpi.update(tick), Some(50.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct BullishPercentIndex { + has_emitted: bool, +} + +impl BullishPercentIndex { + /// Construct a new Bullish Percent Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for BullishPercentIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let bullish = section.on_buy_signal_count() as f64; + let total = section.members.len() as f64; + self.has_emitted = true; + Some(100.0 * bullish / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "BullishPercentIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(bullish: usize, bearish: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..bullish { + members.push(Member::with_signals(1.0, 10.0, false, false, false, true)); + } + for _ in 0..bearish { + members.push(Member::with_signals(-1.0, 10.0, false, false, false, false)); + } + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let bpi = BullishPercentIndex::new(); + assert_eq!(bpi.name(), "BullishPercentIndex"); + assert_eq!(bpi.warmup_period(), 1); + assert!(!bpi.is_ready()); + } + + #[test] + fn first_tick_emits_percentage() { + let mut bpi = BullishPercentIndex::new(); + assert_eq!(bpi.update(tick(2, 2)), Some(50.0)); + assert!(bpi.is_ready()); + } + + #[test] + fn all_bullish_is_one_hundred() { + let mut bpi = BullishPercentIndex::new(); + assert_eq!(bpi.update(tick(5, 0)), Some(100.0)); + } + + #[test] + fn none_bullish_is_zero() { + let mut bpi = BullishPercentIndex::new(); + assert_eq!(bpi.update(tick(0, 4)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut bpi = BullishPercentIndex::new(); + bpi.update(tick(2, 2)); + assert!(bpi.is_ready()); + bpi.reset(); + assert!(!bpi.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)]; + let mut a = BullishPercentIndex::new(); + let mut b = BullishPercentIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/cumulative_volume_index.rs b/crates/wickra-core/src/indicators/cumulative_volume_index.rs new file mode 100644 index 00000000..4250c003 --- /dev/null +++ b/crates/wickra-core/src/indicators/cumulative_volume_index.rs @@ -0,0 +1,163 @@ +//! Cumulative Volume Index — running total of volume-normalised net advancing volume. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Cumulative Volume Index (CVI) — the running total of *volume-normalised* net +/// advancing volume across a universe. +/// +/// On each [`CrossSection`] tick the increment is `(advancing volume - declining +/// volume) / total volume`: the share of the tick's total volume that flowed, +/// net, into advancing issues. The index accumulates this share over time. Where +/// the raw [`AdVolumeLine`](crate::AdVolumeLine) sums *absolute* net volume — and +/// so drifts with secular growth in trading activity — the CVI normalises each +/// tick by its own total volume, so a one-share-net day in a thin market counts +/// the same as in a heavy one. This keeps the index comparable across regimes of +/// very different volume. +/// +/// When a tick has zero total volume the net is necessarily zero too, so the +/// increment is zero and the index is unchanged (the divisor is floored to the +/// smallest positive `f64` purely to keep the division defined). +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member}; +/// +/// let mut cvi = CumulativeVolumeIndex::new(); +/// // adv vol 150, dec vol 50, total 200 -> (150 - 50) / 200 = 0.5. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 150.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(cvi.update(tick), Some(0.5)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct CumulativeVolumeIndex { + index: f64, + has_emitted: bool, +} + +impl CumulativeVolumeIndex { + /// Construct a new Cumulative Volume Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { + index: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for CumulativeVolumeIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancing_volume() - section.declining_volume(); + let total = section.total_volume().max(f64::MIN_POSITIVE); + self.index += net / total; + self.has_emitted = true; + Some(self.index) + } + + fn reset(&mut self) { + self.index = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CumulativeVolumeIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let cvi = CumulativeVolumeIndex::new(); + assert_eq!(cvi.name(), "CumulativeVolumeIndex"); + assert_eq!(cvi.warmup_period(), 1); + assert!(!cvi.is_ready()); + } + + #[test] + fn first_tick_emits_normalised_net() { + let mut cvi = CumulativeVolumeIndex::new(); + assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5)); + assert!(cvi.is_ready()); + } + + #[test] + fn index_accumulates_normalised_shares() { + let mut cvi = CumulativeVolumeIndex::new(); + assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5)); + // adv 60, dec 60, total 120 -> net 0 -> index unchanged. + assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5)); + } + + #[test] + fn zero_total_volume_leaves_index_unchanged() { + let mut cvi = CumulativeVolumeIndex::new(); + cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])); + // A tick with no volume at all: net 0 / floored divisor -> 0 increment. + assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5)); + } + + #[test] + fn reset_clears_state() { + let mut cvi = CumulativeVolumeIndex::new(); + cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])); + assert!(cvi.is_ready()); + cvi.reset(); + assert!(!cvi.is_ready()); + assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 150.0), (-1.0, 50.0)]), + tick(&[(1.0, 60.0), (-1.0, 60.0)]), + tick(&[(0.0, 0.0)]), + ]; + let mut a = CumulativeVolumeIndex::new(); + let mut b = CumulativeVolumeIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/high_low_index.rs b/crates/wickra-core/src/indicators/high_low_index.rs new file mode 100644 index 00000000..d4265830 --- /dev/null +++ b/crates/wickra-core/src/indicators/high_low_index.rs @@ -0,0 +1,162 @@ +//! High-Low Index — a moving average of the record-high percentage. + +use crate::cross_section::CrossSection; +use crate::error::Result; +use crate::traits::Indicator; +use crate::Sma; + +/// High-Low Index — a simple moving average of the *record high percent*, +/// `100 * new_highs / (new_highs + new_lows)`. +/// +/// The record high percent is the share of new-extreme issues that are new +/// *highs* rather than new *lows*; smoothing it over a window (the classic period +/// is 10) gives the High-Low Index. Readings above 50 mean new highs dominate +/// (a healthy, broadening trend), readings below 50 mean new lows dominate. The +/// 30 and 70 lines are watched as oversold / overbought breadth thresholds. +/// +/// Each tick floors the new-extreme count to one, so a tick with no new highs or +/// lows contributes a defined `0.0` instead of dividing by zero. The reading is +/// `None` until `period` ticks have been seen. +/// +/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`), +/// `warmup_period == period`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, HighLowIndex, Indicator, Member}; +/// +/// let mut hli = HighLowIndex::new(2).unwrap(); +/// let highs = CrossSection::new(vec![Member::new(1.0, 1.0, true, false)], 0).unwrap(); +/// assert_eq!(hli.update(highs.clone()), None); // warming up +/// assert_eq!(hli.update(highs), Some(100.0)); // all new highs +/// ``` +#[derive(Debug, Clone)] +pub struct HighLowIndex { + sma: Sma, +} + +impl HighLowIndex { + /// Construct a new High-Low Index over the given window length. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`. + pub fn new(period: usize) -> Result { + Ok(Self { + sma: Sma::new(period)?, + }) + } + + /// Configured window length. + #[must_use] + pub const fn period(&self) -> usize { + self.sma.period() + } +} + +impl Indicator for HighLowIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let new_highs = section.new_highs(); + let new_lows = section.new_lows(); + let extremes = (new_highs + new_lows).max(1) as f64; + let record_high_percent = 100.0 * new_highs as f64 / extremes; + self.sma.update(record_high_percent) + } + + fn reset(&mut self) { + self.sma.reset(); + } + + fn warmup_period(&self) -> usize { + self.sma.period() + } + + fn is_ready(&self) -> bool { + self.sma.value().is_some() + } + + fn name(&self) -> &'static str { + "HighLowIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::error::Error; + use crate::traits::BatchExt; + + fn flags(highs: usize, lows: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..highs { + members.push(Member::new(1.0, 10.0, true, false)); + } + for _ in 0..lows { + members.push(Member::new(-1.0, 10.0, false, true)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let hli = HighLowIndex::new(10).unwrap(); + assert_eq!(hli.name(), "HighLowIndex"); + assert_eq!(hli.warmup_period(), 10); + assert_eq!(hli.period(), 10); + assert!(!hli.is_ready()); + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(HighLowIndex::new(0), Err(Error::PeriodZero))); + } + + #[test] + fn averages_the_record_high_percent() { + let mut hli = HighLowIndex::new(2).unwrap(); + // 8 highs / 10 extremes -> 80% ; window not full. + assert_eq!(hli.update(flags(8, 2)), None); + // 6 highs / 10 extremes -> 60% ; SMA(2) = (80 + 60) / 2 = 70. + let value = hli.update(flags(6, 4)).unwrap(); + assert!((value - 70.0).abs() < 1e-9); + assert!(hli.is_ready()); + } + + #[test] + fn no_extremes_floors_to_zero_percent() { + let mut hli = HighLowIndex::new(1).unwrap(); + // No new highs or lows -> 0 / max(0, 1) -> 0%. + assert_eq!(hli.update(flags(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut hli = HighLowIndex::new(2).unwrap(); + hli.update(flags(8, 2)); + hli.update(flags(6, 4)); + assert!(hli.is_ready()); + hli.reset(); + assert!(!hli.is_ready()); + assert_eq!(hli.update(flags(8, 2)), None); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![flags(8, 2), flags(6, 4), flags(3, 7), flags(0, 0)]; + let mut a = HighLowIndex::new(2).unwrap(); + let mut b = HighLowIndex::new(2).unwrap(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mcclellan_oscillator.rs b/crates/wickra-core/src/indicators/mcclellan_oscillator.rs new file mode 100644 index 00000000..308bbc88 --- /dev/null +++ b/crates/wickra-core/src/indicators/mcclellan_oscillator.rs @@ -0,0 +1,204 @@ +//! McClellan Oscillator — the spread between a fast and slow EMA of breadth. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Fast EMA smoothing constant — the classic McClellan 19-period weight +/// `2 / (19 + 1)`. +const ALPHA_FAST: f64 = 0.1; +/// Slow EMA smoothing constant — the classic McClellan 39-period weight +/// `2 / (39 + 1)`. +const ALPHA_SLOW: f64 = 0.05; +/// Scale applied to the ratio-adjusted net advances so readings land on the +/// familiar McClellan amplitude. +const RANA_SCALE: f64 = 1000.0; + +/// McClellan Oscillator — the difference between a 19-period and a 39-period +/// exponential moving average of *ratio-adjusted net advances*. +/// +/// Each tick's breadth is reduced to ratio-adjusted net advances (RANA), +/// `(advancers - decliners) / (advancers + decliners) * 1000`. Dividing by the +/// number of participating issues makes the reading independent of universe size, +/// so the oscillator stays comparable as the universe grows or shrinks. The +/// oscillator is then the fast EMA minus the slow EMA of that series, using the +/// classic McClellan smoothing constants `0.10` (19-period) and `0.05` +/// (39-period). Both EMAs are seeded from the first tick's RANA, so the +/// oscillator is defined from the first update (`warmup_period == 1`); it starts +/// at `0.0` and crosses zero as breadth momentum shifts. +/// +/// A tick with no advancing or declining issues yields a RANA of `0.0` (the +/// participating count is floored to one). +/// +/// `Input = CrossSection`, `Output = f64`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, McClellanOscillator, Member}; +/// +/// let mut osc = McClellanOscillator::new(); +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// // First tick seeds both EMAs to the same value -> oscillator 0. +/// assert_eq!(osc.update(tick), Some(0.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct McClellanOscillator { + ema_fast: f64, + ema_slow: f64, + seeded: bool, + has_emitted: bool, +} + +impl McClellanOscillator { + /// Construct a new McClellan Oscillator with the classic 19/39 smoothing. + #[must_use] + pub const fn new() -> Self { + Self { + ema_fast: 0.0, + ema_slow: 0.0, + seeded: false, + has_emitted: false, + } + } + + /// Feed a cross-section tick and return the oscillator value, which is defined + /// on every tick. Shared with [`McClellanSummationIndex`] so the summation + /// index can accumulate the oscillator without an `Option` round-trip. + /// + /// [`McClellanSummationIndex`]: crate::McClellanSummationIndex + pub(crate) fn step(&mut self, section: &CrossSection) -> f64 { + let advancers = section.advancers(); + let decliners = section.decliners(); + let net = advancers as f64 - decliners as f64; + let participating = (advancers + decliners).max(1) as f64; + let rana = net / participating * RANA_SCALE; + if self.seeded { + self.ema_fast += ALPHA_FAST * (rana - self.ema_fast); + self.ema_slow += ALPHA_SLOW * (rana - self.ema_slow); + } else { + self.ema_fast = rana; + self.ema_slow = rana; + self.seeded = true; + } + self.has_emitted = true; + self.ema_fast - self.ema_slow + } +} + +impl Indicator for McClellanOscillator { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + Some(self.step(§ion)) + } + + fn reset(&mut self) { + self.ema_fast = 0.0; + self.ema_slow = 0.0; + self.seeded = false; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "McClellanOscillator" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let osc = McClellanOscillator::new(); + assert_eq!(osc.name(), "McClellanOscillator"); + assert_eq!(osc.warmup_period(), 1); + assert!(!osc.is_ready()); + } + + #[test] + fn seeds_to_zero_on_first_tick() { + let mut osc = McClellanOscillator::new(); + // RANA = (3 - 1) / 4 * 1000 = 500 ; both EMAs seed to 500 -> spread 0. + assert_eq!(osc.update(section(3, 1)), Some(0.0)); + assert!(osc.is_ready()); + } + + #[test] + fn tracks_breadth_momentum_after_seeding() { + let mut osc = McClellanOscillator::new(); + osc.update(section(3, 1)); // seed at RANA 500 + // RANA = (1 - 3) / 4 * 1000 = -500. + // fast = 500 + 0.1 * (-1000) = 400 ; slow = 500 + 0.05 * (-1000) = 450. + let value = osc.update(section(1, 3)).unwrap(); + assert!((value - (-50.0)).abs() < 1e-9); + // RANA = 0. fast = 400 + 0.1 * (-400) = 360 ; slow = 450 + 0.05 * (-450) = 427.5. + let value = osc.update(section(2, 2)).unwrap(); + assert!((value - (-67.5)).abs() < 1e-9); + } + + #[test] + fn empty_participation_yields_zero_rana() { + let mut osc = McClellanOscillator::new(); + // No advancers or decliners -> RANA 0 ; seeds both EMAs to 0 -> spread 0. + assert_eq!(osc.update(section(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut osc = McClellanOscillator::new(); + osc.update(section(3, 1)); + osc.update(section(1, 3)); + assert!(osc.is_ready()); + osc.reset(); + assert!(!osc.is_ready()); + // After reset the next tick re-seeds to spread 0. + assert_eq!(osc.update(section(1, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(3, 1), section(1, 3), section(2, 2), section(0, 0)]; + let mut a = McClellanOscillator::new(); + let mut b = McClellanOscillator::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mcclellan_summation_index.rs b/crates/wickra-core/src/indicators/mcclellan_summation_index.rs new file mode 100644 index 00000000..bbf093a7 --- /dev/null +++ b/crates/wickra-core/src/indicators/mcclellan_summation_index.rs @@ -0,0 +1,160 @@ +//! McClellan Summation Index — the running total of the McClellan Oscillator. + +use crate::cross_section::CrossSection; +use crate::indicators::mcclellan_oscillator::McClellanOscillator; +use crate::traits::Indicator; + +/// McClellan Summation Index — the running cumulative sum of the +/// [`McClellanOscillator`]. +/// +/// Where the oscillator measures the *momentum* of breadth, the summation index +/// integrates it into a longer-term breadth trend: it rises while the oscillator +/// is positive and falls while it is negative, so it behaves like a slow, +/// smoothed advance/decline line. Sustained readings far above or below zero mark +/// strong bull or bear breadth regimes, and crosses of the zero line are read as +/// major trend changes. +/// +/// The index embeds a [`McClellanOscillator`] and adds its value on every tick. +/// Because the oscillator seeds to `0.0` on the first tick, the summation index +/// also starts at `0.0` and is defined from the first update +/// (`warmup_period == 1`). +/// +/// `Input = CrossSection`, `Output = f64`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, McClellanSummationIndex, Member}; +/// +/// let mut msi = McClellanSummationIndex::new(); +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// // First tick: oscillator seeds to 0, so the summation index is 0. +/// assert_eq!(msi.update(tick), Some(0.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct McClellanSummationIndex { + oscillator: McClellanOscillator, + sum: f64, + has_emitted: bool, +} + +impl McClellanSummationIndex { + /// Construct a new McClellan Summation Index. + #[must_use] + pub fn new() -> Self { + Self { + oscillator: McClellanOscillator::new(), + sum: 0.0, + has_emitted: false, + } + } +} + +impl Indicator for McClellanSummationIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let oscillator = self.oscillator.step(§ion); + self.sum += oscillator; + self.has_emitted = true; + Some(self.sum) + } + + fn reset(&mut self) { + self.oscillator.reset(); + self.sum = 0.0; + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "McClellanSummationIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let msi = McClellanSummationIndex::new(); + assert_eq!(msi.name(), "McClellanSummationIndex"); + assert_eq!(msi.warmup_period(), 1); + assert!(!msi.is_ready()); + } + + #[test] + fn first_tick_starts_at_zero() { + let mut msi = McClellanSummationIndex::new(); + assert_eq!(msi.update(section(3, 1)), Some(0.0)); + assert!(msi.is_ready()); + } + + #[test] + fn accumulates_the_oscillator() { + let mut msi = McClellanSummationIndex::new(); + assert_eq!(msi.update(section(3, 1)), Some(0.0)); // osc 0 -> sum 0 + // osc -50 -> sum -50. + let value = msi.update(section(1, 3)).unwrap(); + assert!((value - (-50.0)).abs() < 1e-9); + // osc -67.5 -> sum -117.5. + let value = msi.update(section(2, 2)).unwrap(); + assert!((value - (-117.5)).abs() < 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut msi = McClellanSummationIndex::new(); + msi.update(section(3, 1)); + msi.update(section(1, 3)); + assert!(msi.is_ready()); + msi.reset(); + assert!(!msi.is_ready()); + // Oscillator re-seeds, so the summation index restarts at 0. + assert_eq!(msi.update(section(1, 3)), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(3, 1), section(1, 3), section(2, 2)]; + let mut a = McClellanSummationIndex::new(); + let mut b = McClellanSummationIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 6baa157c..1e1195dd 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -5,13 +5,16 @@ //! from the crate root for convenience. mod abandoned_baby; +mod absolute_breadth_index; mod acceleration_bands; mod accelerator_oscillator; mod ad_oscillator; +mod ad_volume_line; mod adaptive_cycle; mod adl; mod advance_block; mod advance_decline; +mod advance_decline_ratio; mod adx; mod adxr; mod alligator; @@ -36,7 +39,9 @@ mod beta; mod beta_neutral_spread; mod bollinger; mod bollinger_bandwidth; +mod breadth_thrust; mod breakaway; +mod bullish_percent_index; mod calendar_spread; mod calmar_ratio; mod camarilla_pivots; @@ -59,6 +64,7 @@ mod conditional_value_at_risk; mod connors_rsi; mod coppock; mod counterattack; +mod cumulative_volume_index; mod cvd; mod cybernetic_cycle; mod decycler; @@ -109,6 +115,7 @@ mod hammer; mod hanging_man; mod harami; mod heikin_ashi; +mod high_low_index; mod high_wave; mod hikkake; mod hikkake_modified; @@ -166,6 +173,8 @@ mod mass_index; mod mat_hold; mod matching_low; mod max_drawdown; +mod mcclellan_oscillator; +mod mcclellan_summation_index; mod mcginley_dynamic; mod median_absolute_deviation; mod median_price; @@ -179,6 +188,7 @@ mod mom; mod morning_doji_star; mod morning_evening_star; mod natr; +mod new_highs_new_lows; mod nvi; mod ob_imbalance_full; mod ob_imbalance_top1; @@ -197,6 +207,7 @@ mod pair_spread_zscore; mod pairwise_beta; mod parkinson; mod pearson_correlation; +mod percent_above_ma; mod percent_b; mod percentage_trailing_stop; mod pgo; @@ -282,11 +293,13 @@ mod three_outside; mod three_soldiers_or_crows; mod three_stars_in_south; mod thrusting; +mod tick_index; mod tii; mod tpo_profile; mod trade_imbalance; mod treynor_ratio; mod trima; +mod trin; mod trix; mod true_range; mod tsf; @@ -299,6 +312,7 @@ mod typical_price; mod ulcer_index; mod ultimate_oscillator; mod unique_three_river; +mod up_down_volume_ratio; mod upside_gap_three_methods; mod upside_gap_two_crows; mod value_area; @@ -330,13 +344,16 @@ mod zig_zag; mod zlema; pub use abandoned_baby::AbandonedBaby; +pub use absolute_breadth_index::AbsoluteBreadthIndex; pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput}; pub use accelerator_oscillator::AcceleratorOscillator; pub use ad_oscillator::AdOscillator; +pub use ad_volume_line::AdVolumeLine; pub use adaptive_cycle::AdaptiveCycle; pub use adl::Adl; pub use advance_block::AdvanceBlock; pub use advance_decline::AdvanceDecline; +pub use advance_decline_ratio::AdvanceDeclineRatio; pub use adx::{Adx, AdxOutput}; pub use adxr::Adxr; pub use alligator::{Alligator, AlligatorOutput}; @@ -361,7 +378,9 @@ pub use beta::Beta; pub use beta_neutral_spread::BetaNeutralSpread; pub use bollinger::{BollingerBands, BollingerOutput}; pub use bollinger_bandwidth::BollingerBandwidth; +pub use breadth_thrust::BreadthThrust; pub use breakaway::Breakaway; +pub use bullish_percent_index::BullishPercentIndex; pub use calendar_spread::CalendarSpread; pub use calmar_ratio::CalmarRatio; pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput}; @@ -384,6 +403,7 @@ pub use conditional_value_at_risk::ConditionalValueAtRisk; pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; pub use counterattack::Counterattack; +pub use cumulative_volume_index::CumulativeVolumeIndex; pub use cvd::CumulativeVolumeDelta; pub use cybernetic_cycle::CyberneticCycle; pub use decycler::Decycler; @@ -434,6 +454,7 @@ pub use hammer::Hammer; pub use hanging_man::HangingMan; pub use harami::Harami; pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput}; +pub use high_low_index::HighLowIndex; pub use high_wave::HighWave; pub use hikkake::Hikkake; pub use hikkake_modified::HikkakeModified; @@ -491,6 +512,8 @@ pub use mass_index::MassIndex; pub use mat_hold::MatHold; pub use matching_low::MatchingLow; pub use max_drawdown::MaxDrawdown; +pub use mcclellan_oscillator::McClellanOscillator; +pub use mcclellan_summation_index::McClellanSummationIndex; pub use mcginley_dynamic::McGinleyDynamic; pub use median_absolute_deviation::MedianAbsoluteDeviation; pub use median_price::MedianPrice; @@ -504,6 +527,7 @@ pub use mom::Mom; pub use morning_doji_star::MorningDojiStar; pub use morning_evening_star::MorningEveningStar; pub use natr::Natr; +pub use new_highs_new_lows::NewHighsNewLows; pub use nvi::Nvi; pub use ob_imbalance_full::OrderBookImbalanceFull; pub use ob_imbalance_top1::OrderBookImbalanceTop1; @@ -522,6 +546,7 @@ pub use pair_spread_zscore::PairSpreadZScore; pub use pairwise_beta::PairwiseBeta; pub use parkinson::ParkinsonVolatility; pub use pearson_correlation::PearsonCorrelation; +pub use percent_above_ma::PercentAboveMa; pub use percent_b::PercentB; pub use percentage_trailing_stop::PercentageTrailingStop; pub use pgo::Pgo; @@ -607,11 +632,13 @@ pub use three_outside::ThreeOutside; pub use three_soldiers_or_crows::ThreeSoldiersOrCrows; pub use three_stars_in_south::ThreeStarsInSouth; pub use thrusting::Thrusting; +pub use tick_index::TickIndex; pub use tii::Tii; pub use tpo_profile::{TpoProfile, TpoProfileOutput}; pub use trade_imbalance::TradeImbalance; pub use treynor_ratio::TreynorRatio; pub use trima::Trima; +pub use trin::Trin; pub use trix::Trix; pub use true_range::TrueRange; pub use tsf::Tsf; @@ -624,6 +651,7 @@ pub use typical_price::TypicalPrice; pub use ulcer_index::UlcerIndex; pub use ultimate_oscillator::UltimateOscillator; pub use unique_three_river::UniqueThreeRiver; +pub use up_down_volume_ratio::UpDownVolumeRatio; pub use upside_gap_three_methods::UpsideGapThreeMethods; pub use upside_gap_two_crows::UpsideGapTwoCrows; pub use value_area::{ValueArea, ValueAreaOutput}; @@ -1070,7 +1098,26 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "Alt-Chart Bars", &["RenkoBars", "KagiBars", "PointAndFigureBars"], ), - ("Market Breadth", &["AdvanceDecline"]), + ( + "Market Breadth", + &[ + "AdvanceDecline", + "AdvanceDeclineRatio", + "AdVolumeLine", + "McClellanOscillator", + "McClellanSummationIndex", + "Trin", + "BreadthThrust", + "NewHighsNewLows", + "HighLowIndex", + "PercentAboveMa", + "UpDownVolumeRatio", + "BullishPercentIndex", + "CumulativeVolumeIndex", + "AbsoluteBreadthIndex", + "TickIndex", + ], + ), ]; #[cfg(test)] @@ -1099,6 +1146,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, 325, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 339, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/new_highs_new_lows.rs b/crates/wickra-core/src/indicators/new_highs_new_lows.rs new file mode 100644 index 00000000..b33b8fd8 --- /dev/null +++ b/crates/wickra-core/src/indicators/new_highs_new_lows.rs @@ -0,0 +1,142 @@ +//! New Highs − New Lows — net count of fresh period extremes across a universe. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// New Highs − New Lows — the number of symbols printing a new period high minus +/// the number printing a new period low across a universe. +/// +/// On each [`CrossSection`] tick the value is `new_highs - new_lows`, read from the +/// per-symbol `new_high` / `new_low` flags. A persistently positive reading means +/// fresh leadership is broad (many names making new highs); a negative reading +/// during an index advance is a classic breadth divergence warning that the rally +/// is narrowing. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, NewHighsNewLows}; +/// +/// let mut nhnl = NewHighsNewLows::new(); +/// // 2 new highs, 1 new low -> net +1. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, true, false), +/// Member::new(1.0, 10.0, true, false), +/// Member::new(-1.0, 10.0, false, true), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(nhnl.update(tick), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct NewHighsNewLows { + has_emitted: bool, +} + +impl NewHighsNewLows { + /// Construct a new New Highs − New Lows indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for NewHighsNewLows { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.new_highs() as f64 - section.new_lows() as f64; + self.has_emitted = true; + Some(net) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "NewHighsNewLows" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn flags(highs: usize, lows: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..highs { + members.push(Member::new(1.0, 10.0, true, false)); + } + for _ in 0..lows { + members.push(Member::new(-1.0, 10.0, false, true)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.name(), "NewHighsNewLows"); + assert_eq!(nhnl.warmup_period(), 1); + assert!(!nhnl.is_ready()); + } + + #[test] + fn first_tick_emits_net_extremes() { + let mut nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.update(flags(5, 2)), Some(3.0)); + assert!(nhnl.is_ready()); + } + + #[test] + fn more_lows_than_highs_is_negative() { + let mut nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.update(flags(1, 4)), Some(-3.0)); + } + + #[test] + fn no_extremes_yields_zero() { + let mut nhnl = NewHighsNewLows::new(); + assert_eq!(nhnl.update(flags(0, 0)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut nhnl = NewHighsNewLows::new(); + nhnl.update(flags(3, 1)); + assert!(nhnl.is_ready()); + nhnl.reset(); + assert!(!nhnl.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![flags(5, 2), flags(1, 4), flags(0, 0)]; + let mut a = NewHighsNewLows::new(); + let mut b = NewHighsNewLows::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/percent_above_ma.rs b/crates/wickra-core/src/indicators/percent_above_ma.rs new file mode 100644 index 00000000..12480d13 --- /dev/null +++ b/crates/wickra-core/src/indicators/percent_above_ma.rs @@ -0,0 +1,146 @@ +//! Percent Above Moving Average — share of a universe trading above its MA. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Percent Above Moving Average — the percentage of symbols in a universe that +/// are trading above their reference moving average. +/// +/// On each [`CrossSection`] tick the value is `100 * above_ma_count / universe +/// size`, read from the per-symbol `above_ma` flag (the caller decides which MA — +/// 50-day, 200-day — when it builds the tick). It is a bounded `0..=100` breadth +/// gauge: readings near 100 mean almost the whole universe is in an uptrend +/// (broad participation, but also a potential overbought extreme), readings near +/// zero mark washouts. Crosses of the 50 line are read as bull/bear regime flips. +/// +/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`), +/// `warmup_period == 1`. The universe is non-empty by construction, so the share +/// is always defined. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, PercentAboveMa}; +/// +/// let mut pct = PercentAboveMa::new(); +/// // 3 of 4 symbols above their MA -> 75%. +/// let tick = CrossSection::new( +/// vec![ +/// Member::with_signals(1.0, 10.0, false, false, true, false), +/// Member::with_signals(1.0, 10.0, false, false, true, false), +/// Member::with_signals(-1.0, 10.0, false, false, true, false), +/// Member::with_signals(-1.0, 10.0, false, false, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(pct.update(tick), Some(75.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct PercentAboveMa { + has_emitted: bool, +} + +impl PercentAboveMa { + /// Construct a new Percent Above Moving Average indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for PercentAboveMa { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let above = section.above_ma_count() as f64; + let total = section.members.len() as f64; + self.has_emitted = true; + Some(100.0 * above / total) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "PercentAboveMa" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(above: usize, below: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..above { + members.push(Member::with_signals(1.0, 10.0, false, false, true, false)); + } + for _ in 0..below { + members.push(Member::with_signals(-1.0, 10.0, false, false, false, false)); + } + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let pct = PercentAboveMa::new(); + assert_eq!(pct.name(), "PercentAboveMa"); + assert_eq!(pct.warmup_period(), 1); + assert!(!pct.is_ready()); + } + + #[test] + fn first_tick_emits_percentage() { + let mut pct = PercentAboveMa::new(); + assert_eq!(pct.update(tick(3, 1)), Some(75.0)); + assert!(pct.is_ready()); + } + + #[test] + fn all_above_is_one_hundred() { + let mut pct = PercentAboveMa::new(); + assert_eq!(pct.update(tick(4, 0)), Some(100.0)); + } + + #[test] + fn none_above_is_zero() { + let mut pct = PercentAboveMa::new(); + assert_eq!(pct.update(tick(0, 5)), Some(0.0)); + } + + #[test] + fn reset_clears_state() { + let mut pct = PercentAboveMa::new(); + pct.update(tick(3, 1)); + assert!(pct.is_ready()); + pct.reset(); + assert!(!pct.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![tick(3, 1), tick(4, 0), tick(0, 5)]; + let mut a = PercentAboveMa::new(); + let mut b = PercentAboveMa::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/tick_index.rs b/crates/wickra-core/src/indicators/tick_index.rs new file mode 100644 index 00000000..469adc54 --- /dev/null +++ b/crates/wickra-core/src/indicators/tick_index.rs @@ -0,0 +1,148 @@ +//! TICK Index — instantaneous net advancing-minus-declining issues. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// TICK Index — the instantaneous net of advancing minus declining issues across +/// a universe, `advancers - decliners`. +/// +/// Unlike the cumulative [`AdvanceDecline`](crate::AdvanceDecline) line, the TICK +/// is *not* accumulated: each tick reports the breadth of that snapshot alone. It +/// oscillates around zero — strongly positive readings mean a broad surge of +/// upticks (often an intraday overbought extreme), strongly negative readings a +/// broad flush. Traders fade extremes and watch the zero line for intraday bias. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, TickIndex}; +/// +/// let mut tick = TickIndex::new(); +/// // 2 advancers, 5 decliners -> net -3. +/// let snapshot = CrossSection::new( +/// vec![ +/// Member::new(1.0, 10.0, false, false), +/// Member::new(1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// Member::new(-1.0, 10.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(tick.update(snapshot), Some(-3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct TickIndex { + has_emitted: bool, +} + +impl TickIndex { + /// Construct a new TICK Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for TickIndex { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let net = section.advancers() as f64 - section.decliners() as f64; + self.has_emitted = true; + Some(net) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TickIndex" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn section(up: usize, down: usize) -> CrossSection { + let mut members = Vec::new(); + for _ in 0..up { + members.push(Member::new(1.0, 10.0, false, false)); + } + for _ in 0..down { + members.push(Member::new(-1.0, 10.0, false, false)); + } + members.push(Member::new(0.0, 10.0, false, false)); + CrossSection::new(members, 0).unwrap() + } + + #[test] + fn accessors_and_metadata() { + let tick = TickIndex::new(); + assert_eq!(tick.name(), "TickIndex"); + assert_eq!(tick.warmup_period(), 1); + assert!(!tick.is_ready()); + } + + #[test] + fn positive_when_advancers_lead() { + let mut tick = TickIndex::new(); + assert_eq!(tick.update(section(5, 2)), Some(3.0)); + assert!(tick.is_ready()); + } + + #[test] + fn negative_when_decliners_lead() { + let mut tick = TickIndex::new(); + assert_eq!(tick.update(section(2, 5)), Some(-3.0)); + } + + #[test] + fn does_not_accumulate() { + let mut tick = TickIndex::new(); + // Each tick is independent — the second reading does not carry the first. + assert_eq!(tick.update(section(3, 0)), Some(3.0)); + assert_eq!(tick.update(section(0, 1)), Some(-1.0)); + } + + #[test] + fn reset_clears_state() { + let mut tick = TickIndex::new(); + tick.update(section(3, 0)); + assert!(tick.is_ready()); + tick.reset(); + assert!(!tick.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![section(5, 2), section(2, 5), section(3, 0), section(0, 1)]; + let mut a = TickIndex::new(); + let mut b = TickIndex::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/trin.rs b/crates/wickra-core/src/indicators/trin.rs new file mode 100644 index 00000000..1d434884 --- /dev/null +++ b/crates/wickra-core/src/indicators/trin.rs @@ -0,0 +1,171 @@ +//! TRIN / Arms Index — the advance-decline ratio over the up-down volume ratio. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// TRIN (Arms Index) — `(advancers / decliners) / (advancing volume / declining +/// volume)`. +/// +/// The TRIN compares the breadth of a move in *issues* to the breadth of the move +/// in *volume*. A value near `1.0` means advancing issues and advancing volume are +/// in balance; a value below `1.0` is bullish (volume is concentrated in advancing +/// issues relative to their count); a value above `1.0` is bearish (declining +/// issues are absorbing disproportionate volume). +/// +/// To stay finite on degenerate ticks the decliner count is floored to one and +/// both volume sums are floored to `1.0`, so a tick with no declining issues or no +/// volume on one side still yields a defined reading instead of a division by +/// zero. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, Trin}; +/// +/// let mut trin = Trin::new(); +/// // 3 advancers / 1 decliner = 3; adv vol 150 / dec vol 50 = 3; TRIN = 1.0. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 50.0, false, false), +/// Member::new(1.0, 50.0, false, false), +/// Member::new(1.0, 50.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(trin.update(tick), Some(1.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct Trin { + has_emitted: bool, +} + +impl Trin { + /// Construct a new TRIN / Arms Index indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for Trin { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancers = section.advancers() as f64; + let decliners = section.decliners().max(1) as f64; + let advancing_volume = section.advancing_volume().max(1.0); + let declining_volume = section.declining_volume().max(1.0); + let ad_ratio = advancers / decliners; + let volume_ratio = advancing_volume / declining_volume; + self.has_emitted = true; + Some(ad_ratio / volume_ratio) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Trin" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let trin = Trin::new(); + assert_eq!(trin.name(), "Trin"); + assert_eq!(trin.warmup_period(), 1); + assert!(!trin.is_ready()); + } + + #[test] + fn balanced_breadth_yields_one() { + let mut trin = Trin::new(); + let value = trin + .update(tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)])) + .unwrap(); + assert!((value - 1.0).abs() < 1e-9); + assert!(trin.is_ready()); + } + + #[test] + fn zero_decliners_and_volume_are_floored() { + let mut trin = Trin::new(); + // 2 advancers, 0 decliners, adv vol 100, dec vol 0. + // ad_ratio = 2 / max(0,1) = 2; volume_ratio = 100 / max(0,1) = 100; TRIN = 0.02. + let value = trin.update(tick(&[(1.0, 50.0), (1.0, 50.0)])).unwrap(); + assert!((value - 0.02).abs() < 1e-9); + } + + #[test] + fn heavy_declining_volume_pushes_above_one() { + let mut trin = Trin::new(); + // 2 adv / 2 dec = 1; adv vol 20 / dec vol 80 = 0.25; TRIN = 4.0. + let value = trin + .update(tick(&[ + (1.0, 10.0), + (1.0, 10.0), + (-1.0, 40.0), + (-1.0, 40.0), + ])) + .unwrap(); + assert!((value - 4.0).abs() < 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut trin = Trin::new(); + trin.update(tick(&[(1.0, 10.0), (-1.0, 10.0)])); + assert!(trin.is_ready()); + trin.reset(); + assert!(!trin.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 50.0), (1.0, 50.0), (1.0, 50.0), (-1.0, 50.0)]), + tick(&[(1.0, 50.0), (1.0, 50.0)]), + tick(&[(1.0, 10.0), (1.0, 10.0), (-1.0, 40.0), (-1.0, 40.0)]), + ]; + let mut a = Trin::new(); + let mut b = Trin::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/up_down_volume_ratio.rs b/crates/wickra-core/src/indicators/up_down_volume_ratio.rs new file mode 100644 index 00000000..d8e8f06e --- /dev/null +++ b/crates/wickra-core/src/indicators/up_down_volume_ratio.rs @@ -0,0 +1,143 @@ +//! Up/Down Volume Ratio — advancing volume divided by declining volume. + +use crate::cross_section::CrossSection; +use crate::traits::Indicator; + +/// Up/Down Volume Ratio — total advancing volume divided by total declining +/// volume across a universe. +/// +/// On each [`CrossSection`] tick the ratio is `advancing volume / declining +/// volume`. A reading above one means more volume is trading in advancing issues +/// than declining ones (accumulation); a reading below one means distribution. +/// Sustained extremes are used to flag breadth thrusts and washout bottoms. +/// +/// When a tick has no declining volume the denominator is floored to `1.0`, so the +/// ratio stays finite (it degrades to the advancing-volume total) instead of +/// dividing by zero. +/// +/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{CrossSection, Indicator, Member, UpDownVolumeRatio}; +/// +/// let mut udv = UpDownVolumeRatio::new(); +/// // advancing volume 150, declining volume 50 -> ratio 3.0. +/// let tick = CrossSection::new( +/// vec![ +/// Member::new(1.0, 150.0, false, false), +/// Member::new(-1.0, 50.0, false, false), +/// ], +/// 0, +/// ) +/// .unwrap(); +/// assert_eq!(udv.update(tick), Some(3.0)); +/// ``` +#[derive(Debug, Clone, Default)] +pub struct UpDownVolumeRatio { + has_emitted: bool, +} + +impl UpDownVolumeRatio { + /// Construct a new Up/Down Volume Ratio indicator. + #[must_use] + pub const fn new() -> Self { + Self { has_emitted: false } + } +} + +impl Indicator for UpDownVolumeRatio { + type Input = CrossSection; + type Output = f64; + + fn update(&mut self, section: CrossSection) -> Option { + let advancing_volume = section.advancing_volume(); + let declining_volume = section.declining_volume().max(1.0); + self.has_emitted = true; + Some(advancing_volume / declining_volume) + } + + fn reset(&mut self) { + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "UpDownVolumeRatio" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cross_section::Member; + use crate::traits::BatchExt; + + fn tick(items: &[(f64, f64)]) -> CrossSection { + CrossSection::new( + items + .iter() + .map(|&(change, volume)| Member::new(change, volume, false, false)) + .collect(), + 0, + ) + .unwrap() + } + + #[test] + fn accessors_and_metadata() { + let udv = UpDownVolumeRatio::new(); + assert_eq!(udv.name(), "UpDownVolumeRatio"); + assert_eq!(udv.warmup_period(), 1); + assert!(!udv.is_ready()); + } + + #[test] + fn first_tick_emits_ratio() { + let mut udv = UpDownVolumeRatio::new(); + assert_eq!(udv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(3.0)); + assert!(udv.is_ready()); + } + + #[test] + fn zero_declining_volume_floors_denominator() { + let mut udv = UpDownVolumeRatio::new(); + // advancing volume 100, declining volume 0 -> 100 / max(0, 1) = 100.0. + assert_eq!(udv.update(tick(&[(1.0, 100.0)])), Some(100.0)); + } + + #[test] + fn reset_clears_state() { + let mut udv = UpDownVolumeRatio::new(); + udv.update(tick(&[(1.0, 10.0), (-1.0, 10.0)])); + assert!(udv.is_ready()); + udv.reset(); + assert!(!udv.is_ready()); + } + + #[test] + fn batch_equals_streaming() { + let sections = vec![ + tick(&[(1.0, 150.0), (-1.0, 50.0)]), + tick(&[(1.0, 100.0)]), + tick(&[(1.0, 20.0), (-1.0, 80.0)]), + ]; + let mut a = UpDownVolumeRatio::new(); + let mut b = UpDownVolumeRatio::new(); + assert_eq!( + a.batch(§ions), + sections + .iter() + .map(|s| b.update(s.clone())) + .collect::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 407255b7..4563e799 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -55,20 +55,21 @@ pub use cross_section::{CrossSection, Member}; pub use derivatives::DerivativesTick; pub use error::{Error, Result}; pub use indicators::{ - AbandonedBaby, AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator, - AdaptiveCycle, Adl, AdvanceBlock, AdvanceDecline, Adx, AdxOutput, Adxr, Alligator, - AlligatorOutput, Alma, Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, - AroonOutput, Atr, AtrBands, AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, - AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, - BetaNeutralSpread, BollingerBands, BollingerBandwidth, BollingerOutput, Breakaway, + AbandonedBaby, AbsoluteBreadthIndex, AccelerationBands, AccelerationBandsOutput, + AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock, + AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, + Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, + AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AvgPrice, AwesomeOscillator, + AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BetaNeutralSpread, BollingerBands, + BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, - CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DemandIndex, - DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar, - Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, + CumulativeVolumeDelta, CumulativeVolumeIndex, CyberneticCycle, Decycler, DecyclerOscillator, + Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, + Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama, @@ -76,47 +77,48 @@ pub use indicators::{ ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, - HiLoActivator, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, - Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, - HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, - Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline, - InverseFisherTransform, InvertedHammer, Jma, KagiBars, KalmanHedgeRatio, - KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, - Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, - LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, - LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, - LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, - MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, - MatHold, MatchingLow, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, - Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, - Natr, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, - OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, - OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex, PairSpreadZScore, - PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo, - PiercingDarkCloud, PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, - QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB, - RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, - Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollingCorrelation, RollingCovariance, - RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeparatingLines, - SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma, - SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadBollingerBands, - SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands, - StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, - StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, - SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, - TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, + HiLoActivator, HighLowIndex, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, + HistoricalVolatility, Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, + HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, + InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, + InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, KagiBars, + KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, + Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, + LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, + LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, + LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio, + MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, + MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, + McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, + MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, + MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, + OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput, + OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex, + PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, + PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, + PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, + RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, + RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, + RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, + Rwi, RwiOutput, SarExt, SeparatingLines, SharpeRatio, ShootingStar, ShortLine, SignedVolume, + SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, + SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, + StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, + StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, + SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, + TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike, - ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, Tii, TpoProfile, - TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsf, Tsi, Tsv, + ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TpoProfile, + TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, - UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, - ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, VoltyStop, - VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, - Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, - WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, - WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, - ZigZag, ZigZagOutput, Zlema, FAMILIES, T3, + UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, + ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, + VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, + VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, + WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, 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 a550c6a1..f4125871 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 **325 indicators** across +- A per-indicator deep dive for every one of the **339 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_crosssection.rs b/fuzz/fuzz_targets/indicator_update_crosssection.rs index 4dd5805a..15879794 100644 --- a/fuzz/fuzz_targets/indicator_update_crosssection.rs +++ b/fuzz/fuzz_targets/indicator_update_crosssection.rs @@ -12,7 +12,7 @@ //! streaming or batched. use libfuzzer_sys::fuzz_target; -use wickra_core::{AdvanceDecline, BatchExt, CrossSection, Indicator, Member}; +use wickra_core::{AbsoluteBreadthIndex, AdVolumeLine, AdvanceDecline, AdvanceDeclineRatio, BatchExt, BreadthThrust, BullishPercentIndex, CrossSection, CumulativeVolumeIndex, HighLowIndex, Indicator, McClellanOscillator, McClellanSummationIndex, Member, NewHighsNewLows, PercentAboveMa, TickIndex, Trin, UpDownVolumeRatio}; #[inline(never)] fn drive(make: impl Fn() -> I, sections: &[CrossSection]) @@ -44,4 +44,18 @@ fuzz_target!(|data: &[u8]| { .collect(); drive(AdvanceDecline::new, §ions); + drive(AdvanceDeclineRatio::new, §ions); + drive(AdVolumeLine::new, §ions); + drive(McClellanOscillator::new, §ions); + drive(McClellanSummationIndex::new, §ions); + drive(Trin::new, §ions); + drive(|| BreadthThrust::new(10).unwrap(), §ions); + drive(NewHighsNewLows::new, §ions); + drive(|| HighLowIndex::new(10).unwrap(), §ions); + drive(PercentAboveMa::new, §ions); + drive(UpDownVolumeRatio::new, §ions); + drive(BullishPercentIndex::new, §ions); + drive(CumulativeVolumeIndex::new, §ions); + drive(AbsoluteBreadthIndex::new, §ions); + drive(TickIndex::new, §ions); });