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
@@ -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<Candle>,
last: Option<CompositeProfileOutput>,
}
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<Self> {
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<CompositeProfileOutput> {
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<CompositeProfileOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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);
}
}
@@ -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<Candle>,
last: Option<HighLowVolumeNodesOutput>,
}
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<Self> {
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<HighLowVolumeNodesOutput> {
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<f64>) {
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<HighLowVolumeNodesOutput> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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);
}
}
+16 -1
View File
@@ -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");
}
}
@@ -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<Candle>,
naked: Vec<f64>,
last_close: f64,
ready: bool,
last: Option<f64>,
}
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<Self> {
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<f64> {
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<f64> {
// 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<Candle> = (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<Candle> = (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}"
);
}
}
@@ -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<Candle>,
last: Option<f64>,
}
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<Self> {
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<f64> {
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<f64> {
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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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<Candle> = (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());
}
}
@@ -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<Candle>,
last: Option<f64>,
}
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<Self> {
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<f64> {
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<f64> {
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<Candle> = (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<Candle> = (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::<Vec<_>>(),
)
.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::<Vec<_>>(),
);
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<Candle> = (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);
}
}
+63 -62
View File
@@ -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,