feat: add Volume Profile and TPO Profile to the market profile family (#145)

Volume Profile exposes the full per-bin volume histogram (price bounds plus raw distribution) that Value Area reduces to POC/VAH/VAL. TPO Profile is the volume-agnostic Time-Price-Opportunity letter count over a rolling window. Both candle-input, Vec-output, Market Profile family, with custom Python/Node/WASM bindings, fuzz, benches, tests and docs. Indicator count 290 -> 292.
This commit is contained in:
kingchenc
2026-06-02 21:16:30 +02:00
committed by GitHub
parent 93097db482
commit f37eedd44e
17 changed files with 1263 additions and 21 deletions
@@ -1246,3 +1246,29 @@ test('basis rejects bad input', () => {
assert.throws(() => new wickra.TermStructureBasis().update(100, 0));
assert.throws(() => new wickra.CalendarSpread().update(100, 0));
});
test('VolumeProfile exposes the full histogram', () => {
// bar0 single-print at 10 vol 100; bar1 spans 10..14 vol 80 over 4 bins.
const vp = new wickra.VolumeProfile(2, 4);
assert.equal(vp.update(10, 10, 100), null);
const out = vp.update(14, 10, 80);
assert.ok(out !== null);
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
assert.deepEqual(out.bins.length, 4);
assert.ok(Math.abs(out.bins[0] - 120) < 1e-9);
for (let i = 1; i < 4; i++) {
assert.ok(Math.abs(out.bins[i] - 20) < 1e-9);
}
});
test('TpoProfile counts time at price, volume-agnostic', () => {
// bar0 spans 10..14 (+1 each bin); bar1 spans 11..12 (+1 bins 1,2).
const tpo = new wickra.TpoProfile(2, 4);
assert.equal(tpo.update(14, 10), null);
const out = tpo.update(12, 11);
assert.ok(out !== null);
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
assert.deepEqual(out.counts, [1, 2, 2, 1]);
});
+28
View File
@@ -270,6 +270,16 @@ export interface ValueAreaValue {
vah: number
val: number
}
export interface VolumeProfileValue {
priceLow: number
priceHigh: number
bins: Array<number>
}
export interface TpoProfileValue {
priceLow: number
priceHigh: number
counts: Array<number>
}
export interface InitialBalanceValue {
high: number
low: number
@@ -2064,6 +2074,24 @@ export declare class ValueArea {
update(high: number, low: number, volume: number): ValueAreaValue | null
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
}
export type VolumeProfileNode = VolumeProfile
export declare class VolumeProfile {
constructor(period: number, binCount: number)
reset(): void
isReady(): boolean
warmupPeriod(): number
update(high: number, low: number, volume: number): VolumeProfileValue | null
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
}
export type TpoProfileNode = TpoProfile
export declare class TpoProfile {
constructor(period: number, binCount: number)
reset(): void
isReady(): boolean
warmupPeriod(): number
update(high: number, low: number): TpoProfileValue | null
batch(high: Array<number>, low: Array<number>): Array<number>
}
export type InitialBalanceNode = InitialBalance
export declare class InitialBalance {
constructor(period: number)
+3 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -499,6 +499,8 @@ module.exports.FAMA = FAMA
module.exports.Ichimoku = Ichimoku
module.exports.HeikinAshi = HeikinAshi
module.exports.ValueArea = ValueArea
module.exports.VolumeProfile = VolumeProfile
module.exports.TpoProfile = TpoProfile
module.exports.InitialBalance = InitialBalance
module.exports.OpeningRange = OpeningRange
module.exports.Doji = Doji
+147
View File
@@ -8476,6 +8476,153 @@ impl ValueAreaNode {
}
}
// ============================== VolumeProfile ==============================
#[napi(object)]
pub struct VolumeProfileValue {
pub price_low: f64,
pub price_high: f64,
pub bins: Vec<f64>,
}
#[napi(js_name = "VolumeProfile")]
pub struct VolumeProfileNode {
inner: wc::VolumeProfile,
}
#[napi]
impl VolumeProfileNode {
#[napi(constructor)]
pub fn new(period: u32, bin_count: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::VolumeProfile::new(period as usize, bin_count as usize).map_err(map_err)?,
})
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi]
pub fn update(
&mut self,
high: f64,
low: f64,
volume: f64,
) -> napi::Result<Option<VolumeProfileValue>> {
let mid = f64::midpoint(high, low);
let candle = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
Ok(self.inner.update(candle).map(|o| VolumeProfileValue {
price_low: o.price_low,
price_high: o.price_high,
bins: o.bins,
}))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(NapiError::from_reason(
"high, low, volume must be equal length".to_string(),
));
}
let k = self.inner.params().1 + 2;
let n = high.len();
let mut out = vec![f64::NAN; n * k];
for i in 0..n {
let mid = f64::midpoint(high[i], low[i]);
let candle =
wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * k] = o.price_low;
out[i * k + 1] = o.price_high;
for (j, b) in o.bins.iter().enumerate() {
out[i * k + 2 + j] = *b;
}
}
}
Ok(out)
}
}
// ============================== TpoProfile ==============================
#[napi(object)]
pub struct TpoProfileValue {
pub price_low: f64,
pub price_high: f64,
pub counts: Vec<f64>,
}
#[napi(js_name = "TpoProfile")]
pub struct TpoProfileNode {
inner: wc::TpoProfile,
}
#[napi]
impl TpoProfileNode {
#[napi(constructor)]
pub fn new(period: u32, bin_count: u32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TpoProfile::new(period as usize, bin_count as usize).map_err(map_err)?,
})
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi]
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<TpoProfileValue>> {
let mid = f64::midpoint(high, low);
let candle = wc::Candle::new(mid, high, low, mid, 1.0, 0).map_err(map_err)?;
Ok(self.inner.update(candle).map(|o| TpoProfileValue {
price_low: o.price_low,
price_high: o.price_high,
counts: o.counts,
}))
}
#[napi]
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
if high.len() != low.len() {
return Err(NapiError::from_reason(
"high, low must be equal length".to_string(),
));
}
let k = self.inner.params().1 + 2;
let n = high.len();
let mut out = vec![f64::NAN; n * k];
for i in 0..n {
let mid = f64::midpoint(high[i], low[i]);
let candle = wc::Candle::new(mid, high[i], low[i], mid, 1.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * k] = o.price_low;
out[i * k + 1] = o.price_high;
for (j, count) in o.counts.iter().enumerate() {
out[i * k + 2 + j] = *count;
}
}
}
Ok(out)
}
}
// ============================== InitialBalance ==============================
#[napi(object)]
@@ -223,6 +223,8 @@ from ._wickra import (
HeikinAshi,
# Market Profile
ValueArea,
VolumeProfile,
TpoProfile,
InitialBalance,
OpeningRange,
# Candlestick patterns
@@ -536,6 +538,8 @@ __all__ = [
"HeikinAshi",
# Market Profile
"ValueArea",
"VolumeProfile",
"TpoProfile",
"InitialBalance",
"OpeningRange",
# Candlestick patterns
+179
View File
@@ -11326,6 +11326,183 @@ impl PyValueArea {
}
}
// ============================== VolumeProfile ==============================
/// Streaming profile output: `(price_low, price_high, per_bin_values)`, or `None`
/// during warmup. Shared by `VolumeProfile` (volume bins) and `TpoProfile` (TPO
/// counts).
type ProfileHistogram<'py> = Option<(f64, f64, Bound<'py, PyArray1<f64>>)>;
#[pyclass(name = "VolumeProfile", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyVolumeProfile {
inner: wc::VolumeProfile,
}
#[pymethods]
impl PyVolumeProfile {
#[new]
#[pyo3(signature = (period=20, bin_count=50))]
fn new(period: usize, bin_count: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::VolumeProfile::new(period, bin_count).map_err(map_err)?,
})
}
/// Streaming update. Returns `(price_low, price_high, bins)` once warm, else `None`.
fn update<'py>(
&mut self,
py: Python<'py>,
candle: &Bound<'_, PyAny>,
) -> PyResult<ProfileHistogram<'py>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.price_low, o.price_high, o.bins.into_pyarray(py))))
}
/// Batch over numpy columns high, low, volume. Returns shape `(n, bin_count + 2)`
/// with columns `[price_low, price_high, bin_0, ..., bin_{k-1}]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() || l.len() != v.len() {
return Err(PyValueError::new_err(
"high, low, volume must be equal length",
));
}
let k = self.inner.params().1 + 2;
let n = h.len();
let mut out = vec![f64::NAN; n * k];
for i in 0..n {
let mid = f64::midpoint(h[i], l[i]);
let candle = wc::Candle::new(mid, h[i], l[i], mid, v[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * k] = o.price_low;
out[i * k + 1] = o.price_high;
for (j, b) in o.bins.iter().enumerate() {
out[i * k + 2 + j] = *b;
}
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, usize) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (period, bin_count) = self.inner.params();
format!("VolumeProfile(period={period}, bin_count={bin_count})")
}
}
// ============================== TpoProfile ==============================
#[pyclass(name = "TpoProfile", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTpoProfile {
inner: wc::TpoProfile,
}
#[pymethods]
impl PyTpoProfile {
#[new]
#[pyo3(signature = (period=30, bin_count=50))]
fn new(period: usize, bin_count: usize) -> PyResult<Self> {
Ok(Self {
inner: wc::TpoProfile::new(period, bin_count).map_err(map_err)?,
})
}
/// Streaming update. Returns `(price_low, price_high, counts)` once warm, else `None`.
fn update<'py>(
&mut self,
py: Python<'py>,
candle: &Bound<'_, PyAny>,
) -> PyResult<ProfileHistogram<'py>> {
let c = extract_candle(candle)?;
Ok(self
.inner
.update(c)
.map(|o| (o.price_low, o.price_high, o.counts.into_pyarray(py))))
}
/// Batch over numpy columns high, low. Returns shape `(n, bin_count + 2)`
/// with columns `[price_low, price_high, count_0, ..., count_{k-1}]`; warmup rows are `NaN`.
fn batch<'py>(
&mut self,
py: Python<'py>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
if h.len() != l.len() {
return Err(PyValueError::new_err("high, low must be equal length"));
}
let k = self.inner.params().1 + 2;
let n = h.len();
let mut out = vec![f64::NAN; n * k];
for i in 0..n {
let mid = f64::midpoint(h[i], l[i]);
let candle = wc::Candle::new(mid, h[i], l[i], mid, 1.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(candle) {
out[i * k] = o.price_low;
out[i * k + 1] = o.price_high;
for (j, count) in o.counts.iter().enumerate() {
out[i * k + 2 + j] = *count;
}
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, usize) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (period, bin_count) = self.inner.params();
format!("TpoProfile(period={period}, bin_count={bin_count})")
}
}
// ============================== InitialBalance ==============================
#[pyclass(
@@ -14228,6 +14405,8 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyRelativeStrengthAB>()?;
m.add_class::<PySpearmanCorrelation>()?;
m.add_class::<PyValueArea>()?;
m.add_class::<PyVolumeProfile>()?;
m.add_class::<PyTpoProfile>()?;
m.add_class::<PyInitialBalance>()?;
m.add_class::<PyOpeningRange>()?;
// Candlestick patterns.
@@ -1010,6 +1010,64 @@ def test_opening_range_shape_and_streaming(ohlcv):
assert _eq_nan(batch, np.array(rows, dtype=np.float64))
def test_volume_profile_reference():
# bar0 single-print at 10 vol 100; bar1 spans 10..14 vol 80 over 4 bins.
vp = ta.VolumeProfile(2, 4)
assert vp.update((10.0, 10.0, 10.0, 10.0, 100.0, 0)) is None
out = vp.update((10.0, 14.0, 10.0, 12.0, 80.0, 1))
assert out is not None
price_low, price_high, bins = out
assert price_low == pytest.approx(10.0)
assert price_high == pytest.approx(14.0)
np.testing.assert_allclose(bins, [120.0, 20.0, 20.0, 20.0])
def test_volume_profile_streaming_matches_batch(ohlcv):
high, low, close, volume = ohlcv
batch = ta.VolumeProfile(10, 8).batch(high, low, volume)
assert batch.shape == (high.size, 10)
streamer = ta.VolumeProfile(10, 8)
for i in range(high.size):
mid = float((high[i] + low[i]) / 2)
out = streamer.update((mid, float(high[i]), float(low[i]), mid, float(volume[i]), i))
if out is None:
assert np.isnan(batch[i]).all()
else:
pl, ph, bins = out
assert pl == pytest.approx(batch[i][0])
assert ph == pytest.approx(batch[i][1])
np.testing.assert_allclose(bins, batch[i][2:])
def test_tpo_profile_reference():
# bar0 spans 10..14 (+1 to all 4 bins); bar1 spans 11..12 (+1 to bins 1,2).
tpo = ta.TpoProfile(2, 4)
assert tpo.update((12.0, 14.0, 10.0, 12.0, 5.0, 0)) is None
out = tpo.update((11.5, 12.0, 11.0, 11.5, 999.0, 1))
assert out is not None
price_low, price_high, counts = out
assert price_low == pytest.approx(10.0)
assert price_high == pytest.approx(14.0)
np.testing.assert_allclose(counts, [1.0, 2.0, 2.0, 1.0])
def test_tpo_profile_streaming_matches_batch(ohlcv):
high, low, close, volume = ohlcv
batch = ta.TpoProfile(10, 8).batch(high, low)
assert batch.shape == (high.size, 10)
streamer = ta.TpoProfile(10, 8)
for i in range(high.size):
mid = float((high[i] + low[i]) / 2)
out = streamer.update((mid, float(high[i]), float(low[i]), mid, 1.0, i))
if out is None:
assert np.isnan(batch[i]).all()
else:
pl, ph, counts = out
assert pl == pytest.approx(batch[i][0])
assert ph == pytest.approx(batch[i][1])
np.testing.assert_allclose(counts, batch[i][2:])
# --- TD Pressure (OHLCV-input) -------------------------------------------
+129
View File
@@ -6070,6 +6070,135 @@ impl WasmValueArea {
}
}
#[wasm_bindgen(js_name = VolumeProfile)]
pub struct WasmVolumeProfile {
inner: wc::VolumeProfile,
}
#[wasm_bindgen(js_class = VolumeProfile)]
impl WasmVolumeProfile {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, bin_count: usize) -> Result<WasmVolumeProfile, JsError> {
Ok(Self {
inner: wc::VolumeProfile::new(period, bin_count).map_err(map_err)?,
})
}
pub fn batch(
&mut self,
high: &[f64],
low: &[f64],
volume: &[f64],
) -> Result<Float64Array, JsError> {
if high.len() != low.len() || low.len() != volume.len() {
return Err(JsError::new("high, low, volume must be equal length"));
}
let k = self.inner.params().1 + 2;
let n = high.len();
let mut out = vec![f64::NAN; n * k];
for i in 0..n {
let mid = f64::midpoint(high[i], low[i]);
let c = wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
if let Some(o) = self.inner.update(c) {
out[i * k] = o.price_low;
out[i * k + 1] = o.price_high;
for (j, b) in o.bins.iter().enumerate() {
out[i * k + 2 + j] = *b;
}
}
}
Ok(Float64Array::from(out.as_slice()))
}
/// Streaming update. Returns `{ priceLow, priceHigh, bins }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<JsValue, JsError> {
let mid = f64::midpoint(high, low);
let c = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"priceLow".into(), &o.price_low.into()).ok();
Reflect::set(&obj, &"priceHigh".into(), &o.price_high.into()).ok();
let bins = Float64Array::from(o.bins.as_slice());
Reflect::set(&obj, &"bins".into(), &bins).ok();
obj.into()
}
None => JsValue::NULL,
})
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = TpoProfile)]
pub struct WasmTpoProfile {
inner: wc::TpoProfile,
}
#[wasm_bindgen(js_class = TpoProfile)]
impl WasmTpoProfile {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, bin_count: usize) -> Result<WasmTpoProfile, JsError> {
Ok(Self {
inner: wc::TpoProfile::new(period, bin_count).map_err(map_err)?,
})
}
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
if high.len() != low.len() {
return Err(JsError::new("high, low must be equal length"));
}
let k = self.inner.params().1 + 2;
let n = high.len();
let mut out = vec![f64::NAN; n * k];
for i in 0..n {
let mid = f64::midpoint(high[i], low[i]);
let c = wc::Candle::new(mid, high[i], low[i], mid, 1.0, 0).map_err(map_err)?;
if let Some(o) = self.inner.update(c) {
out[i * k] = o.price_low;
out[i * k + 1] = o.price_high;
for (j, count) in o.counts.iter().enumerate() {
out[i * k + 2 + j] = *count;
}
}
}
Ok(Float64Array::from(out.as_slice()))
}
/// Streaming update. Returns `{ priceLow, priceHigh, counts }` once warm, else `null`.
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
let mid = f64::midpoint(high, low);
let c = wc::Candle::new(mid, high, low, mid, 1.0, 0).map_err(map_err)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"priceLow".into(), &o.price_low.into()).ok();
Reflect::set(&obj, &"priceHigh".into(), &o.price_high.into()).ok();
let counts = Float64Array::from(o.counts.as_slice());
Reflect::set(&obj, &"counts".into(), &counts).ok();
obj.into()
}
None => JsValue::NULL,
})
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = InitialBalance)]
pub struct WasmInitialBalance {
inner: wc::InitialBalance,