feat(market-profile): naked POC, single prints, profile shape, HVN/LVN, composite profile (B17) (#216)

## B17 Market Profile — five new indicators (493 → 498)

| Indicator | Output | Notes |
|-----------|--------|-------|
| `NakedPoc` | `f64` | most recent untouched point-of-control level |
| `SinglePrints` | `f64` | count of single-print price levels |
| `ProfileShape` | `f64` | b/P/D shape classification as a numeric code |
| `HighLowVolumeNodes` | struct `{hvn, lvn}` | highest/lowest volume nodes |
| `CompositeProfile` | struct `{poc, vah, val}` | multi-session composite volume profile |

### Wiring
- Core structs + full unit tests; all join the existing **Market Profile** family.
- Hand-written Python/Node/WASM bindings (f64 via candle helpers; struct via PyArray2 / `#[napi(object)]` / `Object`+`Reflect::set`).
- Fuzz drives in `indicator_update_candle.rs`; CANDLE_SCALAR + MULTI registry tests + reference tests.
- README counter + `docs/README.md` + `FAMILIES` assert bumped to 498.

### Verify (local, all green)
- `cargo test -p wickra-core --lib`: 4066 · `--doc`: 448
- clippy workspace: clean
- node: 568 · pytest: 938
This commit is contained in:
kingchenc
2026-06-08 04:17:06 +02:00
committed by GitHub
parent 5862401958
commit 91aa6fffbf
19 changed files with 2692 additions and 74 deletions
@@ -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)) {
+54
View File
@@ -409,6 +409,15 @@ export interface VolumeProfileValue {
priceHigh: number
bins: Array<number>
}
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<number>, low: Array<number>, volume: Array<number>): Array<number>
}
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<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
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<number>, low: Array<number>): Array<number>
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<number>, low: Array<number>, volume: Array<number>): Array<number>
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<number>, low: Array<number>, volume: Array<number>): Array<number>
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<number>, low: Array<number>, volume: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type VolumeProfileNode = VolumeProfile
export declare class VolumeProfile {
constructor(period: number, binCount: number)
File diff suppressed because one or more lines are too long
+326
View File
@@ -12874,6 +12874,332 @@ pub struct VolumeProfileValue {
pub bins: Vec<f64>,
}
// 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<Self> {
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<Option<f64>> {
Ok(self.inner.update(cnd(high, low, close, volume)?))
}
#[napi]
pub fn batch(
&mut self,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<f64>> {
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<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
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<Self> {
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<Option<f64>> {
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<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 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<Self> {
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<Option<HighLowVolumeNodesValue>> {
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<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 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<Self> {
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<Option<CompositeProfileValue>> {
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<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 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,
+10
View File
@@ -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",
+348
View File
@@ -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<Self> {
Ok(Self {
inner: wc::NakedPoc::new(session_len, bin_count).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<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 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<Self> {
Ok(Self {
inner: wc::SinglePrints::new(period, bin_count).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<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 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<Self> {
Ok(Self {
inner: wc::ProfileShape::new(period, bin_count).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<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 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<Self> {
Ok(Self {
inner: wc::HighLowVolumeNodes::new(period, bin_count).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
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<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 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<Self> {
Ok(Self {
inner: wc::CompositeProfile::new(period, bin_count, value_area_pct).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
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<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 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::<PyPointAndFigureBars>()?;
m.add_class::<PyInitialBalance>()?;
m.add_class::<PyOpeningRange>()?;
m.add_class::<PyNakedPoc>()?;
m.add_class::<PySinglePrints>()?;
m.add_class::<PyProfileShape>()?;
m.add_class::<PyHighLowVolumeNodes>()?;
m.add_class::<PyCompositeProfile>()?;
// Candlestick patterns.
m.add_class::<PyDoji>()?;
m.add_class::<PyHammer>()?;
@@ -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 ------------------------------------------------------------
+292
View File
@@ -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<WasmNakedPoc, JsError> {
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<Option<f64>, 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<Float64Array, JsError> {
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<WasmSinglePrints, JsError> {
Ok(Self {
inner: wc::SinglePrints::new(period, bin_count).map_err(map_err)?,
})
}
pub fn update(&mut self, high: f64, low: f64) -> Result<Option<f64>, 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<Float64Array, JsError> {
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<WasmProfileShape, JsError> {
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<Option<f64>, 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<Float64Array, JsError> {
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<WasmHighLowVolumeNodes, JsError> {
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<Float64Array, JsError> {
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<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, &"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<WasmCompositeProfile, JsError> {
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<Float64Array, JsError> {
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<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, &"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,