diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a58165d..206fc8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Anchored RSI** — a cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (`set_anchor`), the momentum counterpart to Anchored VWAP. Every up- and down-move since the anchor is weighted equally, so it reports the RSI of the entire move since the anchor point. Scalar input, Momentum Oscillators family; available in Rust, Python, Node and WASM. +- **Volume Profile** — the full per-bin volume distribution over a rolling window, exposing the raw histogram (price bounds plus per-bin volume) that Value Area reduces to POC/VAH/VAL. Market Profile family; candle input, available in Rust, Python, Node and WASM. +- **TPO Profile** — the Time-Price-Opportunity (market-profile letter) distribution: a volume-agnostic count of how many periods traded at each price level over a rolling window. Market Profile family; candle input, available in Rust, Python, Node and WASM. ## [0.4.4] - 2026-06-02 diff --git a/README.md b/README.md index 34e60566..3ed247ce 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 290 indicators; start at the + every one of the 292 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 -290 streaming-first indicators across eighteen families. Every one passes the +292 streaming-first indicators across eighteen 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). @@ -158,7 +158,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow | | 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), Initial Balance, Opening Range | +| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range | | 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, @@ -238,7 +238,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 290 indicators +│ ├── wickra-core/ core engine + all 292 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 7d4218fc..d1a36272 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -1246,3 +1246,29 @@ test('basis rejects bad input', () => { assert.throws(() => new wickra.TermStructureBasis().update(100, 0)); assert.throws(() => new wickra.CalendarSpread().update(100, 0)); }); + +test('VolumeProfile exposes the full histogram', () => { + // bar0 single-print at 10 vol 100; bar1 spans 10..14 vol 80 over 4 bins. + const vp = new wickra.VolumeProfile(2, 4); + assert.equal(vp.update(10, 10, 100), null); + const out = vp.update(14, 10, 80); + assert.ok(out !== null); + assert.ok(Math.abs(out.priceLow - 10) < 1e-9); + assert.ok(Math.abs(out.priceHigh - 14) < 1e-9); + assert.deepEqual(out.bins.length, 4); + assert.ok(Math.abs(out.bins[0] - 120) < 1e-9); + for (let i = 1; i < 4; i++) { + assert.ok(Math.abs(out.bins[i] - 20) < 1e-9); + } +}); + +test('TpoProfile counts time at price, volume-agnostic', () => { + // bar0 spans 10..14 (+1 each bin); bar1 spans 11..12 (+1 bins 1,2). + const tpo = new wickra.TpoProfile(2, 4); + assert.equal(tpo.update(14, 10), null); + const out = tpo.update(12, 11); + assert.ok(out !== null); + assert.ok(Math.abs(out.priceLow - 10) < 1e-9); + assert.ok(Math.abs(out.priceHigh - 14) < 1e-9); + assert.deepEqual(out.counts, [1, 2, 2, 1]); +}); diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index fc6ac8e1..2a42540c 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -270,6 +270,16 @@ export interface ValueAreaValue { vah: number val: number } +export interface VolumeProfileValue { + priceLow: number + priceHigh: number + bins: Array +} +export interface TpoProfileValue { + priceLow: number + priceHigh: number + counts: Array +} export interface InitialBalanceValue { high: number low: number @@ -2064,6 +2074,24 @@ export declare class ValueArea { update(high: number, low: number, volume: number): ValueAreaValue | null batch(high: Array, low: Array, volume: Array): Array } +export type VolumeProfileNode = VolumeProfile +export declare class VolumeProfile { + constructor(period: number, binCount: number) + reset(): void + isReady(): boolean + warmupPeriod(): number + update(high: number, low: number, volume: number): VolumeProfileValue | null + batch(high: Array, low: Array, volume: Array): Array +} +export type TpoProfileNode = TpoProfile +export declare class TpoProfile { + constructor(period: number, binCount: number) + reset(): void + isReady(): boolean + warmupPeriod(): number + update(high: number, low: number): TpoProfileValue | null + batch(high: Array, low: Array): Array +} export type InitialBalanceNode = InitialBalance export declare class InitialBalance { constructor(period: number) diff --git a/bindings/node/index.js b/bindings/node/index.js index 74107db8..793866e3 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -310,7 +310,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, 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, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -499,6 +499,8 @@ module.exports.FAMA = FAMA module.exports.Ichimoku = Ichimoku module.exports.HeikinAshi = HeikinAshi module.exports.ValueArea = ValueArea +module.exports.VolumeProfile = VolumeProfile +module.exports.TpoProfile = TpoProfile module.exports.InitialBalance = InitialBalance module.exports.OpeningRange = OpeningRange module.exports.Doji = Doji diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index d2671530..d1f2d8a0 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -8476,6 +8476,153 @@ impl ValueAreaNode { } } +// ============================== VolumeProfile ============================== + +#[napi(object)] +pub struct VolumeProfileValue { + pub price_low: f64, + pub price_high: f64, + pub bins: Vec, +} + +#[napi(js_name = "VolumeProfile")] +pub struct VolumeProfileNode { + inner: wc::VolumeProfile, +} +#[napi] +impl VolumeProfileNode { + #[napi(constructor)] + pub fn new(period: u32, bin_count: u32) -> napi::Result { + Ok(Self { + inner: wc::VolumeProfile::new(period as usize, bin_count as usize).map_err(map_err)?, + }) + } + #[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] + pub fn update( + &mut self, + high: f64, + low: f64, + volume: f64, + ) -> napi::Result> { + let mid = f64::midpoint(high, low); + let candle = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?; + Ok(self.inner.update(candle).map(|o| VolumeProfileValue { + price_low: o.price_low, + price_high: o.price_high, + bins: o.bins, + })) + } + #[napi] + pub fn batch( + &mut self, + high: Vec, + low: Vec, + volume: Vec, + ) -> napi::Result> { + if high.len() != low.len() || low.len() != volume.len() { + return Err(NapiError::from_reason( + "high, low, volume must be equal length".to_string(), + )); + } + let k = self.inner.params().1 + 2; + let n = high.len(); + let mut out = vec![f64::NAN; n * k]; + for i in 0..n { + let mid = f64::midpoint(high[i], low[i]); + let candle = + wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * k] = o.price_low; + out[i * k + 1] = o.price_high; + for (j, b) in o.bins.iter().enumerate() { + out[i * k + 2 + j] = *b; + } + } + } + Ok(out) + } +} + +// ============================== TpoProfile ============================== + +#[napi(object)] +pub struct TpoProfileValue { + pub price_low: f64, + pub price_high: f64, + pub counts: Vec, +} + +#[napi(js_name = "TpoProfile")] +pub struct TpoProfileNode { + inner: wc::TpoProfile, +} +#[napi] +impl TpoProfileNode { + #[napi(constructor)] + pub fn new(period: u32, bin_count: u32) -> napi::Result { + Ok(Self { + inner: wc::TpoProfile::new(period as usize, bin_count as usize).map_err(map_err)?, + }) + } + #[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] + pub fn update(&mut self, high: f64, low: f64) -> napi::Result> { + let mid = f64::midpoint(high, low); + let candle = wc::Candle::new(mid, high, low, mid, 1.0, 0).map_err(map_err)?; + Ok(self.inner.update(candle).map(|o| TpoProfileValue { + price_low: o.price_low, + price_high: o.price_high, + counts: o.counts, + })) + } + #[napi] + pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> { + if high.len() != low.len() { + return Err(NapiError::from_reason( + "high, low must be equal length".to_string(), + )); + } + let k = self.inner.params().1 + 2; + let n = high.len(); + let mut out = vec![f64::NAN; n * k]; + for i in 0..n { + let mid = f64::midpoint(high[i], low[i]); + let candle = wc::Candle::new(mid, high[i], low[i], mid, 1.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * k] = o.price_low; + out[i * k + 1] = o.price_high; + for (j, count) in o.counts.iter().enumerate() { + out[i * k + 2 + j] = *count; + } + } + } + Ok(out) + } +} + // ============================== InitialBalance ============================== #[napi(object)] diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index b4c6ecd4..a3cdf2b0 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -223,6 +223,8 @@ from ._wickra import ( HeikinAshi, # Market Profile ValueArea, + VolumeProfile, + TpoProfile, InitialBalance, OpeningRange, # Candlestick patterns @@ -536,6 +538,8 @@ __all__ = [ "HeikinAshi", # Market Profile "ValueArea", + "VolumeProfile", + "TpoProfile", "InitialBalance", "OpeningRange", # Candlestick patterns diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index c5bf0dc4..acbdbf55 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -11326,6 +11326,183 @@ impl PyValueArea { } } +// ============================== VolumeProfile ============================== + +/// Streaming profile output: `(price_low, price_high, per_bin_values)`, or `None` +/// during warmup. Shared by `VolumeProfile` (volume bins) and `TpoProfile` (TPO +/// counts). +type ProfileHistogram<'py> = Option<(f64, f64, Bound<'py, PyArray1>)>; + +#[pyclass(name = "VolumeProfile", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyVolumeProfile { + inner: wc::VolumeProfile, +} + +#[pymethods] +impl PyVolumeProfile { + #[new] + #[pyo3(signature = (period=20, bin_count=50))] + fn new(period: usize, bin_count: usize) -> PyResult { + Ok(Self { + inner: wc::VolumeProfile::new(period, bin_count).map_err(map_err)?, + }) + } + /// Streaming update. Returns `(price_low, price_high, bins)` once warm, else `None`. + fn update<'py>( + &mut self, + py: Python<'py>, + candle: &Bound<'_, PyAny>, + ) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.price_low, o.price_high, o.bins.into_pyarray(py)))) + } + /// Batch over numpy columns high, low, volume. Returns shape `(n, bin_count + 2)` + /// with columns `[price_low, price_high, bin_0, ..., bin_{k-1}]`; warmup rows are `NaN`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + volume: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let v = volume + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() || l.len() != v.len() { + return Err(PyValueError::new_err( + "high, low, volume must be equal length", + )); + } + let k = self.inner.params().1 + 2; + let n = h.len(); + let mut out = vec![f64::NAN; n * k]; + for i in 0..n { + let mid = f64::midpoint(h[i], l[i]); + let candle = wc::Candle::new(mid, h[i], l[i], mid, v[i], 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * k] = o.price_low; + out[i * k + 1] = o.price_high; + for (j, b) in o.bins.iter().enumerate() { + out[i * k + 2 + j] = *b; + } + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn params(&self) -> (usize, usize) { + self.inner.params() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (period, bin_count) = self.inner.params(); + format!("VolumeProfile(period={period}, bin_count={bin_count})") + } +} + +// ============================== TpoProfile ============================== + +#[pyclass(name = "TpoProfile", module = "wickra._wickra", skip_from_py_object)] +#[derive(Clone)] +struct PyTpoProfile { + inner: wc::TpoProfile, +} + +#[pymethods] +impl PyTpoProfile { + #[new] + #[pyo3(signature = (period=30, bin_count=50))] + fn new(period: usize, bin_count: usize) -> PyResult { + Ok(Self { + inner: wc::TpoProfile::new(period, bin_count).map_err(map_err)?, + }) + } + /// Streaming update. Returns `(price_low, price_high, counts)` once warm, else `None`. + fn update<'py>( + &mut self, + py: Python<'py>, + candle: &Bound<'_, PyAny>, + ) -> PyResult> { + let c = extract_candle(candle)?; + Ok(self + .inner + .update(c) + .map(|o| (o.price_low, o.price_high, o.counts.into_pyarray(py)))) + } + /// Batch over numpy columns high, low. Returns shape `(n, bin_count + 2)` + /// with columns `[price_low, price_high, count_0, ..., count_{k-1}]`; warmup rows are `NaN`. + fn batch<'py>( + &mut self, + py: Python<'py>, + high: PyReadonlyArray1<'py, f64>, + low: PyReadonlyArray1<'py, f64>, + ) -> PyResult>> { + let h = high + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + let l = low + .as_slice() + .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?; + if h.len() != l.len() { + return Err(PyValueError::new_err("high, low must be equal length")); + } + let k = self.inner.params().1 + 2; + let n = h.len(); + let mut out = vec![f64::NAN; n * k]; + for i in 0..n { + let mid = f64::midpoint(h[i], l[i]); + let candle = wc::Candle::new(mid, h[i], l[i], mid, 1.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(candle) { + out[i * k] = o.price_low; + out[i * k + 1] = o.price_high; + for (j, count) in o.counts.iter().enumerate() { + out[i * k + 2 + j] = *count; + } + } + } + Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out) + .expect("shape consistent") + .into_pyarray(py)) + } + #[getter] + fn params(&self) -> (usize, usize) { + self.inner.params() + } + fn reset(&mut self) { + self.inner.reset(); + } + fn is_ready(&self) -> bool { + self.inner.is_ready() + } + fn warmup_period(&self) -> usize { + self.inner.warmup_period() + } + fn __repr__(&self) -> String { + let (period, bin_count) = self.inner.params(); + format!("TpoProfile(period={period}, bin_count={bin_count})") + } +} + // ============================== InitialBalance ============================== #[pyclass( @@ -14228,6 +14405,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; // Candlestick patterns. diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index c03ad29f..c9d071c5 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -1010,6 +1010,64 @@ def test_opening_range_shape_and_streaming(ohlcv): assert _eq_nan(batch, np.array(rows, dtype=np.float64)) +def test_volume_profile_reference(): + # bar0 single-print at 10 vol 100; bar1 spans 10..14 vol 80 over 4 bins. + vp = ta.VolumeProfile(2, 4) + assert vp.update((10.0, 10.0, 10.0, 10.0, 100.0, 0)) is None + out = vp.update((10.0, 14.0, 10.0, 12.0, 80.0, 1)) + assert out is not None + price_low, price_high, bins = out + assert price_low == pytest.approx(10.0) + assert price_high == pytest.approx(14.0) + np.testing.assert_allclose(bins, [120.0, 20.0, 20.0, 20.0]) + + +def test_volume_profile_streaming_matches_batch(ohlcv): + high, low, close, volume = ohlcv + batch = ta.VolumeProfile(10, 8).batch(high, low, volume) + assert batch.shape == (high.size, 10) + streamer = ta.VolumeProfile(10, 8) + for i in range(high.size): + mid = float((high[i] + low[i]) / 2) + out = streamer.update((mid, float(high[i]), float(low[i]), mid, float(volume[i]), i)) + if out is None: + assert np.isnan(batch[i]).all() + else: + pl, ph, bins = out + assert pl == pytest.approx(batch[i][0]) + assert ph == pytest.approx(batch[i][1]) + np.testing.assert_allclose(bins, batch[i][2:]) + + +def test_tpo_profile_reference(): + # bar0 spans 10..14 (+1 to all 4 bins); bar1 spans 11..12 (+1 to bins 1,2). + tpo = ta.TpoProfile(2, 4) + assert tpo.update((12.0, 14.0, 10.0, 12.0, 5.0, 0)) is None + out = tpo.update((11.5, 12.0, 11.0, 11.5, 999.0, 1)) + assert out is not None + price_low, price_high, counts = out + assert price_low == pytest.approx(10.0) + assert price_high == pytest.approx(14.0) + np.testing.assert_allclose(counts, [1.0, 2.0, 2.0, 1.0]) + + +def test_tpo_profile_streaming_matches_batch(ohlcv): + high, low, close, volume = ohlcv + batch = ta.TpoProfile(10, 8).batch(high, low) + assert batch.shape == (high.size, 10) + streamer = ta.TpoProfile(10, 8) + for i in range(high.size): + mid = float((high[i] + low[i]) / 2) + out = streamer.update((mid, float(high[i]), float(low[i]), mid, 1.0, i)) + if out is None: + assert np.isnan(batch[i]).all() + else: + pl, ph, counts = out + assert pl == pytest.approx(batch[i][0]) + assert ph == pytest.approx(batch[i][1]) + np.testing.assert_allclose(counts, batch[i][2:]) + + # --- TD Pressure (OHLCV-input) ------------------------------------------- diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index 99b2b9c6..e6ebfa8d 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -6070,6 +6070,135 @@ impl WasmValueArea { } } +#[wasm_bindgen(js_name = VolumeProfile)] +pub struct WasmVolumeProfile { + inner: wc::VolumeProfile, +} + +#[wasm_bindgen(js_class = VolumeProfile)] +impl WasmVolumeProfile { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, bin_count: usize) -> Result { + Ok(Self { + inner: wc::VolumeProfile::new(period, bin_count).map_err(map_err)?, + }) + } + pub fn batch( + &mut self, + high: &[f64], + low: &[f64], + volume: &[f64], + ) -> Result { + if high.len() != low.len() || low.len() != volume.len() { + return Err(JsError::new("high, low, volume must be equal length")); + } + let k = self.inner.params().1 + 2; + let n = high.len(); + let mut out = vec![f64::NAN; n * k]; + for i in 0..n { + let mid = f64::midpoint(high[i], low[i]); + let c = wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?; + if let Some(o) = self.inner.update(c) { + out[i * k] = o.price_low; + out[i * k + 1] = o.price_high; + for (j, b) in o.bins.iter().enumerate() { + out[i * k + 2 + j] = *b; + } + } + } + Ok(Float64Array::from(out.as_slice())) + } + /// Streaming update. Returns `{ priceLow, priceHigh, bins }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result { + let mid = f64::midpoint(high, low); + let c = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"priceLow".into(), &o.price_low.into()).ok(); + Reflect::set(&obj, &"priceHigh".into(), &o.price_high.into()).ok(); + let bins = Float64Array::from(o.bins.as_slice()); + Reflect::set(&obj, &"bins".into(), &bins).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + 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 = TpoProfile)] +pub struct WasmTpoProfile { + inner: wc::TpoProfile, +} + +#[wasm_bindgen(js_class = TpoProfile)] +impl WasmTpoProfile { + #[wasm_bindgen(constructor)] + pub fn new(period: usize, bin_count: usize) -> Result { + Ok(Self { + inner: wc::TpoProfile::new(period, bin_count).map_err(map_err)?, + }) + } + pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result { + if high.len() != low.len() { + return Err(JsError::new("high, low must be equal length")); + } + let k = self.inner.params().1 + 2; + let n = high.len(); + let mut out = vec![f64::NAN; n * k]; + for i in 0..n { + let mid = f64::midpoint(high[i], low[i]); + let c = wc::Candle::new(mid, high[i], low[i], mid, 1.0, 0).map_err(map_err)?; + if let Some(o) = self.inner.update(c) { + out[i * k] = o.price_low; + out[i * k + 1] = o.price_high; + for (j, count) in o.counts.iter().enumerate() { + out[i * k + 2 + j] = *count; + } + } + } + Ok(Float64Array::from(out.as_slice())) + } + /// Streaming update. Returns `{ priceLow, priceHigh, counts }` once warm, else `null`. + pub fn update(&mut self, high: f64, low: f64) -> Result { + let mid = f64::midpoint(high, low); + let c = wc::Candle::new(mid, high, low, mid, 1.0, 0).map_err(map_err)?; + Ok(match self.inner.update(c) { + Some(o) => { + let obj = Object::new(); + Reflect::set(&obj, &"priceLow".into(), &o.price_low.into()).ok(); + Reflect::set(&obj, &"priceHigh".into(), &o.price_high.into()).ok(); + let counts = Float64Array::from(o.counts.as_slice()); + Reflect::set(&obj, &"counts".into(), &counts).ok(); + obj.into() + } + None => JsValue::NULL, + }) + } + 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 = InitialBalance)] pub struct WasmInitialBalance { inner: wc::InitialBalance, diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index ff726e2f..ace1257f 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -252,6 +252,7 @@ mod three_soldiers_or_crows; mod three_stars_in_south; mod thrusting; mod tii; +mod tpo_profile; mod trade_imbalance; mod treynor_ratio; mod trima; @@ -275,6 +276,7 @@ mod vertical_horizontal_filter; mod vidya; mod volty_stop; mod volume_oscillator; +mod volume_profile; mod vortex; mod vpt; mod vwap; @@ -542,6 +544,7 @@ pub use three_soldiers_or_crows::ThreeSoldiersOrCrows; pub use three_stars_in_south::ThreeStarsInSouth; pub use thrusting::Thrusting; pub use tii::Tii; +pub use tpo_profile::{TpoProfile, TpoProfileOutput}; pub use trade_imbalance::TradeImbalance; pub use treynor_ratio::TreynorRatio; pub use trima::Trima; @@ -565,6 +568,7 @@ pub use vertical_horizontal_filter::VerticalHorizontalFilter; pub use vidya::Vidya; pub use volty_stop::VoltyStop; pub use volume_oscillator::VolumeOscillator; +pub use volume_profile::{VolumeProfile, VolumeProfileOutput}; pub use vortex::{Vortex, VortexOutput}; pub use vpt::VolumePriceTrend; pub use vwap::{RollingVwap, Vwap}; @@ -932,7 +936,13 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ ), ( "Market Profile", - &["ValueArea", "InitialBalance", "OpeningRange"], + &[ + "ValueArea", + "InitialBalance", + "OpeningRange", + "VolumeProfile", + "TpoProfile", + ], ), ( "Risk / Performance", @@ -984,6 +994,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, 285, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 287, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/tpo_profile.rs b/crates/wickra-core/src/indicators/tpo_profile.rs new file mode 100644 index 00000000..6bfd73dd --- /dev/null +++ b/crates/wickra-core/src/indicators/tpo_profile.rs @@ -0,0 +1,314 @@ +//! TPO Profile — the Time-Price-Opportunity (market-profile letter) distribution. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// TPO Profile output: the price domain plus the per-bin time-period counts. +/// +/// `counts[i]` is the number of periods in the rolling window whose `[low, high]` +/// range touched the price bucket `[price_low + i * w, price_low + (i + 1) * w)` +/// where `w = (price_high - price_low) / counts.len()`. This is the classic +/// market-profile "letter" count: one Time-Price-Opportunity per period per +/// price level it traded at, independent of volume. +#[derive(Debug, Clone, PartialEq)] +pub struct TpoProfileOutput { + /// Lowest price in the window — the lower edge of bin 0. + pub price_low: f64, + /// Highest price in the window — the upper edge of the last bin. + pub price_high: f64, + /// Per-bin TPO count, lowest price bucket first. Length equals `bin_count`. + pub counts: Vec, +} + +/// Rolling TPO (Time Price Opportunity) Profile over the last `period` candles. +/// +/// Where [`crate::VolumeProfile`] distributes each bar's *volume* across the +/// bins it touches, the TPO profile counts *time*: every period that trades at a +/// price level contributes exactly one TPO mark there, regardless of how much +/// volume it carried. The result highlights the prices the market spent the most +/// time at — the market-profile bell curve. Each touched bin receives a full +/// `+1` per period (no sharing), so a wide-range bar marks every level it spans. +/// +/// A window whose bars are all single-print at one price (`price_high == price_low`) +/// is degenerate: every period's mark lands in bin 0 and both edges collapse to +/// that price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TpoProfile}; +/// +/// let mut tpo = TpoProfile::new(5, 10).unwrap(); +/// let mut last = None; +/// for i in 0..10 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// last = tpo.update(candle); +/// } +/// let profile = last.unwrap(); +/// assert_eq!(profile.counts.len(), 10); +/// ``` +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone)] +pub struct TpoProfile { + period: usize, + bin_count: usize, + window: VecDeque, + last: Option, +} + +impl TpoProfile { + /// Construct a TPO Profile indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero. + pub fn new(period: usize, bin_count: usize) -> Result { + if period == 0 || bin_count == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bin_count, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Classic TPO Profile: 30-bar rolling window, 50 bins. + pub fn classic() -> Self { + Self::new(30, 50).expect("classic TpoProfile params are valid") + } + + /// Configured `(period, bin_count)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bin_count) + } + + /// Most recent profile if available. + pub fn value(&self) -> Option<&TpoProfileOutput> { + self.last.as_ref() + } + + fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize { + let raw = ((price - win_low) / bin_width).floor(); + let max = (self.bin_count - 1) as f64; + raw.clamp(0.0, max) as usize + } + + fn compute(&self) -> TpoProfileOutput { + let mut win_low = f64::INFINITY; + let mut win_high = f64::NEG_INFINITY; + for candle in &self.window { + if candle.low < win_low { + win_low = candle.low; + } + if candle.high > win_high { + win_high = candle.high; + } + } + let span = win_high - win_low; + let mut counts = vec![0.0_f64; self.bin_count]; + + if span <= 0.0 { + // All bars are single-print at the same price: every period marks bin 0. + counts[0] = self.window.len() as f64; + return TpoProfileOutput { + price_low: win_low, + price_high: win_low, + counts, + }; + } + + let bin_width = span / self.bin_count as f64; + for candle in &self.window { + if candle.high <= candle.low { + let idx = self.price_to_bin(candle.low, win_low, bin_width); + counts[idx] += 1.0; + continue; + } + let lo_idx = self.price_to_bin(candle.low, win_low, bin_width); + let hi_idx = self.price_to_bin(candle.high, win_low, bin_width); + for count in counts.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *count += 1.0; + } + } + + TpoProfileOutput { + price_low: win_low, + price_high: win_high, + counts, + } + } +} + +impl Indicator for TpoProfile { + type Input = Candle; + type Output = TpoProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out.clone()); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TpoProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(TpoProfile::new(0, 50), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_zero_bin_count() { + assert!(matches!(TpoProfile::new(20, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let tpo = TpoProfile::new(30, 50).unwrap(); + assert_eq!(tpo.name(), "TpoProfile"); + assert_eq!(tpo.warmup_period(), 30); + assert_eq!(tpo.params(), (30, 50)); + assert!(tpo.value().is_none()); + assert!(!tpo.is_ready()); + } + + #[test] + fn classic_params() { + let tpo = TpoProfile::classic(); + assert_eq!(tpo.params(), (30, 50)); + } + + #[test] + fn warms_up_over_period() { + let mut tpo = TpoProfile::new(3, 4).unwrap(); + assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none()); + assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none()); + assert!(tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some()); + assert!(tpo.is_ready()); + } + + #[test] + fn reference_counts() { + // Window of 2 candles, 4 bins, domain 10..14, width 1. + // bar0: 10..14 touches bins 0,1,2,3 -> +1 each. + // bar1: 11..12 touches bins 1,2 -> +1 each. + // counts = [1, 2, 2, 1]. TPO is volume-agnostic. + let mut tpo = TpoProfile::new(2, 4).unwrap(); + assert!(tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)).is_none()); + let out = tpo.update(c(11.0, 12.0, 11.0, 11.5, 999.0, 1)).unwrap(); + assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12); + assert_eq!(out.counts.len(), 4); + assert_relative_eq!(out.counts[0], 1.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[1], 2.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[2], 2.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[3], 1.0, epsilon = 1e-12); + } + + #[test] + fn volume_independent() { + // Identical ranges with wildly different volumes give identical TPO counts. + let mut a = TpoProfile::new(2, 4).unwrap(); + let mut b = TpoProfile::new(2, 4).unwrap(); + a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 0)); + let out_a = a.update(c(10.0, 14.0, 10.0, 12.0, 1.0, 1)).unwrap(); + b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 0)); + let out_b = b.update(c(10.0, 14.0, 10.0, 12.0, 9_999.0, 1)).unwrap(); + assert_eq!(out_a.counts, out_b.counts); + } + + #[test] + fn degenerate_single_price_window() { + let mut tpo = TpoProfile::new(3, 4).unwrap(); + tpo.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0)); + tpo.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1)); + let out = tpo.update(c(50.0, 50.0, 50.0, 50.0, 30.0, 2)).unwrap(); + assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[0], 3.0, epsilon = 1e-12); + assert_relative_eq!(out.counts[1], 0.0, epsilon = 1e-12); + } + + #[test] + fn single_print_bar_marks_one_bin() { + // A single-print bar inside a wider domain marks exactly its own bin. + let mut tpo = TpoProfile::new(2, 4).unwrap(); + tpo.update(c(10.0, 14.0, 10.0, 12.0, 5.0, 0)); // domain setter + let out = tpo.update(c(13.0, 13.0, 13.0, 13.0, 5.0, 1)).unwrap(); + // domain 10..14, width 1; price 13 -> bin 3. + assert_relative_eq!(out.counts[3], 2.0, epsilon = 1e-12); + } + + #[test] + fn reset_clears_state() { + let mut tpo = TpoProfile::new(2, 4).unwrap(); + tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)); + tpo.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)); + assert!(tpo.is_ready()); + tpo.reset(); + assert!(!tpo.is_ready()); + assert!(tpo.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + f64::from(i % 7); + c( + base, + base + 2.0, + base - 2.0, + base, + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut a = TpoProfile::new(10, 16).unwrap(); + let mut b = TpoProfile::new(10, 16).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/volume_profile.rs b/crates/wickra-core/src/indicators/volume_profile.rs new file mode 100644 index 00000000..5786f49f --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_profile.rs @@ -0,0 +1,333 @@ +//! Volume Profile — the full per-bin volume distribution over a rolling window. + +use std::collections::VecDeque; + +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume Profile output: the price domain plus the per-bin volume histogram. +/// +/// `bins[i]` holds the volume attributed to the price bucket +/// `[price_low + i * w, price_low + (i + 1) * w)` where +/// `w = (price_high - price_low) / bins.len()`. The histogram sums to the total +/// volume in the rolling window (within floating-point tolerance). +#[derive(Debug, Clone, PartialEq)] +pub struct VolumeProfileOutput { + /// Lowest price in the window — the lower edge of bin 0. + pub price_low: f64, + /// Highest price in the window — the upper edge of the last bin. + pub price_high: f64, + /// Per-bin volume, lowest price bucket first. Length equals `bin_count`. + pub bins: Vec, +} + +/// Rolling Volume Profile over the last `period` candles. +/// +/// Where [`crate::ValueArea`] reduces the same volume distribution to its +/// summary levels (Point of Control, Value Area High / Low), Volume Profile +/// exposes the **full histogram** so callers can inspect, render or post-process +/// the raw distribution. Each candle's volume is spread uniformly across the +/// bins its `[low, high]` range touches; a single-print bar (`low == high`) +/// drops its whole volume into one bin. The histogram domain spans the window's +/// lowest low to its highest high. +/// +/// A window whose bars are all single-print at one price (`price_high == price_low`) +/// is degenerate: the entire volume lands in bin 0 and both edges collapse to +/// that price. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeProfile}; +/// +/// let mut vp = VolumeProfile::new(5, 10).unwrap(); +/// let mut last = None; +/// for i in 0..10 { +/// let base = 100.0 + f64::from(i); +/// let candle = +/// Candle::new(base, base + 2.0, base - 2.0, base, 10.0, i64::from(i)).unwrap(); +/// last = vp.update(candle); +/// } +/// let profile = last.unwrap(); +/// assert_eq!(profile.bins.len(), 10); +/// ``` +#[allow(clippy::struct_field_names)] +#[derive(Debug, Clone)] +pub struct VolumeProfile { + period: usize, + bin_count: usize, + window: VecDeque, + last: Option, +} + +impl VolumeProfile { + /// Construct a Volume Profile indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if `period` or `bin_count` is zero. + pub fn new(period: usize, bin_count: usize) -> Result { + if period == 0 || bin_count == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + period, + bin_count, + window: VecDeque::with_capacity(period), + last: None, + }) + } + + /// Classic Volume Profile: 20-bar rolling window, 50 bins. + pub fn classic() -> Self { + Self::new(20, 50).expect("classic VolumeProfile params are valid") + } + + /// Configured `(period, bin_count)`. + pub const fn params(&self) -> (usize, usize) { + (self.period, self.bin_count) + } + + /// Most recent profile if available. + pub fn value(&self) -> Option<&VolumeProfileOutput> { + self.last.as_ref() + } + + fn price_to_bin(&self, price: f64, win_low: f64, bin_width: f64) -> usize { + let raw = ((price - win_low) / bin_width).floor(); + let max = (self.bin_count - 1) as f64; + raw.clamp(0.0, max) as usize + } + + fn compute(&self) -> VolumeProfileOutput { + let mut win_low = f64::INFINITY; + let mut win_high = f64::NEG_INFINITY; + for candle in &self.window { + if candle.low < win_low { + win_low = candle.low; + } + if candle.high > win_high { + win_high = candle.high; + } + } + let span = win_high - win_low; + let mut bins = vec![0.0_f64; self.bin_count]; + + if span <= 0.0 { + // All bars are single-print at the same price. + let total: f64 = self.window.iter().map(|candle| candle.volume).sum(); + bins[0] = total; + return VolumeProfileOutput { + price_low: win_low, + price_high: win_low, + bins, + }; + } + + let bin_width = span / self.bin_count as f64; + for candle in &self.window { + if candle.volume == 0.0 { + continue; + } + if candle.high <= candle.low { + let idx = self.price_to_bin(candle.low, win_low, bin_width); + bins[idx] += candle.volume; + continue; + } + let lo_idx = self.price_to_bin(candle.low, win_low, bin_width); + let hi_idx = self.price_to_bin(candle.high, win_low, bin_width); + let touched = hi_idx - lo_idx + 1; + let share = candle.volume / touched as f64; + for bin in bins.iter_mut().take(hi_idx + 1).skip(lo_idx) { + *bin += share; + } + } + + VolumeProfileOutput { + price_low: win_low, + price_high: win_high, + bins, + } + } +} + +impl Indicator for VolumeProfile { + type Input = Candle; + type Output = VolumeProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + if self.window.len() == self.period { + self.window.pop_front(); + } + self.window.push_back(candle); + if self.window.len() < self.period { + return None; + } + let out = self.compute(); + self.last = Some(out.clone()); + Some(out) + } + + fn reset(&mut self) { + self.window.clear(); + self.last = None; + } + + fn warmup_period(&self) -> usize { + self.period + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + fn c(open: f64, high: f64, low: f64, close: f64, volume: f64, ts: i64) -> Candle { + Candle::new(open, high, low, close, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_period() { + assert!(matches!(VolumeProfile::new(0, 50), Err(Error::PeriodZero))); + } + + #[test] + fn rejects_zero_bin_count() { + assert!(matches!(VolumeProfile::new(20, 0), Err(Error::PeriodZero))); + } + + #[test] + fn accessors_and_metadata() { + let vp = VolumeProfile::new(20, 50).unwrap(); + assert_eq!(vp.name(), "VolumeProfile"); + assert_eq!(vp.warmup_period(), 20); + assert_eq!(vp.params(), (20, 50)); + assert!(vp.value().is_none()); + assert!(!vp.is_ready()); + } + + #[test] + fn classic_params() { + let vp = VolumeProfile::classic(); + assert_eq!(vp.params(), (20, 50)); + } + + #[test] + fn warms_up_over_period() { + let mut vp = VolumeProfile::new(3, 4).unwrap(); + assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)).is_none()); + assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)).is_none()); + assert!(vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 2)).is_some()); + assert!(vp.is_ready()); + } + + #[test] + fn reference_distribution() { + // Window of 2 candles, 4 bins. + // bar0: single print at 10, vol 100 -> bin 0 gets 100. + // bar1: 10..14, vol 80, spans 4 bins -> 20 each. + // domain: low=10, high=14, width=1 -> bins = [120, 20, 20, 20]. + let mut vp = VolumeProfile::new(2, 4).unwrap(); + assert!(vp.update(c(10.0, 10.0, 10.0, 10.0, 100.0, 0)).is_none()); + let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 80.0, 1)).unwrap(); + assert_relative_eq!(out.price_low, 10.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12); + assert_eq!(out.bins.len(), 4); + assert_relative_eq!(out.bins[0], 120.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[1], 20.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[2], 20.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[3], 20.0, epsilon = 1e-9); + } + + #[test] + fn conserves_total_volume() { + let mut vp = VolumeProfile::new(4, 8).unwrap(); + let candles = [ + c(10.0, 12.0, 9.0, 11.0, 30.0, 0), + c(11.0, 13.0, 10.0, 12.0, 40.0, 1), + c(12.0, 14.0, 11.0, 13.0, 50.0, 2), + c(13.0, 15.0, 12.0, 14.0, 60.0, 3), + ]; + let out = vp.batch(&candles).pop().unwrap().unwrap(); + let total: f64 = out.bins.iter().sum(); + assert_relative_eq!(total, 180.0, epsilon = 1e-9); + } + + #[test] + fn degenerate_single_price_window() { + // All bars single-print at 50 -> domain collapses, all volume in bin 0. + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(50.0, 50.0, 50.0, 50.0, 10.0, 0)); + let out = vp.update(c(50.0, 50.0, 50.0, 50.0, 20.0, 1)).unwrap(); + assert_relative_eq!(out.price_low, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.price_high, 50.0, epsilon = 1e-12); + assert_relative_eq!(out.bins[0], 30.0, epsilon = 1e-9); + assert_relative_eq!(out.bins[1], 0.0, epsilon = 1e-12); + } + + #[test] + fn zero_volume_bars_are_skipped() { + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(10.0, 14.0, 10.0, 12.0, 0.0, 0)); + let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1)).unwrap(); + let total: f64 = out.bins.iter().sum(); + assert_relative_eq!(total, 40.0, epsilon = 1e-9); + } + + #[test] + fn rolling_window_drops_oldest() { + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(100.0, 100.0, 100.0, 100.0, 99.0, 0)); + vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 1)); + // Third bar evicts the price-100 bar; domain is now 10..14 only. + let out = vp.update(c(10.0, 14.0, 10.0, 12.0, 40.0, 2)).unwrap(); + assert_relative_eq!(out.price_high, 14.0, epsilon = 1e-12); + let total: f64 = out.bins.iter().sum(); + assert_relative_eq!(total, 80.0, epsilon = 1e-9); + } + + #[test] + fn reset_clears_state() { + let mut vp = VolumeProfile::new(2, 4).unwrap(); + vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 0)); + vp.update(c(10.0, 11.0, 9.0, 10.0, 5.0, 1)); + assert!(vp.is_ready()); + vp.reset(); + assert!(!vp.is_ready()); + assert!(vp.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + let base = 100.0 + f64::from(i % 7); + c( + base, + base + 2.0, + base - 2.0, + base, + 10.0 + f64::from(i), + i64::from(i), + ) + }) + .collect(); + let mut a = VolumeProfile::new(10, 16).unwrap(); + let mut b = VolumeProfile::new(10, 16).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 03ca9d7e..d360c57d 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -100,14 +100,15 @@ pub use indicators::{ TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, - Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze, - TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, - UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, - ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator, - VolumePriceTrend, 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, + Tii, TpoProfile, TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, + Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, + UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, + ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, + VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, + Vwap, 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/crates/wickra/benches/indicators.rs b/crates/wickra/benches/indicators.rs index 29da35fa..b1089869 100644 --- a/crates/wickra/benches/indicators.rs +++ b/crates/wickra/benches/indicators.rs @@ -39,9 +39,10 @@ use wickra::{ Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, Obv, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput, - TdSequential, TdSequentialOutput, Trade, TradeImbalance, TradeQuote, TtmSqueeze, - TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, - VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3, + TdSequential, TdSequentialOutput, TpoProfile, TpoProfileOutput, Trade, TradeImbalance, + TradeQuote, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, + VolumeProfile, VolumeProfileOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, + YangZhangVolatility, T3, }; use wickra_data::csv::CandleReader; @@ -348,6 +349,12 @@ fn benches(c: &mut Criterion) { bench_candle_input::<_, _, ValueAreaOutput>(c, "value_area", &candles, || { ValueArea::new(20, 50, 0.70).unwrap() }); + bench_candle_input::<_, _, VolumeProfileOutput>(c, "volume_profile", &candles, || { + VolumeProfile::new(20, 50).unwrap() + }); + bench_candle_input::<_, _, TpoProfileOutput>(c, "tpo_profile", &candles, || { + TpoProfile::new(20, 50).unwrap() + }); // === Family 16 — Risk / Performance Metrics === // Close-prices stand in for the equity curve / return stream; absolute diff --git a/docs/README.md b/docs/README.md index a892fbeb..c84e9e6e 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 **290 indicators** across +- A per-indicator deep dive for every one of the **292 indicators** across the sixteen families (Moving Averages, Momentum Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands, Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots & diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index f8af3f23..cd617d5b 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -23,7 +23,7 @@ use libfuzzer_sys::fuzz_target; use wickra_core::{ -AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MedianPrice, Mfi, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag +AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MedianPrice, Mfi, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag }; /// Convert a flat `f64` stream into a `Vec` by chunking it into @@ -155,6 +155,8 @@ fuzz_target!(|data: Vec| { // --- Market Profile (multi-output) --- drive(|| ValueArea::new(20, 50, 0.70).unwrap(), &candles); + drive(|| VolumeProfile::new(20, 50).unwrap(), &candles); + drive(|| TpoProfile::new(20, 50).unwrap(), &candles); drive(|| InitialBalance::new(12).unwrap(), &candles); drive(|| OpeningRange::new(6).unwrap(), &candles);