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
@@ -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) -------------------------------------------