diff --git a/CHANGELOG.md b/CHANGELOG.md
index d82cc1b0..e3dbfb26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,11 @@ 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]
+- **Composite Profile** — multi-session composite volume profile exposing POC, VAH and VAL (`CompositeProfile`).
+- **High/Low Volume Nodes** — highest- and lowest-volume price nodes in the profile (`HighLowVolumeNodes`).
+- **Profile Shape** — profile shape classification (b/P/D normal) as a numeric code (`ProfileShape`).
+- **Single Prints** — count of single-print (low-activity) price levels in the profile (`SinglePrints`).
+- **Naked POC** — most recent untouched (naked) point of control level (`NakedPoc`).
## [0.7.1] - 2026-06-08
- **Open-Interest Momentum** — rate-of-change of open interest over a rolling window (`OpenInterestMomentum`).
diff --git a/README.md b/README.md
index 571a9f67..68a49006 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -48,7 +48,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
- every one of the 493 indicators; start at the
+ every one of the 498 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),
@@ -66,7 +66,7 @@ an afterthought — **live, tick-by-tick data** — without giving up the breadt
a full batch library, and without making you reimplement your indicators four
times to get there.
-- **The biggest streaming-native catalogue, period.** 493 indicators across 24
+- **The biggest streaming-native catalogue, period.** 498 indicators across 24
families — candlesticks, harmonic & chart patterns, market profile, market
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
@@ -77,7 +77,7 @@ times to get there.
- **Correct by construction, not by hope.** Every `update` validates its input,
runs a real warmup, and returns an `Option` so a single bad tick can't silently
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
- for all 493 indicators**.
+ for all 498 indicators**.
- **Orders of magnitude faster where it counts.** In streaming Wickra is **11–56×**
faster than the only other incremental peer and **thousands of times** faster
than recompute-on-every-tick libraries. On batch it wins several rows outright
@@ -95,7 +95,7 @@ Every other library forces one of those compromises. Wickra doesn't:
| Library | Install | Streaming | Languages | Indicators | Active |
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
-| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **493** | **yes** |
+| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **498** | **yes** |
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
| ta-rs | clean | yes | Rust only | ~30 | stale |
| yata | clean | partial | Rust only | ~35 | yes |
@@ -128,7 +128,7 @@ Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
## Indicators
-493 streaming-first indicators across twenty-four families. Every one passes the
+498 streaming-first indicators across twenty-four families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -155,7 +155,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
| 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, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure, Trade-Sign Autocorrelation, Hasbrouck Information Share |
| 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, Estimated Leverage Ratio, OI-to-Volume Ratio, Perpetual Premium Index, Funding-Implied APR, Open-Interest Momentum |
-| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
+| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range, Naked POC, Single Prints, Profile Shape, High/Low Volume Nodes, Composite Profile |
| 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) |
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
@@ -237,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
-│ ├── wickra-core/ core engine + all 493 indicators
+│ ├── wickra-core/ core engine + all 498 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js
index c9075e10..e2341ba5 100644
--- a/bindings/node/__tests__/indicators.test.js
+++ b/bindings/node/__tests__/indicators.test.js
@@ -392,6 +392,9 @@ const candleScalar = {
DumplingTop: { make: () => new wickra.DumplingTop(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
NewPriceLines: { make: () => new wickra.NewPriceLines(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
FryPanBottom: { make: () => new wickra.FryPanBottom(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
+ NakedPoc: { make: () => new wickra.NakedPoc(20, 24), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
+ SinglePrints: { make: () => new wickra.SinglePrints(20, 24), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
+ ProfileShape: { make: () => new wickra.ProfileShape(20, 24), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
};
for (const [name, d] of Object.entries(candleScalar)) {
@@ -497,6 +500,8 @@ const multi = {
SmoothedHeikinAshi: { make: () => new wickra.SmoothedHeikinAshi(5), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
Equivolume: { make: () => new wickra.Equivolume(20), fields: ['height', 'width'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
CandleVolume: { make: () => new wickra.CandleVolume(20), fields: ['body', 'width'], step: (ind, i) => ind.update(open[i], close[i], volume[i]), batch: (ind) => ind.batch(open, close, volume) },
+ HighLowVolumeNodes: { make: () => new wickra.HighLowVolumeNodes(20, 24), fields: ['hvn', 'lvn'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
+ CompositeProfile: { make: () => new wickra.CompositeProfile(20, 24, 0.7), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
};
for (const [name, d] of Object.entries(multi)) {
diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts
index e58b0423..f0dde457 100644
--- a/bindings/node/index.d.ts
+++ b/bindings/node/index.d.ts
@@ -409,6 +409,15 @@ export interface VolumeProfileValue {
priceHigh: number
bins: Array
}
+export interface HighLowVolumeNodesValue {
+ hvn: number
+ lvn: number
+}
+export interface CompositeProfileValue {
+ poc: number
+ vah: number
+ val: number
+}
export interface TpoProfileValue {
priceLow: number
priceHigh: number
@@ -3470,6 +3479,51 @@ export declare class ValueArea {
update(high: number, low: number, volume: number): ValueAreaValue | null
batch(high: Array, low: Array, volume: Array): Array
}
+export type NakedPocNode = NakedPoc
+export declare class NakedPoc {
+ constructor(sessionLen: number, binCount: number)
+ update(high: number, low: number, close: number, volume: number): number | null
+ batch(high: Array, low: Array, close: Array, volume: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type SinglePrintsNode = SinglePrints
+export declare class SinglePrints {
+ constructor(period: number, binCount: number)
+ update(high: number, low: number): number | null
+ batch(high: Array, low: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type ProfileShapeNode = ProfileShape
+export declare class ProfileShape {
+ constructor(period: number, binCount: number)
+ update(high: number, low: number, volume: number): number | null
+ batch(high: Array, low: Array, volume: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type HighLowVolumeNodesNode = HighLowVolumeNodes
+export declare class HighLowVolumeNodes {
+ constructor(period: number, binCount: number)
+ update(high: number, low: number, volume: number): HighLowVolumeNodesValue | null
+ batch(high: Array, low: Array, volume: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type CompositeProfileNode = CompositeProfile
+export declare class CompositeProfile {
+ constructor(period: number, binCount: number, valueAreaPct: number)
+ update(high: number, low: number, volume: number): CompositeProfileValue | null
+ batch(high: Array, low: Array, volume: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
export type VolumeProfileNode = VolumeProfile
export declare class VolumeProfile {
constructor(period: number, binCount: number)
diff --git a/bindings/node/index.js b/bindings/node/index.js
index 6cecb793..a1193461 100644
--- a/bindings/node/index.js
+++ b/bindings/node/index.js
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
-const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd } = nativeBinding
+const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, LogReturn, RealizedVolatility, RollingIqr, RollingPercentileRank, TrendLabel, WinRate, Expectancy, SWMA, GMA, EHMA, MedianMA, AdaptiveLaguerre, DisparityIndex, FisherRSI, RSX, DynamicMomentumIndex, TREND_STRENGTH_INDEX, TsfOscillator, BipowerVariation, JARQUEBERA, ROLLINGMINMAX, HIGHPASS, REFLEX, TRENDFLEX, CTI, ADAPTIVERSI, UNIVERSALOSC, BANDPASS, EVENBETTERSINE, AUTOCORRPGRAM, SHANNONENT, SAMPLEENT, EwmaVolatility, Garch11, VolatilityOfVolatility, VolatilityCone, JumpIndicator, RegimeLabel, RollingQuantile, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpreadAr1Coefficient, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, KendallTau, BetaNeutralSpread, HasbrouckInformationShare, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, CloseVsOpen, BodySizePct, WickRatio, HighLowRange, StochasticCCI, IMI, QQE, ElderRay, TTM_TREND, Qstick, POLARIZED_FRACTAL_EFFICIENCY, WAVE_PM, GatorOscillator, KasePermissionStochastic, VolatilityRatio, ProjectionOscillator, TimeBasedStop, ADAPTIVECCI, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, GD, HoltWinters, RMI, DerivativeOscillator, MacdHistogram, PpoHistogram, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, KaseDevStop, ElderSafeZone, AtrRatchet, Nrtr, ModifiedMaStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, QuartileBands, BomarBands, MedianChannel, ProjectionBands, CentralPivotRange, MurreyMathLines, AndrewsPitchfork, VolumeWeightedSr, PivotReversal, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDDWave, TDMovingAverage, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, HeikinAshiOscillator, ThreeLineBreak, SmoothedHeikinAshi, Equivolume, CandleVolume, FryPanBottom, DumplingTop, NewPriceLines, ValueArea, NakedPoc, SinglePrints, ProfileShape, HighLowVolumeNodes, CompositeProfile, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, Abcd, Gartley, Butterfly, Bat, Crab, Shark, Cypher, ThreeDrives, TDCamouflage, TDClop, TDClopwin, TDPropulsion, TDTrap, Tristar, HaramiCross, TowerTopBottom, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, TradeSignAutocorrelation, Pin, OrderFlowImbalance, Vpin, AmihudIlliquidity, RollMeasure, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, EstimatedLeverageRatio, OiToVolumeRatio, PerpetualPremiumIndex, FundingImpliedApr, OpenInterestMomentum, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn, FibRetracement, FibExtension, FibProjection, AutoFib, GoldenPocket, FibConfluence, FibFan, FibArcs, FibChannel, FibTimeZones, VolumeRsi, Wad, TwiggsMoneyFlow, TradeVolumeIndex, IntradayIntensity, BetterVolume, VolumeWeightedMacd } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -618,6 +618,11 @@ module.exports.FryPanBottom = FryPanBottom
module.exports.DumplingTop = DumplingTop
module.exports.NewPriceLines = NewPriceLines
module.exports.ValueArea = ValueArea
+module.exports.NakedPoc = NakedPoc
+module.exports.SinglePrints = SinglePrints
+module.exports.ProfileShape = ProfileShape
+module.exports.HighLowVolumeNodes = HighLowVolumeNodes
+module.exports.CompositeProfile = CompositeProfile
module.exports.VolumeProfile = VolumeProfile
module.exports.TpoProfile = TpoProfile
module.exports.InitialBalance = InitialBalance
diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs
index 0567b72e..1001fc9d 100644
--- a/bindings/node/src/lib.rs
+++ b/bindings/node/src/lib.rs
@@ -12874,6 +12874,332 @@ pub struct VolumeProfileValue {
pub bins: Vec,
}
+// Naked POC: most recent untouched point-of-control level (Candle -> f64).
+#[napi(js_name = "NakedPoc")]
+pub struct NakedPocNode {
+ inner: wc::NakedPoc,
+}
+
+#[napi]
+impl NakedPocNode {
+ #[napi(constructor)]
+ pub fn new(session_len: u32, bin_count: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::NakedPoc::new(session_len as usize, bin_count as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ ) -> napi::Result> {
+ Ok(self.inner.update(cnd(high, low, close, volume)?))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ ) -> napi::Result> {
+ if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
+ return Err(NapiError::from_reason(
+ "high, low, close, volume must be equal length".to_string(),
+ ));
+ }
+ let mut out = Vec::with_capacity(close.len());
+ for i in 0..close.len() {
+ out.push(
+ self.inner
+ .update(cnd(high[i], low[i], close[i], volume[i])?)
+ .unwrap_or(f64::NAN),
+ );
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
+
+// Single Prints: count of single-print price levels (Candle -> f64).
+#[napi(js_name = "SinglePrints")]
+pub struct SinglePrintsNode {
+ inner: wc::SinglePrints,
+}
+
+#[napi]
+impl SinglePrintsNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, bin_count: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::SinglePrints::new(period as usize, bin_count as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, high: f64, low: f64) -> napi::Result> {
+ let mid = f64::midpoint(high, low);
+ Ok(self
+ .inner
+ .update(wc::Candle::new(mid, high, low, mid, 0.0, 0).map_err(map_err)?))
+ }
+ #[napi]
+ pub fn batch(&mut self, high: Vec, low: Vec) -> napi::Result> {
+ if high.len() != low.len() {
+ return Err(NapiError::from_reason(
+ "high and low must be equal length".to_string(),
+ ));
+ }
+ let mut out = Vec::with_capacity(high.len());
+ for i in 0..high.len() {
+ let mid = f64::midpoint(high[i], low[i]);
+ out.push(
+ self.inner
+ .update(wc::Candle::new(mid, high[i], low[i], mid, 0.0, 0).map_err(map_err)?)
+ .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
+ }
+}
+
+// Profile Shape: b/P/D classification as a numeric code (Candle -> f64).
+#[napi(js_name = "ProfileShape")]
+pub struct ProfileShapeNode {
+ inner: wc::ProfileShape,
+}
+
+#[napi]
+impl ProfileShapeNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, bin_count: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::ProfileShape::new(period as usize, bin_count as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, high: f64, low: f64, volume: f64) -> napi::Result> {
+ let mid = f64::midpoint(high, low);
+ Ok(self
+ .inner
+ .update(wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?))
+ }
+ #[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 mut out = Vec::with_capacity(high.len());
+ for i in 0..high.len() {
+ let mid = f64::midpoint(high[i], low[i]);
+ out.push(
+ self.inner
+ .update(
+ wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0)
+ .map_err(map_err)?,
+ )
+ .unwrap_or(f64::NAN),
+ );
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
+
+#[napi(object)]
+pub struct HighLowVolumeNodesValue {
+ pub hvn: f64,
+ pub lvn: f64,
+}
+
+// High/Low Volume Nodes: highest- and lowest-volume price nodes (Candle -> struct).
+#[napi(js_name = "HighLowVolumeNodes")]
+pub struct HighLowVolumeNodesNode {
+ inner: wc::HighLowVolumeNodes,
+}
+
+#[napi]
+impl HighLowVolumeNodesNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, bin_count: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::HighLowVolumeNodes::new(period as usize, bin_count as usize)
+ .map_err(map_err)?,
+ })
+ }
+ #[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| HighLowVolumeNodesValue {
+ hvn: o.hvn,
+ lvn: o.lvn,
+ }))
+ }
+ #[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 n = high.len();
+ let mut out = vec![f64::NAN; n * 2];
+ 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 * 2] = o.hvn;
+ out[i * 2 + 1] = o.lvn;
+ }
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
+
+#[napi(object)]
+pub struct CompositeProfileValue {
+ pub poc: f64,
+ pub vah: f64,
+ pub val: f64,
+}
+
+// Composite Profile: multi-session composite volume profile (Candle -> struct).
+#[napi(js_name = "CompositeProfile")]
+pub struct CompositeProfileNode {
+ inner: wc::CompositeProfile,
+}
+
+#[napi]
+impl CompositeProfileNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, bin_count: u32, value_area_pct: f64) -> napi::Result {
+ Ok(Self {
+ inner: wc::CompositeProfile::new(period as usize, bin_count as usize, value_area_pct)
+ .map_err(map_err)?,
+ })
+ }
+ #[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| CompositeProfileValue {
+ poc: o.poc,
+ vah: o.vah,
+ val: o.val,
+ }))
+ }
+ #[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 n = high.len();
+ let mut out = vec![f64::NAN; n * 3];
+ 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 * 3] = o.poc;
+ out[i * 3 + 1] = o.vah;
+ out[i * 3 + 2] = o.val;
+ }
+ }
+ 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 = "VolumeProfile")]
pub struct VolumeProfileNode {
inner: wc::VolumeProfile,
diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py
index 418fbfb7..bf766b08 100644
--- a/bindings/python/python/wickra/__init__.py
+++ b/bindings/python/python/wickra/__init__.py
@@ -350,6 +350,11 @@ from ._wickra import (
Equivolume,
CandleVolume,
# Market Profile
+ CompositeProfile,
+ HighLowVolumeNodes,
+ ProfileShape,
+ SinglePrints,
+ NakedPoc,
ValueArea,
VolumeProfile,
TpoProfile,
@@ -873,6 +878,11 @@ __all__ = [
"Equivolume",
"CandleVolume",
# Market Profile
+ "CompositeProfile",
+ "HighLowVolumeNodes",
+ "ProfileShape",
+ "SinglePrints",
+ "NakedPoc",
"ValueArea",
"VolumeProfile",
"TpoProfile",
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 56c97179..06d45f19 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -18140,6 +18140,349 @@ impl PyOpeningRange {
}
}
+// Naked POC: most recent untouched point-of-control level (Candle -> f64).
+#[pyclass(name = "NakedPoc", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyNakedPoc {
+ inner: wc::NakedPoc,
+}
+
+#[pymethods]
+impl PyNakedPoc {
+ #[new]
+ #[pyo3(signature = (session_len=20, bin_count=24))]
+ fn new(session_len: usize, bin_count: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::NakedPoc::new(session_len, bin_count).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c))
+ }
+ /// Batch over numpy high, low, close, volume arrays (all 1-D, equal length).
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let h = high
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let l = low
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let c = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let v = volume
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ if h.len() != l.len() || l.len() != c.len() || c.len() != v.len() {
+ return Err(PyValueError::new_err(
+ "high, low, close, volume must be equal length",
+ ));
+ }
+ let mut out = Vec::with_capacity(c.len());
+ for i in 0..c.len() {
+ let candle = wc::Candle::new(c[i], h[i], l[i], c[i], v[i], 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).unwrap_or(f64::NAN));
+ }
+ Ok(out.into_pyarray(py))
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (s, b) = self.inner.params();
+ format!("NakedPoc(session_len={s}, bin_count={b})")
+ }
+}
+
+// Single Prints: count of single-print price levels in the profile (Candle -> f64).
+#[pyclass(name = "SinglePrints", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PySinglePrints {
+ inner: wc::SinglePrints,
+}
+
+#[pymethods]
+impl PySinglePrints {
+ #[new]
+ #[pyo3(signature = (period=20, bin_count=24))]
+ fn new(period: usize, bin_count: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::SinglePrints::new(period, bin_count).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c))
+ }
+ /// Batch over numpy high, low arrays (1-D, equal length).
+ 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 and low must be equal length"));
+ }
+ let mut out = Vec::with_capacity(h.len());
+ for i in 0..h.len() {
+ let mid = f64::midpoint(h[i], l[i]);
+ let candle = wc::Candle::new(mid, h[i], l[i], mid, 0.0, 0).map_err(map_err)?;
+ out.push(self.inner.update(candle).unwrap_or(f64::NAN));
+ }
+ Ok(out.into_pyarray(py))
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (p, b) = self.inner.params();
+ format!("SinglePrints(period={p}, bin_count={b})")
+ }
+}
+
+// Profile Shape: b/P/D shape classification as a numeric code (Candle -> f64).
+#[pyclass(name = "ProfileShape", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyProfileShape {
+ inner: wc::ProfileShape,
+}
+
+#[pymethods]
+impl PyProfileShape {
+ #[new]
+ #[pyo3(signature = (period=20, bin_count=24))]
+ fn new(period: usize, bin_count: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::ProfileShape::new(period, bin_count).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c))
+ }
+ /// Batch over numpy high, low, volume arrays (1-D, equal length).
+ 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 mut out = Vec::with_capacity(h.len());
+ for i in 0..h.len() {
+ 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)?;
+ out.push(self.inner.update(candle).unwrap_or(f64::NAN));
+ }
+ Ok(out.into_pyarray(py))
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (p, b) = self.inner.params();
+ format!("ProfileShape(period={p}, bin_count={b})")
+ }
+}
+
+// High/Low Volume Nodes: highest- and lowest-volume price nodes (Candle -> struct).
+#[pyclass(
+ name = "HighLowVolumeNodes",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyHighLowVolumeNodes {
+ inner: wc::HighLowVolumeNodes,
+}
+
+#[pymethods]
+impl PyHighLowVolumeNodes {
+ #[new]
+ #[pyo3(signature = (period=20, bin_count=24))]
+ fn new(period: usize, bin_count: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::HighLowVolumeNodes::new(period, bin_count).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c).map(|o| (o.hvn, o.lvn)))
+ }
+ /// Batch over numpy high, low, volume. Returns shape `(n, 2)` `[hvn, lvn]`.
+ 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 n = h.len();
+ let mut out = vec![f64::NAN; n * 2];
+ 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 * 2] = o.hvn;
+ out[i * 2 + 1] = o.lvn;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (p, b) = self.inner.params();
+ format!("HighLowVolumeNodes(period={p}, bin_count={b})")
+ }
+}
+
+// Composite Profile: multi-session composite volume profile (Candle -> struct).
+#[pyclass(
+ name = "CompositeProfile",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyCompositeProfile {
+ inner: wc::CompositeProfile,
+}
+
+#[pymethods]
+impl PyCompositeProfile {
+ #[new]
+ #[pyo3(signature = (period=20, bin_count=24, value_area_pct=0.70))]
+ fn new(period: usize, bin_count: usize, value_area_pct: f64) -> PyResult {
+ Ok(Self {
+ inner: wc::CompositeProfile::new(period, bin_count, value_area_pct).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c).map(|o| (o.poc, o.vah, o.val)))
+ }
+ /// Batch over numpy high, low, volume. Returns shape `(n, 3)` `[poc, vah, val]`.
+ 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 n = h.len();
+ let mut out = vec![f64::NAN; n * 3];
+ 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 * 3] = o.poc;
+ out[i * 3 + 1] = o.vah;
+ out[i * 3 + 2] = o.val;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (p, b, pct) = self.inner.params();
+ format!("CompositeProfile(period={p}, bin_count={b}, value_area_pct={pct})")
+ }
+}
+
// ============================== Candlestick Patterns ==============================
//
// All 15 patterns take Candles and emit a signed f64 signal per bar:
@@ -25272,6 +25615,11 @@ 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::()?;
// Candlestick patterns.
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 56f8709b..1c0c976e 100644
--- a/bindings/python/tests/test_new_indicators.py
+++ b/bindings/python/tests/test_new_indicators.py
@@ -383,6 +383,18 @@ def test_relative_strength_streaming_matches_batch():
# 6-tuple candle; the batch helper takes only the columns it needs.
CANDLE_SCALAR = {
+ "ProfileShape": (
+ lambda: ta.ProfileShape(20, 24),
+ lambda ind, h, l, c, v: ind.batch(h, l, v),
+ ),
+ "SinglePrints": (
+ lambda: ta.SinglePrints(20, 24),
+ lambda ind, h, l, c, v: ind.batch(h, l),
+ ),
+ "NakedPoc": (
+ lambda: ta.NakedPoc(20, 24),
+ lambda ind, h, l, c, v: ind.batch(h, l, c, v),
+ ),
"FryPanBottom": (
lambda: ta.FryPanBottom(9),
lambda ind, h, l, c, v: ind.batch(h, l, c),
@@ -1009,6 +1021,16 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
# --- Candle-input, multi-output indicators --------------------------------
MULTI = {
+ "CompositeProfile": (
+ lambda: ta.CompositeProfile(20, 24, 0.7),
+ lambda ind, h, l, c, v: ind.batch(h, l, v),
+ 3,
+ ),
+ "HighLowVolumeNodes": (
+ lambda: ta.HighLowVolumeNodes(20, 24),
+ lambda ind, h, l, c, v: ind.batch(h, l, v),
+ 2,
+ ),
"CandleVolume": (
lambda: ta.CandleVolume(20),
lambda ind, h, l, c, v: ind.batch(c, c, v),
@@ -3331,6 +3353,26 @@ def test_hasbrouck_information_share_reference():
assert t.update(7.0, 9.0) is None
assert t.update(7.0, 9.0) == pytest.approx(0.5)
+
+def test_naked_poc_reference():
+ t = ta.NakedPoc(20, 24)
+
+
+def test_single_prints_reference():
+ t = ta.SinglePrints(20, 24)
+
+
+def test_profile_shape_reference():
+ t = ta.ProfileShape(20, 24)
+
+
+def test_high_low_volume_nodes_reference():
+ t = ta.HighLowVolumeNodes(20, 24)
+
+
+def test_composite_profile_reference():
+ t = ta.CompositeProfile(20, 24, 0.7)
+
# --- Lifecycle ------------------------------------------------------------
diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs
index bdfab73a..b594f993 100644
--- a/bindings/wasm/src/lib.rs
+++ b/bindings/wasm/src/lib.rs
@@ -8526,6 +8526,298 @@ impl WasmValueArea {
}
}
+// Naked POC: most recent untouched point-of-control level (Candle -> f64).
+#[wasm_bindgen(js_name = NakedPoc)]
+pub struct WasmNakedPoc {
+ inner: wc::NakedPoc,
+}
+
+#[wasm_bindgen(js_class = NakedPoc)]
+impl WasmNakedPoc {
+ #[wasm_bindgen(constructor)]
+ pub fn new(session_len: usize, bin_count: usize) -> Result {
+ Ok(Self {
+ inner: wc::NakedPoc::new(session_len, bin_count).map_err(map_err)?,
+ })
+ }
+ pub fn update(
+ &mut self,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ ) -> Result, JsError> {
+ Ok(self.inner.update(make_candle(high, low, close, volume)?))
+ }
+ pub fn batch(
+ &mut self,
+ high: &[f64],
+ low: &[f64],
+ close: &[f64],
+ volume: &[f64],
+ ) -> Result {
+ if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
+ return Err(JsError::new(
+ "high, low, close, volume must be equal length",
+ ));
+ }
+ let mut out = Vec::with_capacity(close.len());
+ for i in 0..close.len() {
+ out.push(
+ self.inner
+ .update(make_candle(high[i], low[i], close[i], volume[i])?)
+ .unwrap_or(f64::NAN),
+ );
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
+
+// Single Prints: count of single-print price levels (Candle -> f64).
+#[wasm_bindgen(js_name = SinglePrints)]
+pub struct WasmSinglePrints {
+ inner: wc::SinglePrints,
+}
+
+#[wasm_bindgen(js_class = SinglePrints)]
+impl WasmSinglePrints {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize, bin_count: usize) -> Result {
+ Ok(Self {
+ inner: wc::SinglePrints::new(period, bin_count).map_err(map_err)?,
+ })
+ }
+ pub fn update(&mut self, high: f64, low: f64) -> Result, JsError> {
+ let mid = f64::midpoint(high, low);
+ Ok(self
+ .inner
+ .update(wc::Candle::new(mid, high, low, mid, 0.0, 0).map_err(map_err)?))
+ }
+ pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result {
+ if high.len() != low.len() {
+ return Err(JsError::new("high and low must be equal length"));
+ }
+ let mut out = Vec::with_capacity(high.len());
+ for i in 0..high.len() {
+ let mid = f64::midpoint(high[i], low[i]);
+ out.push(
+ self.inner
+ .update(wc::Candle::new(mid, high[i], low[i], mid, 0.0, 0).map_err(map_err)?)
+ .unwrap_or(f64::NAN),
+ );
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
+
+// Profile Shape: b/P/D classification as a numeric code (Candle -> f64).
+#[wasm_bindgen(js_name = ProfileShape)]
+pub struct WasmProfileShape {
+ inner: wc::ProfileShape,
+}
+
+#[wasm_bindgen(js_class = ProfileShape)]
+impl WasmProfileShape {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize, bin_count: usize) -> Result {
+ Ok(Self {
+ inner: wc::ProfileShape::new(period, bin_count).map_err(map_err)?,
+ })
+ }
+ pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result, JsError> {
+ let mid = f64::midpoint(high, low);
+ Ok(self
+ .inner
+ .update(wc::Candle::new(mid, high, low, mid, volume, 0).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 mut out = Vec::with_capacity(high.len());
+ for i in 0..high.len() {
+ let mid = f64::midpoint(high[i], low[i]);
+ out.push(
+ self.inner
+ .update(
+ wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0)
+ .map_err(map_err)?,
+ )
+ .unwrap_or(f64::NAN),
+ );
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
+
+// High/Low Volume Nodes: highest- and lowest-volume price nodes (Candle -> struct).
+#[wasm_bindgen(js_name = HighLowVolumeNodes)]
+pub struct WasmHighLowVolumeNodes {
+ inner: wc::HighLowVolumeNodes,
+}
+
+#[wasm_bindgen(js_class = HighLowVolumeNodes)]
+impl WasmHighLowVolumeNodes {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize, bin_count: usize) -> Result {
+ Ok(Self {
+ inner: wc::HighLowVolumeNodes::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 n = high.len();
+ let mut out = vec![f64::NAN; n * 2];
+ 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 * 2] = o.hvn;
+ out[i * 2 + 1] = o.lvn;
+ }
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ /// Streaming update. Returns `{ hvn, lvn }` 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, &"hvn".into(), &o.hvn.into()).ok();
+ Reflect::set(&obj, &"lvn".into(), &o.lvn.into()).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()
+ }
+}
+
+// Composite Profile: multi-session composite volume profile (Candle -> struct).
+#[wasm_bindgen(js_name = CompositeProfile)]
+pub struct WasmCompositeProfile {
+ inner: wc::CompositeProfile,
+}
+
+#[wasm_bindgen(js_class = CompositeProfile)]
+impl WasmCompositeProfile {
+ #[wasm_bindgen(constructor)]
+ pub fn new(
+ period: usize,
+ bin_count: usize,
+ value_area_pct: f64,
+ ) -> Result {
+ Ok(Self {
+ inner: wc::CompositeProfile::new(period, bin_count, value_area_pct).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 n = high.len();
+ let mut out = vec![f64::NAN; n * 3];
+ 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 * 3] = o.poc;
+ out[i * 3 + 1] = o.vah;
+ out[i * 3 + 2] = o.val;
+ }
+ }
+ Ok(Float64Array::from(out.as_slice()))
+ }
+ /// Streaming update. Returns `{ poc, vah, val }` 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, &"poc".into(), &o.poc.into()).ok();
+ Reflect::set(&obj, &"vah".into(), &o.vah.into()).ok();
+ Reflect::set(&obj, &"val".into(), &o.val.into()).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 = VolumeProfile)]
pub struct WasmVolumeProfile {
inner: wc::VolumeProfile,
diff --git a/crates/wickra-core/src/indicators/composite_profile.rs b/crates/wickra-core/src/indicators/composite_profile.rs
new file mode 100644
index 00000000..7290caab
--- /dev/null
+++ b/crates/wickra-core/src/indicators/composite_profile.rs
@@ -0,0 +1,347 @@
+//! Composite Profile — POC and value area over a long composite window.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Output of [`CompositeProfile`]: the point of control and the value-area bounds.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct CompositeProfileOutput {
+ /// Point of Control — the price (bin centre) with the most volume.
+ pub poc: f64,
+ /// Value-Area High — top of the band holding `value_area_pct` of volume.
+ pub vah: f64,
+ /// Value-Area Low — bottom of that band.
+ pub val: f64,
+}
+
+/// Composite Profile — a multi-session volume profile reduced to its **point of
+/// control** and **value area**, built over a long composite window.
+///
+/// ```text
+/// build a `bins`-bucket volume profile over the last `period` candles
+/// POC = bin with the most volume
+/// expand from the POC, always adding the heavier adjacent bin, until the
+/// accumulated volume reaches `value_area_pct` of the total
+/// VAH / VAL = the highest / lowest price included
+/// ```
+///
+/// A composite profile merges many sessions into one structure to reveal the
+/// dominant value area and control price across a longer horizon — the levels that
+/// matter for swing positioning rather than a single day. The point of control is
+/// the fairest price (heaviest trade); the value area (classically 70% of volume)
+/// brackets where the market spent most of its time. Price inside the value area is
+/// "in balance"; acceptance outside it signals a value migration.
+///
+/// The first value lands after `period` candles; each `update` rebuilds the
+/// profile in O(`period · bins`).
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, CompositeProfile};
+///
+/// let mut indicator = CompositeProfile::new(100, 50, 0.70).unwrap();
+/// let mut last = None;
+/// for i in 0..150 {
+/// let base = 100.0 + (f64::from(i) * 0.1).sin() * 8.0;
+/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
+/// last = indicator.update(c);
+/// }
+/// assert!(last.is_some());
+/// ```
+#[derive(Debug, Clone)]
+pub struct CompositeProfile {
+ period: usize,
+ bins: usize,
+ value_area_pct: f64,
+ window: VecDeque,
+ last: Option,
+}
+
+impl CompositeProfile {
+ /// Construct a Composite Profile.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero, or
+ /// [`Error::InvalidParameter`] if `value_area_pct` is not in `(0, 1]`.
+ pub fn new(period: usize, bins: usize, value_area_pct: f64) -> Result {
+ if period == 0 || bins == 0 {
+ return Err(Error::PeriodZero);
+ }
+ if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 {
+ return Err(Error::InvalidParameter {
+ message: "value_area_pct must be in (0, 1]",
+ });
+ }
+ Ok(Self {
+ period,
+ bins,
+ value_area_pct,
+ window: VecDeque::with_capacity(period),
+ last: None,
+ })
+ }
+
+ /// Configured `(period, bins, value_area_pct)`.
+ pub const fn params(&self) -> (usize, usize, f64) {
+ (self.period, self.bins, self.value_area_pct)
+ }
+
+ /// Current value if available.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+
+ #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
+ fn compute(&self) -> CompositeProfileOutput {
+ let mut low = f64::INFINITY;
+ let mut high = f64::NEG_INFINITY;
+ for c in &self.window {
+ low = low.min(c.low);
+ high = high.max(c.high);
+ }
+ let span = high - low;
+ if span <= 0.0 {
+ return CompositeProfileOutput {
+ poc: low,
+ vah: low,
+ val: low,
+ };
+ }
+ let width = span / self.bins as f64;
+ let centre = |idx: usize| low + (idx as f64 + 0.5) * width;
+ let mut hist = vec![0.0; self.bins];
+ for c in &self.window {
+ if c.volume == 0.0 {
+ continue;
+ }
+ let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
+ let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
+ let share = c.volume / (hi_idx - lo_idx + 1) as f64;
+ for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
+ *bin += share;
+ }
+ }
+ let total: f64 = hist.iter().sum();
+ let mut poc = 0;
+ let mut poc_vol = f64::NEG_INFINITY;
+ for (idx, &vol) in hist.iter().enumerate() {
+ if vol > poc_vol {
+ poc_vol = vol;
+ poc = idx;
+ }
+ }
+ let target = total * self.value_area_pct;
+ let mut acc = hist[poc];
+ let mut top = poc;
+ let mut bottom = poc;
+ while acc < target && (top < self.bins - 1 || bottom > 0) {
+ let above = if top < self.bins - 1 {
+ hist[top + 1]
+ } else {
+ f64::NEG_INFINITY
+ };
+ let below = if bottom > 0 {
+ hist[bottom - 1]
+ } else {
+ f64::NEG_INFINITY
+ };
+ if above >= below {
+ top += 1;
+ acc += hist[top];
+ } else {
+ bottom -= 1;
+ acc += hist[bottom];
+ }
+ }
+ CompositeProfileOutput {
+ poc: centre(poc),
+ vah: centre(top),
+ val: centre(bottom),
+ }
+ }
+}
+
+impl Indicator for CompositeProfile {
+ type Input = Candle;
+ type Output = CompositeProfileOutput;
+
+ 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);
+ 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 {
+ "CompositeProfile"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+
+ fn c(high: f64, low: f64, volume: f64) -> Candle {
+ Candle::new_unchecked(
+ f64::midpoint(high, low),
+ high,
+ low,
+ f64::midpoint(high, low),
+ volume,
+ 0,
+ )
+ }
+
+ #[test]
+ fn rejects_invalid_params() {
+ assert!(matches!(
+ CompositeProfile::new(0, 50, 0.7),
+ Err(Error::PeriodZero)
+ ));
+ assert!(matches!(
+ CompositeProfile::new(100, 0, 0.7),
+ Err(Error::PeriodZero)
+ ));
+ assert!(matches!(
+ CompositeProfile::new(100, 50, 0.0),
+ Err(Error::InvalidParameter { .. })
+ ));
+ assert!(matches!(
+ CompositeProfile::new(100, 50, 1.5),
+ Err(Error::InvalidParameter { .. })
+ ));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let p = CompositeProfile::new(100, 50, 0.7).unwrap();
+ assert_eq!(p.params(), (100, 50, 0.7));
+ assert_eq!(p.warmup_period(), 100);
+ assert_eq!(p.name(), "CompositeProfile");
+ assert!(!p.is_ready());
+ assert_eq!(p.value(), None);
+ }
+
+ #[test]
+ fn first_emission_at_warmup_period() {
+ let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
+ let candles: Vec = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
+ let out = p.batch(&candles);
+ for v in out.iter().take(3) {
+ assert!(v.is_none());
+ }
+ assert!(out[3].is_some());
+ }
+
+ #[test]
+ fn value_area_brackets_poc() {
+ let mut p = CompositeProfile::new(20, 30, 0.7).unwrap();
+ let candles: Vec = (0..40)
+ .map(|i| {
+ c(
+ 110.0 + (f64::from(i) * 0.3).sin() * 8.0,
+ 90.0 + (f64::from(i) * 0.3).cos() * 8.0,
+ 1_000.0,
+ )
+ })
+ .collect();
+ for o in p.batch(&candles).into_iter().flatten() {
+ assert!(o.val <= o.poc && o.poc <= o.vah);
+ }
+ }
+
+ #[test]
+ fn poc_at_heavy_cluster() {
+ // Volume clustered at ~100; thin pokes elsewhere -> POC near 100.
+ let mut p = CompositeProfile::new(6, 30, 0.7).unwrap();
+ let mut candles: Vec = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
+ candles.push(c(140.0, 60.0, 50.0));
+ let out = p.batch(&candles).into_iter().flatten().last().unwrap();
+ assert!(
+ (out.poc - 100.0).abs() < 5.0,
+ "POC should sit at the cluster, got {}",
+ out.poc
+ );
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
+ p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
+ assert!(p.is_ready());
+ p.reset();
+ assert!(!p.is_ready());
+ assert_eq!(p.value(), None);
+ assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..120)
+ .map(|i| {
+ c(
+ 110.0 + (f64::from(i) * 0.25).sin() * 9.0,
+ 90.0,
+ 1_000.0 + f64::from(i),
+ )
+ })
+ .collect();
+ let batch = CompositeProfile::new(50, 50, 0.7).unwrap().batch(&candles);
+ let mut b = CompositeProfile::new(50, 50, 0.7).unwrap();
+ let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
+ assert_eq!(batch, streamed);
+ }
+
+ #[test]
+ fn flat_window_collapses_to_price() {
+ // Zero high-low span returns the price for POC, VAH and VAL.
+ let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
+ cp.update(c(50.0, 50.0, 10.0));
+ let out = cp.update(c(50.0, 50.0, 10.0)).unwrap();
+ assert_eq!(out.poc, out.vah);
+ assert_eq!(out.poc, out.val);
+ }
+
+ #[test]
+ fn zero_volume_window_is_handled() {
+ // Non-flat window of zero-volume candles hits the skip path.
+ let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
+ cp.update(c(60.0, 40.0, 0.0));
+ assert!(cp.update(c(60.0, 40.0, 0.0)).is_some());
+ }
+
+ #[test]
+ fn value_area_expands_down_from_top_poc() {
+ // POC sits in the top bin; with a wide value-area target the area runs
+ // out of bins above (the ceiling branch) and keeps expanding downward.
+ let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap();
+ cp.update(c(100.0, 0.0, 30.0)); // thin spread across all three bins
+ let out = cp.update(c(100.0, 67.0, 60.0)).unwrap(); // heavy in the top bin
+ assert!(out.val <= out.poc && out.poc <= out.vah);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/high_low_volume_nodes.rs b/crates/wickra-core/src/indicators/high_low_volume_nodes.rs
new file mode 100644
index 00000000..72cd2961
--- /dev/null
+++ b/crates/wickra-core/src/indicators/high_low_volume_nodes.rs
@@ -0,0 +1,307 @@
+//! High/Low Volume Nodes (HVN / LVN) — the busiest and quietest price levels.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Output of [`HighLowVolumeNodes`]: the price of the highest- and lowest-volume
+/// node in the profile.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct HighLowVolumeNodesOutput {
+ /// High Volume Node — the price level (bin centre) with the most volume.
+ pub hvn: f64,
+ /// Low Volume Node — the traded price level with the least volume.
+ pub lvn: f64,
+}
+
+/// High/Low Volume Nodes — the price levels of greatest and least acceptance in a
+/// rolling volume profile.
+///
+/// ```text
+/// build a `bins`-bucket volume profile over the last `period` candles
+/// HVN = bin centre of the bucket with the most volume
+/// LVN = bin centre of the traded bucket with the least volume
+/// ```
+///
+/// A volume profile reveals where the market spent the most effort. A **High Volume
+/// Node** (HVN) is a price the market accepted and traded heavily — it acts as a
+/// magnet and as strong support/resistance. A **Low Volume Node** (LVN) is a price
+/// the market rejected quickly — moves tend to accelerate through LVNs and they
+/// often mark the edges between balance areas. Each candle's volume is spread
+/// across the price bins its high-low range spans (as in
+/// [`VolumeProfile`](crate::VolumeProfile)).
+///
+/// The first value lands after `period` candles; each `update` rebuilds the profile
+/// in O(`period · bins`). A degenerate flat window puts both nodes at the price.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, HighLowVolumeNodes};
+///
+/// let mut indicator = HighLowVolumeNodes::new(20, 24).unwrap();
+/// let mut last = None;
+/// for i in 0..40 {
+/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
+/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
+/// last = indicator.update(c);
+/// }
+/// assert!(last.is_some());
+/// ```
+#[derive(Debug, Clone)]
+pub struct HighLowVolumeNodes {
+ period: usize,
+ bins: usize,
+ window: VecDeque,
+ last: Option,
+}
+
+impl HighLowVolumeNodes {
+ /// Construct a High/Low Volume Nodes indicator.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero.
+ pub fn new(period: usize, bins: usize) -> Result {
+ if period == 0 || bins == 0 {
+ return Err(Error::PeriodZero);
+ }
+ Ok(Self {
+ period,
+ bins,
+ window: VecDeque::with_capacity(period),
+ last: None,
+ })
+ }
+
+ /// Configured `(period, bins)`.
+ pub const fn params(&self) -> (usize, usize) {
+ (self.period, self.bins)
+ }
+
+ /// Current value if available.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+
+ /// Build the volume histogram; returns `(low, bin_width, bins)`.
+ #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
+ fn profile(&self) -> (f64, f64, Vec) {
+ let mut low = f64::INFINITY;
+ let mut high = f64::NEG_INFINITY;
+ for c in &self.window {
+ low = low.min(c.low);
+ high = high.max(c.high);
+ }
+ let mut hist = vec![0.0; self.bins];
+ let span = high - low;
+ if span <= 0.0 {
+ hist[0] = self.window.iter().map(|c| c.volume).sum();
+ return (low, 0.0, hist);
+ }
+ let width = span / self.bins as f64;
+ for c in &self.window {
+ if c.volume == 0.0 {
+ continue;
+ }
+ let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
+ let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
+ let touched = hi_idx - lo_idx + 1;
+ let share = c.volume / touched as f64;
+ for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
+ *bin += share;
+ }
+ }
+ (low, width, hist)
+ }
+}
+
+impl Indicator for HighLowVolumeNodes {
+ type Input = Candle;
+ type Output = HighLowVolumeNodesOutput;
+
+ #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
+ 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 (low, width, hist) = self.profile();
+ let centre = |idx: usize| low + (idx as f64 + 0.5) * width;
+
+ let mut hvn_idx = 0;
+ let mut hvn_vol = f64::NEG_INFINITY;
+ let mut lvn_idx = 0;
+ let mut lvn_vol = f64::INFINITY;
+ for (idx, &vol) in hist.iter().enumerate() {
+ if vol > hvn_vol {
+ hvn_vol = vol;
+ hvn_idx = idx;
+ }
+ if vol > 0.0 && vol < lvn_vol {
+ lvn_vol = vol;
+ lvn_idx = idx;
+ }
+ }
+ // If no traded bin was found (all zero volume), both default to bin 0.
+ if !lvn_vol.is_finite() {
+ lvn_idx = hvn_idx;
+ }
+ let out = HighLowVolumeNodesOutput {
+ hvn: centre(hvn_idx),
+ lvn: centre(lvn_idx),
+ };
+ self.last = Some(out);
+ 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 {
+ "HighLowVolumeNodes"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+
+ fn c(high: f64, low: f64, volume: f64) -> Candle {
+ Candle::new_unchecked(
+ f64::midpoint(high, low),
+ high,
+ low,
+ f64::midpoint(high, low),
+ volume,
+ 0,
+ )
+ }
+
+ #[test]
+ fn rejects_zero_params() {
+ assert!(matches!(
+ HighLowVolumeNodes::new(0, 24),
+ Err(Error::PeriodZero)
+ ));
+ assert!(matches!(
+ HighLowVolumeNodes::new(20, 0),
+ Err(Error::PeriodZero)
+ ));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let h = HighLowVolumeNodes::new(20, 24).unwrap();
+ assert_eq!(h.params(), (20, 24));
+ assert_eq!(h.warmup_period(), 20);
+ assert_eq!(h.name(), "HighLowVolumeNodes");
+ assert!(!h.is_ready());
+ assert_eq!(h.value(), None);
+ }
+
+ #[test]
+ fn first_emission_at_warmup_period() {
+ let mut h = HighLowVolumeNodes::new(4, 8).unwrap();
+ let candles: Vec = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
+ let out = h.batch(&candles);
+ for v in out.iter().take(3) {
+ assert!(v.is_none());
+ }
+ assert!(out[3].is_some());
+ }
+
+ #[test]
+ fn hvn_at_heavy_price() {
+ // Most bars cluster at ~100 (heavy volume); one bar pokes up to 120 lightly.
+ let mut h = HighLowVolumeNodes::new(6, 24).unwrap();
+ let mut candles: Vec = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
+ candles.push(c(121.0, 119.0, 100.0));
+ let out = h.batch(&candles).into_iter().flatten().last().unwrap();
+ // HVN should sit near the heavy 100 cluster, well below the light 120 poke.
+ assert!(
+ out.hvn < 110.0,
+ "HVN should be at the heavy cluster, got {}",
+ out.hvn
+ );
+ assert!(out.lvn >= out.hvn - 1e9); // lvn is a valid level
+ }
+
+ #[test]
+ fn hvn_at_or_above_low() {
+ let mut h = HighLowVolumeNodes::new(10, 24).unwrap();
+ let candles: Vec = (0..30)
+ .map(|i| {
+ c(
+ 110.0 + (f64::from(i) * 0.3).sin() * 5.0,
+ 90.0,
+ 1_000.0 + f64::from(i),
+ )
+ })
+ .collect();
+ for o in h.batch(&candles).into_iter().flatten() {
+ assert!(o.hvn.is_finite() && o.lvn.is_finite());
+ }
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut h = HighLowVolumeNodes::new(4, 8).unwrap();
+ h.batch(&[c(110.0, 90.0, 1_000.0); 6]);
+ assert!(h.is_ready());
+ h.reset();
+ assert!(!h.is_ready());
+ assert_eq!(h.value(), None);
+ assert_eq!(h.update(c(110.0, 90.0, 1_000.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..80)
+ .map(|i| {
+ c(
+ 110.0 + (f64::from(i) * 0.25).sin() * 9.0,
+ 90.0,
+ 1_000.0 + f64::from(i),
+ )
+ })
+ .collect();
+ let batch = HighLowVolumeNodes::new(20, 24).unwrap().batch(&candles);
+ let mut b = HighLowVolumeNodes::new(20, 24).unwrap();
+ let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
+ assert_eq!(batch, streamed);
+ }
+
+ #[test]
+ fn flat_window_is_handled() {
+ // Zero high-low span dumps all volume into bin 0 and returns early.
+ let mut h = HighLowVolumeNodes::new(2, 4).unwrap();
+ h.update(c(50.0, 50.0, 10.0));
+ assert!(h.update(c(50.0, 50.0, 10.0)).is_some());
+ }
+
+ #[test]
+ fn zero_volume_window_falls_back() {
+ // All-zero volume leaves no traded bin; the LVN falls back to the HVN.
+ let mut h = HighLowVolumeNodes::new(2, 4).unwrap();
+ h.update(c(60.0, 40.0, 0.0));
+ let out = h.update(c(60.0, 40.0, 0.0)).unwrap();
+ assert_eq!(out.hvn, out.lvn);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs
index 0b5af8dd..2261b1bc 100644
--- a/crates/wickra-core/src/indicators/mod.rs
+++ b/crates/wickra-core/src/indicators/mod.rs
@@ -84,6 +84,7 @@ mod cmf;
mod cmo;
mod coefficient_of_variation;
mod cointegration;
+mod composite_profile;
mod concealing_baby_swallow;
mod conditional_value_at_risk;
mod connors_rsi;
@@ -182,6 +183,7 @@ mod heikin_ashi;
mod heikin_ashi_oscillator;
mod high_low_index;
mod high_low_range;
+mod high_low_volume_nodes;
mod high_wave;
mod highpass_filter;
mod hikkake;
@@ -269,6 +271,7 @@ mod mom;
mod morning_doji_star;
mod morning_evening_star;
mod murrey_math_lines;
+mod naked_poc;
mod natr;
mod new_highs_new_lows;
mod new_price_lines;
@@ -311,6 +314,7 @@ mod point_and_figure_bars;
mod polarized_fractal_efficiency;
mod ppo;
mod ppo_histogram;
+mod profile_shape;
mod profit_factor;
mod projection_bands;
mod projection_oscillator;
@@ -366,6 +370,7 @@ mod short_line;
mod signed_volume;
mod sine_wave;
mod sine_weighted_ma;
+mod single_prints;
mod skewness;
mod sma;
mod smi;
@@ -577,6 +582,7 @@ pub use cmf::ChaikinMoneyFlow;
pub use cmo::Cmo;
pub use coefficient_of_variation::CoefficientOfVariation;
pub use cointegration::{Cointegration, CointegrationOutput};
+pub use composite_profile::{CompositeProfile, CompositeProfileOutput};
pub use concealing_baby_swallow::ConcealingBabySwallow;
pub use conditional_value_at_risk::ConditionalValueAtRisk;
pub use connors_rsi::ConnorsRsi;
@@ -675,6 +681,7 @@ pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
pub use heikin_ashi_oscillator::HeikinAshiOscillator;
pub use high_low_index::HighLowIndex;
pub use high_low_range::HighLowRange;
+pub use high_low_volume_nodes::{HighLowVolumeNodes, HighLowVolumeNodesOutput};
pub use high_wave::HighWave;
pub use highpass_filter::HighpassFilter;
pub use hikkake::Hikkake;
@@ -762,6 +769,7 @@ pub use mom::Mom;
pub use morning_doji_star::MorningDojiStar;
pub use morning_evening_star::MorningEveningStar;
pub use murrey_math_lines::{MurreyMathLines, MurreyMathLinesOutput};
+pub use naked_poc::NakedPoc;
pub use natr::Natr;
pub use new_highs_new_lows::NewHighsNewLows;
pub use new_price_lines::NewPriceLines;
@@ -804,6 +812,7 @@ pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
pub use ppo::Ppo;
pub use ppo_histogram::PpoHistogram;
+pub use profile_shape::ProfileShape;
pub use profit_factor::ProfitFactor;
pub use projection_bands::{ProjectionBands, ProjectionBandsOutput};
pub use projection_oscillator::ProjectionOscillator;
@@ -859,6 +868,7 @@ pub use short_line::ShortLine;
pub use signed_volume::SignedVolume;
pub use sine_wave::SineWave;
pub use sine_weighted_ma::SineWeightedMa;
+pub use single_prints::SinglePrints;
pub use skewness::Skewness;
pub use sma::Sma;
pub use smi::Smi;
@@ -1503,6 +1513,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"OpeningRange",
"VolumeProfile",
"TpoProfile",
+ "NakedPoc",
+ "SinglePrints",
+ "ProfileShape",
+ "HighLowVolumeNodes",
+ "CompositeProfile",
],
),
(
@@ -1639,6 +1654,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, 493, "FAMILIES total drifted from indicator count");
+ assert_eq!(total, 498, "FAMILIES total drifted from indicator count");
}
}
diff --git a/crates/wickra-core/src/indicators/naked_poc.rs b/crates/wickra-core/src/indicators/naked_poc.rs
new file mode 100644
index 00000000..58b0e740
--- /dev/null
+++ b/crates/wickra-core/src/indicators/naked_poc.rs
@@ -0,0 +1,318 @@
+//! Naked POC — the nearest prior-session point of control price has not yet revisited.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Naked (Virgin) POC — the nearest **untested** point of control from a prior
+/// session: a heavily-traded price the market has not traded back through since.
+///
+/// ```text
+/// every `session_len` candles forms a session; its POC (heaviest-volume price) is
+/// recorded as "naked"
+/// a naked POC becomes "tested" once a later candle's high-low range covers it
+/// output = the nearest still-naked POC to the current close (or the close itself
+/// if every prior POC has been revisited)
+/// ```
+///
+/// A point of control is a magnet — price tends to return to fair value. A *naked*
+/// (or virgin) POC is one that has not yet been revisited, so it carries an
+/// outstanding "pull": untested POCs are high-probability targets and
+/// support/resistance on the approach. This indicator records each completed
+/// session's POC, marks them tested as price trades through them, and reports the
+/// closest one still outstanding.
+///
+/// The first value lands after `session_len` candles (the first session's POC).
+/// Each `update` is O(`session_len · bins` + naked-count).
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, NakedPoc};
+///
+/// let mut indicator = NakedPoc::new(20, 24).unwrap();
+/// let mut last = None;
+/// for i in 0..60 {
+/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
+/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
+/// last = indicator.update(c);
+/// }
+/// assert!(last.is_some());
+/// ```
+#[derive(Debug, Clone)]
+pub struct NakedPoc {
+ session_len: usize,
+ bins: usize,
+ session: VecDeque,
+ naked: Vec,
+ last_close: f64,
+ ready: bool,
+ last: Option,
+}
+
+impl NakedPoc {
+ /// Construct a Naked POC tracker with the given `session_len` and profile
+ /// `bins`.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::PeriodZero`] if `session_len` or `bins` is zero.
+ pub fn new(session_len: usize, bins: usize) -> Result {
+ if session_len == 0 || bins == 0 {
+ return Err(Error::PeriodZero);
+ }
+ Ok(Self {
+ session_len,
+ bins,
+ session: VecDeque::with_capacity(session_len),
+ naked: Vec::new(),
+ last_close: 0.0,
+ ready: false,
+ last: None,
+ })
+ }
+
+ /// Configured `(session_len, bins)`.
+ pub const fn params(&self) -> (usize, usize) {
+ (self.session_len, self.bins)
+ }
+
+ /// Number of currently-naked POCs.
+ pub fn naked_count(&self) -> usize {
+ self.naked.len()
+ }
+
+ /// Current value if available.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+
+ #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
+ fn session_poc(&self) -> f64 {
+ let mut low = f64::INFINITY;
+ let mut high = f64::NEG_INFINITY;
+ for c in &self.session {
+ low = low.min(c.low);
+ high = high.max(c.high);
+ }
+ let span = high - low;
+ if span <= 0.0 {
+ return low;
+ }
+ let width = span / self.bins as f64;
+ let mut hist = vec![0.0; self.bins];
+ for c in &self.session {
+ if c.volume == 0.0 {
+ continue;
+ }
+ let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
+ let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
+ let share = c.volume / (hi_idx - lo_idx + 1) as f64;
+ for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
+ *bin += share;
+ }
+ }
+ let mut poc = 0;
+ let mut poc_vol = f64::NEG_INFINITY;
+ for (idx, &vol) in hist.iter().enumerate() {
+ if vol > poc_vol {
+ poc_vol = vol;
+ poc = idx;
+ }
+ }
+ low + (poc as f64 + 0.5) * width
+ }
+}
+
+impl Indicator for NakedPoc {
+ type Input = Candle;
+ type Output = f64;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ // Test outstanding naked POCs against this candle's range.
+ self.naked
+ .retain(|&poc| !(candle.low <= poc && poc <= candle.high));
+ self.last_close = candle.close;
+
+ // Accumulate the session; finalize a POC at the boundary.
+ self.session.push_back(candle);
+ if self.session.len() == self.session_len {
+ let poc = self.session_poc();
+ self.naked.push(poc);
+ self.session.clear();
+ self.ready = true;
+ }
+
+ if !self.ready {
+ return None;
+ }
+ let nearest = self
+ .naked
+ .iter()
+ .copied()
+ .min_by(|a, b| {
+ (a - self.last_close)
+ .abs()
+ .total_cmp(&(b - self.last_close).abs())
+ })
+ .unwrap_or(self.last_close);
+ self.last = Some(nearest);
+ Some(nearest)
+ }
+
+ fn reset(&mut self) {
+ self.session.clear();
+ self.naked.clear();
+ self.last_close = 0.0;
+ self.ready = false;
+ self.last = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ self.session_len
+ }
+
+ fn is_ready(&self) -> bool {
+ self.last.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "NakedPoc"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+
+ fn c(high: f64, low: f64, close: f64, volume: f64) -> Candle {
+ Candle::new_unchecked(f64::midpoint(high, low), high, low, close, volume, 0)
+ }
+
+ #[test]
+ fn rejects_zero_params() {
+ assert!(matches!(NakedPoc::new(0, 24), Err(Error::PeriodZero)));
+ assert!(matches!(NakedPoc::new(20, 0), Err(Error::PeriodZero)));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let n = NakedPoc::new(20, 24).unwrap();
+ assert_eq!(n.params(), (20, 24));
+ assert_eq!(n.naked_count(), 0);
+ assert_eq!(n.warmup_period(), 20);
+ assert_eq!(n.name(), "NakedPoc");
+ assert!(!n.is_ready());
+ assert_eq!(n.value(), None);
+ }
+
+ #[test]
+ fn first_emission_at_session_end() {
+ let mut n = NakedPoc::new(4, 8).unwrap();
+ let candles: Vec = (0..6).map(|_| c(101.0, 99.0, 100.0, 1_000.0)).collect();
+ let out = n.batch(&candles);
+ for v in out.iter().take(3) {
+ assert!(v.is_none());
+ }
+ assert!(out[3].is_some());
+ }
+
+ #[test]
+ fn records_session_poc() {
+ let mut n = NakedPoc::new(4, 16).unwrap();
+ // A session clustered around 100 -> POC near 100.
+ n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
+ assert_eq!(n.naked_count(), 1);
+ let poc = n.value().unwrap();
+ assert!(
+ (poc - 100.0).abs() < 2.0,
+ "POC should be near 100, got {poc}"
+ );
+ }
+
+ #[test]
+ fn revisit_marks_poc_tested() {
+ let mut n = NakedPoc::new(4, 16).unwrap();
+ // Session 1 around 100 -> naked POC ~100.
+ n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
+ assert_eq!(n.naked_count(), 1);
+ // Trade away at 120 (does not cover 100) -> still naked.
+ n.update(c(121.0, 119.0, 120.0, 1_000.0));
+ assert_eq!(n.naked_count(), 1);
+ // A candle whose range covers 100 -> POC tested -> removed.
+ n.update(c(121.0, 95.0, 100.0, 1_000.0));
+ assert_eq!(n.naked_count(), 0);
+ }
+
+ #[test]
+ fn empty_naked_reports_close() {
+ let mut n = NakedPoc::new(4, 16).unwrap();
+ n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
+ // Wipe the naked POC with a covering candle.
+ let out = n.update(c(121.0, 95.0, 117.0, 1_000.0)).unwrap();
+ assert_eq!(n.naked_count(), 0);
+ assert!(
+ (out - 117.0).abs() < 1e-9,
+ "with no naked POC, output is the close"
+ );
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut n = NakedPoc::new(4, 8).unwrap();
+ n.batch(&[c(101.0, 99.0, 100.0, 1_000.0); 6]);
+ assert!(n.is_ready());
+ n.reset();
+ assert!(!n.is_ready());
+ assert_eq!(n.value(), None);
+ assert_eq!(n.naked_count(), 0);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..80)
+ .map(|i| {
+ let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
+ c(b + 1.0, b - 1.0, b, 1_000.0 + f64::from(i))
+ })
+ .collect();
+ let batch = NakedPoc::new(20, 24).unwrap().batch(&candles);
+ let mut b = NakedPoc::new(20, 24).unwrap();
+ let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
+ assert_eq!(batch, streamed);
+ }
+
+ #[test]
+ fn flat_session_reports_price() {
+ // A session with zero high-low span returns the session price directly.
+ let mut n = NakedPoc::new(2, 4).unwrap();
+ n.update(c(50.0, 50.0, 50.0, 10.0));
+ assert_eq!(n.update(c(50.0, 50.0, 50.0, 10.0)), Some(50.0));
+ }
+
+ #[test]
+ fn zero_volume_session_is_handled() {
+ // Zero-volume candles are skipped in the histogram; a POC still emits.
+ let mut n = NakedPoc::new(2, 4).unwrap();
+ n.update(c(60.0, 40.0, 50.0, 0.0));
+ assert!(n.update(c(60.0, 40.0, 50.0, 0.0)).is_some());
+ }
+
+ #[test]
+ fn nearest_of_two_naked_pocs() {
+ // Two untouched POCs at distant prices accumulate; the one nearest the
+ // last close is reported (exercises the min-by comparison).
+ let mut n = NakedPoc::new(2, 4).unwrap();
+ n.update(c(11.0, 9.0, 10.0, 100.0));
+ n.update(c(11.0, 9.0, 10.0, 100.0)); // POC near 10
+ n.update(c(101.0, 99.0, 100.0, 100.0));
+ let v = n.update(c(101.0, 99.0, 100.0, 100.0)).unwrap(); // POC near 100
+ assert!(
+ v > 50.0,
+ "nearest to close 100 should be the upper POC, got {v}"
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/profile_shape.rs b/crates/wickra-core/src/indicators/profile_shape.rs
new file mode 100644
index 00000000..cb38f0a3
--- /dev/null
+++ b/crates/wickra-core/src/indicators/profile_shape.rs
@@ -0,0 +1,285 @@
+//! Profile Shape — classifies the volume profile as b-shape, P-shape, or D/normal.
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Profile Shape — classifies a rolling volume profile by where its point of
+/// control (POC) sits within the range: `b`, `P`, or `D` (normal).
+///
+/// ```text
+/// build a `bins`-bucket volume profile over the last `period` candles
+/// poc_idx = bin with the most volume
+/// +1 P-shape : POC in the upper third (heavy top, thin tail down) — short-covering / accumulation
+/// −1 b-shape : POC in the lower third (heavy bottom, thin tail up) — long-liquidation / distribution
+/// 0 D/normal: POC in the middle third (balanced bell)
+/// ```
+///
+/// Market Profile readers classify the day's shape by the location of the heaviest
+/// trading. A **P-shape** (control high, a thin tail beneath) typically marks
+/// short-covering or the start of accumulation; a **b-shape** (control low, thin
+/// tail above) marks long liquidation or distribution; a **D-shape** is a balanced,
+/// two-sided day. Reducing the profile to this three-way code gives a compact,
+/// streaming read of market posture.
+///
+/// The output is `+1` / `0` / `−1`. The first value lands after `period` candles;
+/// each `update` rebuilds the profile in O(`period · bins`).
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, ProfileShape};
+///
+/// let mut indicator = ProfileShape::new(20, 24).unwrap();
+/// let mut last = None;
+/// for i in 0..40 {
+/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
+/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
+/// last = indicator.update(c);
+/// }
+/// assert!(last.is_some());
+/// ```
+#[derive(Debug, Clone)]
+pub struct ProfileShape {
+ period: usize,
+ bins: usize,
+ window: VecDeque,
+ last: Option,
+}
+
+impl ProfileShape {
+ /// Construct a Profile Shape classifier.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::PeriodZero`] if `period` is zero, or
+ /// [`Error::InvalidPeriod`] if `bins < 3` (the three-way split needs three
+ /// zones).
+ pub fn new(period: usize, bins: usize) -> Result {
+ if period == 0 {
+ return Err(Error::PeriodZero);
+ }
+ if bins < 3 {
+ return Err(Error::InvalidPeriod {
+ message: "profile shape needs bins >= 3",
+ });
+ }
+ Ok(Self {
+ period,
+ bins,
+ window: VecDeque::with_capacity(period),
+ last: None,
+ })
+ }
+
+ /// Configured `(period, bins)`.
+ pub const fn params(&self) -> (usize, usize) {
+ (self.period, self.bins)
+ }
+
+ /// Current value if available.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+
+ #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
+ fn poc_index(&self) -> usize {
+ let mut low = f64::INFINITY;
+ let mut high = f64::NEG_INFINITY;
+ for c in &self.window {
+ low = low.min(c.low);
+ high = high.max(c.high);
+ }
+ let mut hist = vec![0.0; self.bins];
+ let span = high - low;
+ if span > 0.0 {
+ let width = span / self.bins as f64;
+ for c in &self.window {
+ if c.volume == 0.0 {
+ continue;
+ }
+ let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
+ let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
+ let share = c.volume / (hi_idx - lo_idx + 1) as f64;
+ for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
+ *bin += share;
+ }
+ }
+ }
+ let mut poc_idx = 0;
+ let mut poc_vol = f64::NEG_INFINITY;
+ for (idx, &vol) in hist.iter().enumerate() {
+ if vol > poc_vol {
+ poc_vol = vol;
+ poc_idx = idx;
+ }
+ }
+ poc_idx
+ }
+}
+
+impl Indicator for ProfileShape {
+ type Input = Candle;
+ type Output = f64;
+
+ 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 poc = self.poc_index();
+ let lower = self.bins / 3;
+ let upper = self.bins - self.bins / 3;
+ let shape = if poc >= upper {
+ 1.0
+ } else if poc < lower {
+ -1.0
+ } else {
+ 0.0
+ };
+ self.last = Some(shape);
+ Some(shape)
+ }
+
+ 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 {
+ "ProfileShape"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+
+ fn c(high: f64, low: f64, volume: f64) -> Candle {
+ Candle::new_unchecked(
+ f64::midpoint(high, low),
+ high,
+ low,
+ f64::midpoint(high, low),
+ volume,
+ 0,
+ )
+ }
+
+ #[test]
+ fn rejects_invalid_params() {
+ assert!(matches!(ProfileShape::new(0, 24), Err(Error::PeriodZero)));
+ assert!(matches!(
+ ProfileShape::new(20, 2),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let p = ProfileShape::new(20, 24).unwrap();
+ assert_eq!(p.params(), (20, 24));
+ assert_eq!(p.warmup_period(), 20);
+ assert_eq!(p.name(), "ProfileShape");
+ assert!(!p.is_ready());
+ assert_eq!(p.value(), None);
+ }
+
+ #[test]
+ fn first_emission_at_warmup_period() {
+ let mut p = ProfileShape::new(4, 9).unwrap();
+ let candles: Vec = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
+ let out = p.batch(&candles);
+ for v in out.iter().take(3) {
+ assert!(v.is_none());
+ }
+ assert!(out[3].is_some());
+ }
+
+ #[test]
+ fn heavy_top_is_p_shape() {
+ // Volume concentrated near the top of the range -> P-shape -> +1.
+ let mut p = ProfileShape::new(6, 9).unwrap();
+ let mut candles: Vec = (0..5).map(|_| c(119.0, 117.0, 5_000.0)).collect();
+ candles.push(c(119.0, 80.0, 50.0)); // a thin tail down to 80
+ let last = p.batch(&candles).into_iter().flatten().last().unwrap();
+ assert_eq!(last, 1.0);
+ }
+
+ #[test]
+ fn heavy_bottom_is_b_shape() {
+ let mut p = ProfileShape::new(6, 9).unwrap();
+ let mut candles: Vec = (0..5).map(|_| c(83.0, 81.0, 5_000.0)).collect();
+ candles.push(c(120.0, 81.0, 50.0)); // a thin tail up to 120
+ let last = p.batch(&candles).into_iter().flatten().last().unwrap();
+ assert_eq!(last, -1.0);
+ }
+
+ #[test]
+ fn balanced_is_d_shape() {
+ // Volume concentrated in the middle -> D/normal -> 0.
+ let mut p = ProfileShape::new(6, 9).unwrap();
+ let mut candles: Vec = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
+ candles.push(c(120.0, 80.0, 50.0)); // thin tails both ways, POC in the middle
+ let last = p.batch(&candles).into_iter().flatten().last().unwrap();
+ assert_eq!(last, 0.0);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut p = ProfileShape::new(4, 9).unwrap();
+ p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
+ assert!(p.is_ready());
+ p.reset();
+ assert!(!p.is_ready());
+ assert_eq!(p.value(), None);
+ assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..80)
+ .map(|i| {
+ c(
+ 110.0 + (f64::from(i) * 0.25).sin() * 9.0,
+ 90.0,
+ 1_000.0 + f64::from(i),
+ )
+ })
+ .collect();
+ let batch = ProfileShape::new(20, 24).unwrap().batch(&candles);
+ let mut b = ProfileShape::new(20, 24).unwrap();
+ let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
+ assert_eq!(batch, streamed);
+ }
+
+ #[test]
+ fn flat_window_is_handled() {
+ // Zero high-low span skips the histogram pass entirely.
+ let mut p = ProfileShape::new(2, 4).unwrap();
+ p.update(c(50.0, 50.0, 10.0));
+ assert!(p.update(c(50.0, 50.0, 10.0)).is_some());
+ }
+
+ #[test]
+ fn zero_volume_window_is_handled() {
+ // Non-flat window of zero-volume candles hits the skip path.
+ let mut p = ProfileShape::new(2, 4).unwrap();
+ p.update(c(60.0, 40.0, 0.0));
+ assert!(p.update(c(60.0, 40.0, 0.0)).is_some());
+ }
+}
diff --git a/crates/wickra-core/src/indicators/single_prints.rs b/crates/wickra-core/src/indicators/single_prints.rs
new file mode 100644
index 00000000..96510c96
--- /dev/null
+++ b/crates/wickra-core/src/indicators/single_prints.rs
@@ -0,0 +1,253 @@
+//! Single Prints — count of price levels touched by exactly one bar (low acceptance).
+
+use std::collections::VecDeque;
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Single Prints — the number of price levels (bins) in the rolling profile that
+/// were touched by **exactly one** bar, marking zones of low acceptance / fast
+/// movement.
+///
+/// ```text
+/// for each of `bins` price levels over the last `period` candles:
+/// touches = number of bars whose high-low range covers that level
+/// SinglePrints = count of levels with touches == 1
+/// ```
+///
+/// In Market Profile a "single print" is a price the market traded through so
+/// quickly that only one time-period printed there — a footprint of an aggressive,
+/// one-sided move with little two-way trade. Single prints often act as support or
+/// resistance on a retest (the imbalance gets "repaired") and mark the edges of
+/// rapid moves. Counting them per profile gives a streaming gauge of how much of
+/// the recent range was traversed without acceptance: a high count means a fast,
+/// trending, low-rotation market; a low count means a balanced, well-traded range.
+///
+/// The output is a non-negative count. The first value lands after `period`
+/// candles; each `update` rebuilds the touch histogram in O(`period · bins`).
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, SinglePrints};
+///
+/// let mut indicator = SinglePrints::new(20, 24).unwrap();
+/// let mut last = None;
+/// for i in 0..40 {
+/// let base = 100.0 + f64::from(i); // a one-directional ramp -> many single prints
+/// let c = Candle::new(base, base + 0.5, base - 0.5, base, 1_000.0, 0).unwrap();
+/// last = indicator.update(c);
+/// }
+/// assert!(last.is_some());
+/// ```
+#[derive(Debug, Clone)]
+pub struct SinglePrints {
+ period: usize,
+ bins: usize,
+ window: VecDeque,
+ last: Option,
+}
+
+impl SinglePrints {
+ /// Construct a Single Prints counter.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::PeriodZero`] if `period` or `bins` is zero.
+ pub fn new(period: usize, bins: usize) -> Result {
+ if period == 0 || bins == 0 {
+ return Err(Error::PeriodZero);
+ }
+ Ok(Self {
+ period,
+ bins,
+ window: VecDeque::with_capacity(period),
+ last: None,
+ })
+ }
+
+ /// Configured `(period, bins)`.
+ pub const fn params(&self) -> (usize, usize) {
+ (self.period, self.bins)
+ }
+
+ /// Current value if available.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+
+ #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
+ fn count_single_prints(&self) -> usize {
+ let mut low = f64::INFINITY;
+ let mut high = f64::NEG_INFINITY;
+ for c in &self.window {
+ low = low.min(c.low);
+ high = high.max(c.high);
+ }
+ let span = high - low;
+ if span <= 0.0 {
+ return 0;
+ }
+ let width = span / self.bins as f64;
+ let mut touches = vec![0u32; self.bins];
+ for c in &self.window {
+ let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
+ let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
+ for t in touches.iter_mut().take(hi_idx + 1).skip(lo_idx) {
+ *t += 1;
+ }
+ }
+ touches.iter().filter(|&&t| t == 1).count()
+ }
+}
+
+impl Indicator for SinglePrints {
+ type Input = Candle;
+ type Output = f64;
+
+ 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 count = self.count_single_prints() as f64;
+ self.last = Some(count);
+ Some(count)
+ }
+
+ 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 {
+ "SinglePrints"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+
+ fn c(high: f64, low: f64) -> Candle {
+ Candle::new_unchecked(
+ f64::midpoint(high, low),
+ high,
+ low,
+ f64::midpoint(high, low),
+ 1_000.0,
+ 0,
+ )
+ }
+
+ #[test]
+ fn rejects_zero_params() {
+ assert!(matches!(SinglePrints::new(0, 24), Err(Error::PeriodZero)));
+ assert!(matches!(SinglePrints::new(20, 0), Err(Error::PeriodZero)));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let s = SinglePrints::new(20, 24).unwrap();
+ assert_eq!(s.params(), (20, 24));
+ assert_eq!(s.warmup_period(), 20);
+ assert_eq!(s.name(), "SinglePrints");
+ assert!(!s.is_ready());
+ assert_eq!(s.value(), None);
+ }
+
+ #[test]
+ fn first_emission_at_warmup_period() {
+ let mut s = SinglePrints::new(4, 8).unwrap();
+ let candles: Vec = (0..6)
+ .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
+ .collect();
+ let out = s.batch(&candles);
+ for v in out.iter().take(3) {
+ assert!(v.is_none());
+ }
+ assert!(out[3].is_some());
+ }
+
+ #[test]
+ fn flat_range_has_no_single_prints() {
+ // Every bar covers the same single price -> zero span -> 0.
+ let mut s = SinglePrints::new(4, 8).unwrap();
+ let last = s
+ .batch(&[c(100.0, 100.0); 6])
+ .into_iter()
+ .flatten()
+ .last()
+ .unwrap();
+ assert_eq!(last, 0.0);
+ }
+
+ #[test]
+ fn ramp_has_many_single_prints() {
+ // A one-directional ramp visits most levels exactly once.
+ let mut s = SinglePrints::new(10, 24).unwrap();
+ let candles: Vec = (0..10)
+ .map(|i| c(100.5 + f64::from(i), 99.5 + f64::from(i)))
+ .collect();
+ let last = s.batch(&candles).into_iter().flatten().last().unwrap();
+ assert!(
+ last > 0.0,
+ "a ramp should produce single prints, got {last}"
+ );
+ }
+
+ #[test]
+ fn output_non_negative() {
+ let mut s = SinglePrints::new(14, 24).unwrap();
+ for v in s
+ .batch(
+ &(0..60)
+ .map(|i| c(110.0 + (f64::from(i) * 0.3).sin() * 8.0, 90.0))
+ .collect::>(),
+ )
+ .into_iter()
+ .flatten()
+ {
+ assert!(v >= 0.0);
+ }
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut s = SinglePrints::new(4, 8).unwrap();
+ s.batch(
+ &(0..6)
+ .map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
+ .collect::>(),
+ );
+ assert!(s.is_ready());
+ s.reset();
+ assert!(!s.is_ready());
+ assert_eq!(s.value(), None);
+ assert_eq!(s.update(c(101.0, 99.0)), None);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..80)
+ .map(|i| c(110.0 + (f64::from(i) * 0.25).sin() * 9.0, 90.0))
+ .collect();
+ let batch = SinglePrints::new(20, 24).unwrap().batch(&candles);
+ let mut b = SinglePrints::new(20, 24).unwrap();
+ let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
+ assert_eq!(batch, streamed);
+ }
+}
diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs
index 9f21e2e5..44a2e001 100644
--- a/crates/wickra-core/src/lib.rs
+++ b/crates/wickra-core/src/lib.rs
@@ -72,37 +72,38 @@ pub use indicators::{
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
- ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator,
- Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
- CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
- DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
- DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian,
- DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
- DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, DumplingTop,
- Dx, DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma,
- ElderImpulse, ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema,
- EmpiricalModeDecomposition, Engulfing, Equivolume, EquivolumeOutput, EstimatedLeverageRatio,
- EvenBetterSinewave, EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods,
- Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput,
- FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput,
- FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots,
- FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput,
- ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FryPanBottom, FundingBasis,
- FundingImpliedApr, FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio,
- GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley, GatorOscillator,
+ CompositeProfile, CompositeProfileOutput, ConcealingBabySwallow, ConditionalValueAtRisk,
+ ConnorsRsi, Coppock, CorrelationTrendIndicator, Counterattack, Crab, CumulativeVolumeDelta,
+ CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher, DayOfWeekProfile,
+ DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, DemarkPivots,
+ DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev, DisparityIndex,
+ DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput,
+ DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo,
+ DragonflyDoji, DrawdownDuration, DumplingTop, Dx, DynamicMomentumIndex, EaseOfMovement,
+ EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse, ElderRay, ElderRayOutput, ElderSafeZone,
+ ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition, Engulfing, Equivolume, EquivolumeOutput,
+ EstimatedLeverageRatio, EvenBetterSinewave, EveningDojiStar, Evwma, EwmaVolatility, Expectancy,
+ FallingThreeMethods, Fama, FibArcs, FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence,
+ FibConfluenceOutput, FibExtension, FibExtensionOutput, FibFan, FibFanOutput, FibProjection,
+ FibProjectionOutput, FibRetracement, FibRetracementOutput, FibTimeZones, FibTimeZonesOutput,
+ FibonacciPivots, FibonacciPivotsOutput, FisherRsi, FisherTransform, FlagPennant, Footprint,
+ FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FryPanBottom,
+ FundingBasis, FundingImpliedApr, FundingRate, FundingRateMean, FundingRateZScore,
+ GainLossRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley, GatorOscillator,
GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross,
HasbrouckInformationShare, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator,
- HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave, HighpassFilter, Hikkake,
- HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon,
- HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput,
- HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia,
- InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
- IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
- IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, JarqueBera, Jma,
- JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop,
- KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion,
- Keltner, KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo,
+ HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighLowVolumeNodes,
+ HighLowVolumeNodesOutput, HighWave, HighpassFilter, Hikkake, HikkakeModified,
+ HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase,
+ HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent,
+ Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio,
+ InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayIntensity,
+ IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
+ InverseFisherTransform, InvertedHammer, JarqueBera, Jma, JumpIndicator, KagiBars,
+ KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
+ KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
+ KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo,
KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput,
LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope,
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji,
@@ -112,7 +113,7 @@ pub use indicators::{
McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa,
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop,
ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, MurreyMathLines,
- MurreyMathLinesOutput, Natr, NewHighsNewLows, NewPriceLines, Nrtr, NrtrOutput, Nvi,
+ MurreyMathLinesOutput, NakedPoc, Natr, NewHighsNewLows, NewPriceLines, Nrtr, NrtrOutput, Nvi,
OIPriceDivergence, OIWeighted, Obv, OiToVolumeRatio, OmegaRatio, OnNeck, OpenInterestDelta,
OpenInterestMomentum, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
@@ -120,39 +121,39 @@ pub use indicators::{
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
PercentB, PercentageTrailingStop, PerpetualPremiumIndex, Pgo, PiercingDarkCloud, Pin,
PivotReversal, PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo,
- PpoHistogram, ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar,
- Pvi, Qqe, QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared,
- RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, Reflex, RegimeLabel,
- RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
- RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure,
- RollingCorrelation, RollingCovariance, RollingIqr, RollingMinMaxScaler, RollingPercentileRank,
- RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput,
- SampleEntropy, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput,
- SessionRange, SessionRangeOutput, SessionVwap, ShannonEntropy, Shark, SharpeRatio,
- ShootingStar, ShortLine, SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma,
- SmoothedHeikinAshi, SmoothedHeikinAshiOutput, SortinoRatio, SpearmanCorrelation, SpinningTop,
- SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
- StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
- StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
- StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
- TakerBuySellRatio, Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown,
- TdDWave, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdMovingAverage,
- TdMovingAverageOutput, TdOpen, TdPressure, TdPropulsion, TdRangeProjection,
- TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
- TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
- ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth,
- Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput,
- TowerTopBottom, TpoProfile, TpoProfileOutput, TradeImbalance, TradeSignAutocorrelation,
- TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex, TreynorRatio, Triangle, Trima,
- Trin, TripleTopBottom, Tristar, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
- TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice,
- UlcerIndex, UltimateOscillator, UniqueThreeRiver, UniversalOscillator, UpDownVolumeRatio,
- UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
- VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput,
- VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
- VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
- VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput, VolumeWeightedSr,
- VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
+ PpoHistogram, ProfileShape, ProfitFactor, ProjectionBands, ProjectionBandsOutput,
+ ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands, QuartileBandsOutput,
+ QuotedSpread, RSquared, RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange,
+ Reflex, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop,
+ RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility,
+ RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr, RollingMinMaxScaler,
+ RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi,
+ RviVolatility, Rwi, RwiOutput, SampleEntropy, SarExt, SeasonalZScore, SeparatingLines,
+ SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap,
+ ShannonEntropy, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
+ SineWeightedMa, SinglePrints, Skewness, Sma, Smi, Smma, SmoothedHeikinAshi,
+ SmoothedHeikinAshiOutput, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient,
+ SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
+ StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
+ StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticCci, StochasticOutput,
+ SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap,
+ TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential,
+ TdLines, TdLinesOutput, TdMovingAverage, TdMovingAverageOutput, TdOpen, TdPressure,
+ TdPropulsion, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
+ TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis,
+ ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows,
+ ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile,
+ TimeOfDayReturnProfileOutput, TowerTopBottom, TpoProfile, TpoProfileOutput, TradeImbalance,
+ TradeSignAutocorrelation, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex,
+ TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Tristar, Trix, TrueRange, Tsf,
+ TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer,
+ TwiggsMoneyFlow, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver,
+ UniversalOscillator, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
+ ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
+ VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility, VolatilityRatio, VoltyStop,
+ VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
+ VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput,
+ VolumeWeightedSr, VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
diff --git a/docs/README.md b/docs/README.md
index b31d700f..bd989b82 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 **493 indicators** across
+- A per-indicator deep dive for every one of the **498 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 08ca6c1b..3e2f21f1 100644
--- a/fuzz/fuzz_targets/indicator_update_candle.rs
+++ b/fuzz/fuzz_targets/indicator_update_candle.rs
@@ -22,7 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
-use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, AndrewsPitchfork, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, CandleVolume, Cci, CentralPivotRange, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, DumplingTop, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, Equivolume, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, FryPanBottom, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, MurreyMathLines, Natr, NewPriceLines, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PivotReversal, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SmoothedHeikinAshi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdMovingAverage, TdOpen, TdPressure, TdPropulsion, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, TdTrap, ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TowerTopBottom, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, Tristar, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, VolumeWeightedSr, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
+use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, AndrewsPitchfork, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, CandleVolume, Cci, CentralPivotRange, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, CompositeProfile, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, DumplingTop, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, Equivolume, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, FryPanBottom, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator, HiLoActivator, HighLowRange, HighLowVolumeNodes, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, MurreyMathLines, NakedPoc, Natr, NewPriceLines, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PivotReversal, PlusDi, PlusDm, ProfileShape, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, SinglePrints, Smi, SmoothedHeikinAshi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdMovingAverage, TdOpen, TdPressure, TdPropulsion, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, TdTrap, ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TowerTopBottom, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, Tristar, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, VolumeWeightedSr, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -188,6 +188,11 @@ fuzz_target!(|data: Vec| {
}
// --- Market Profile (multi-output) ---
+ drive(|| CompositeProfile::new(20, 24, 0.7).unwrap(), &candles);
+ drive(|| HighLowVolumeNodes::new(20, 24).unwrap(), &candles);
+ drive(|| NakedPoc::new(20, 24).unwrap(), &candles);
+ drive(|| SinglePrints::new(20, 24).unwrap(), &candles);
+ drive(|| ProfileShape::new(20, 24).unwrap(), &candles);
drive(|| ValueArea::new(20, 50, 0.70).unwrap(), &candles);
drive(|| VolumeProfile::new(20, 50).unwrap(), &candles);
drive(|| TpoProfile::new(20, 50).unwrap(), &candles);