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:
@@ -1246,3 +1246,29 @@ test('basis rejects bad input', () => {
|
||||
assert.throws(() => new wickra.TermStructureBasis().update(100, 0));
|
||||
assert.throws(() => new wickra.CalendarSpread().update(100, 0));
|
||||
});
|
||||
|
||||
test('VolumeProfile exposes the full histogram', () => {
|
||||
// bar0 single-print at 10 vol 100; bar1 spans 10..14 vol 80 over 4 bins.
|
||||
const vp = new wickra.VolumeProfile(2, 4);
|
||||
assert.equal(vp.update(10, 10, 100), null);
|
||||
const out = vp.update(14, 10, 80);
|
||||
assert.ok(out !== null);
|
||||
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
|
||||
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
|
||||
assert.deepEqual(out.bins.length, 4);
|
||||
assert.ok(Math.abs(out.bins[0] - 120) < 1e-9);
|
||||
for (let i = 1; i < 4; i++) {
|
||||
assert.ok(Math.abs(out.bins[i] - 20) < 1e-9);
|
||||
}
|
||||
});
|
||||
|
||||
test('TpoProfile counts time at price, volume-agnostic', () => {
|
||||
// bar0 spans 10..14 (+1 each bin); bar1 spans 11..12 (+1 bins 1,2).
|
||||
const tpo = new wickra.TpoProfile(2, 4);
|
||||
assert.equal(tpo.update(14, 10), null);
|
||||
const out = tpo.update(12, 11);
|
||||
assert.ok(out !== null);
|
||||
assert.ok(Math.abs(out.priceLow - 10) < 1e-9);
|
||||
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
|
||||
assert.deepEqual(out.counts, [1, 2, 2, 1]);
|
||||
});
|
||||
|
||||
Vendored
+28
@@ -270,6 +270,16 @@ export interface ValueAreaValue {
|
||||
vah: number
|
||||
val: number
|
||||
}
|
||||
export interface VolumeProfileValue {
|
||||
priceLow: number
|
||||
priceHigh: number
|
||||
bins: Array<number>
|
||||
}
|
||||
export interface TpoProfileValue {
|
||||
priceLow: number
|
||||
priceHigh: number
|
||||
counts: Array<number>
|
||||
}
|
||||
export interface InitialBalanceValue {
|
||||
high: number
|
||||
low: number
|
||||
@@ -2064,6 +2074,24 @@ export declare class ValueArea {
|
||||
update(high: number, low: number, volume: number): ValueAreaValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
}
|
||||
export type VolumeProfileNode = VolumeProfile
|
||||
export declare class VolumeProfile {
|
||||
constructor(period: number, binCount: number)
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
update(high: number, low: number, volume: number): VolumeProfileValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
}
|
||||
export type TpoProfileNode = TpoProfile
|
||||
export declare class TpoProfile {
|
||||
constructor(period: number, binCount: number)
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
update(high: number, low: number): TpoProfileValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
}
|
||||
export type InitialBalanceNode = InitialBalance
|
||||
export declare class InitialBalance {
|
||||
constructor(period: number)
|
||||
|
||||
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -499,6 +499,8 @@ module.exports.FAMA = FAMA
|
||||
module.exports.Ichimoku = Ichimoku
|
||||
module.exports.HeikinAshi = HeikinAshi
|
||||
module.exports.ValueArea = ValueArea
|
||||
module.exports.VolumeProfile = VolumeProfile
|
||||
module.exports.TpoProfile = TpoProfile
|
||||
module.exports.InitialBalance = InitialBalance
|
||||
module.exports.OpeningRange = OpeningRange
|
||||
module.exports.Doji = Doji
|
||||
|
||||
@@ -8476,6 +8476,153 @@ impl ValueAreaNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== VolumeProfile ==============================
|
||||
|
||||
#[napi(object)]
|
||||
pub struct VolumeProfileValue {
|
||||
pub price_low: f64,
|
||||
pub price_high: f64,
|
||||
pub bins: Vec<f64>,
|
||||
}
|
||||
|
||||
#[napi(js_name = "VolumeProfile")]
|
||||
pub struct VolumeProfileNode {
|
||||
inner: wc::VolumeProfile,
|
||||
}
|
||||
#[napi]
|
||||
impl VolumeProfileNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, bin_count: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::VolumeProfile::new(period as usize, bin_count as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
volume: f64,
|
||||
) -> napi::Result<Option<VolumeProfileValue>> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let candle = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
|
||||
Ok(self.inner.update(candle).map(|o| VolumeProfileValue {
|
||||
price_low: o.price_low,
|
||||
price_high: o.price_high,
|
||||
bins: o.bins,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, volume must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let k = self.inner.params().1 + 2;
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * k];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let candle =
|
||||
wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * k] = o.price_low;
|
||||
out[i * k + 1] = o.price_high;
|
||||
for (j, b) in o.bins.iter().enumerate() {
|
||||
out[i * k + 2 + j] = *b;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== TpoProfile ==============================
|
||||
|
||||
#[napi(object)]
|
||||
pub struct TpoProfileValue {
|
||||
pub price_low: f64,
|
||||
pub price_high: f64,
|
||||
pub counts: Vec<f64>,
|
||||
}
|
||||
|
||||
#[napi(js_name = "TpoProfile")]
|
||||
pub struct TpoProfileNode {
|
||||
inner: wc::TpoProfile,
|
||||
}
|
||||
#[napi]
|
||||
impl TpoProfileNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, bin_count: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::TpoProfile::new(period as usize, bin_count as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<TpoProfileValue>> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let candle = wc::Candle::new(mid, high, low, mid, 1.0, 0).map_err(map_err)?;
|
||||
Ok(self.inner.update(candle).map(|o| TpoProfileValue {
|
||||
price_low: o.price_low,
|
||||
price_high: o.price_high,
|
||||
counts: o.counts,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let k = self.inner.params().1 + 2;
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * k];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let candle = wc::Candle::new(mid, high[i], low[i], mid, 1.0, 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * k] = o.price_low;
|
||||
out[i * k + 1] = o.price_high;
|
||||
for (j, count) in o.counts.iter().enumerate() {
|
||||
out[i * k + 2 + j] = *count;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== InitialBalance ==============================
|
||||
|
||||
#[napi(object)]
|
||||
|
||||
@@ -223,6 +223,8 @@ from ._wickra import (
|
||||
HeikinAshi,
|
||||
# Market Profile
|
||||
ValueArea,
|
||||
VolumeProfile,
|
||||
TpoProfile,
|
||||
InitialBalance,
|
||||
OpeningRange,
|
||||
# Candlestick patterns
|
||||
@@ -536,6 +538,8 @@ __all__ = [
|
||||
"HeikinAshi",
|
||||
# Market Profile
|
||||
"ValueArea",
|
||||
"VolumeProfile",
|
||||
"TpoProfile",
|
||||
"InitialBalance",
|
||||
"OpeningRange",
|
||||
# Candlestick patterns
|
||||
|
||||
@@ -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) -------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -6070,6 +6070,135 @@ impl WasmValueArea {
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = VolumeProfile)]
|
||||
pub struct WasmVolumeProfile {
|
||||
inner: wc::VolumeProfile,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = VolumeProfile)]
|
||||
impl WasmVolumeProfile {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, bin_count: usize) -> Result<WasmVolumeProfile, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::VolumeProfile::new(period, bin_count).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(JsError::new("high, low, volume must be equal length"));
|
||||
}
|
||||
let k = self.inner.params().1 + 2;
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * k];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let c = wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * k] = o.price_low;
|
||||
out[i * k + 1] = o.price_high;
|
||||
for (j, b) in o.bins.iter().enumerate() {
|
||||
out[i * k + 2 + j] = *b;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
/// Streaming update. Returns `{ priceLow, priceHigh, bins }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<JsValue, JsError> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let c = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"priceLow".into(), &o.price_low.into()).ok();
|
||||
Reflect::set(&obj, &"priceHigh".into(), &o.price_high.into()).ok();
|
||||
let bins = Float64Array::from(o.bins.as_slice());
|
||||
Reflect::set(&obj, &"bins".into(), &bins).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = TpoProfile)]
|
||||
pub struct WasmTpoProfile {
|
||||
inner: wc::TpoProfile,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = TpoProfile)]
|
||||
impl WasmTpoProfile {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, bin_count: usize) -> Result<WasmTpoProfile, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::TpoProfile::new(period, bin_count).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() {
|
||||
return Err(JsError::new("high, low must be equal length"));
|
||||
}
|
||||
let k = self.inner.params().1 + 2;
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * k];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let c = wc::Candle::new(mid, high[i], low[i], mid, 1.0, 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * k] = o.price_low;
|
||||
out[i * k + 1] = o.price_high;
|
||||
for (j, count) in o.counts.iter().enumerate() {
|
||||
out[i * k + 2 + j] = *count;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
/// Streaming update. Returns `{ priceLow, priceHigh, counts }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64) -> Result<JsValue, JsError> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let c = wc::Candle::new(mid, high, low, mid, 1.0, 0).map_err(map_err)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"priceLow".into(), &o.price_low.into()).ok();
|
||||
Reflect::set(&obj, &"priceHigh".into(), &o.price_high.into()).ok();
|
||||
let counts = Float64Array::from(o.counts.as_slice());
|
||||
Reflect::set(&obj, &"counts".into(), &counts).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = InitialBalance)]
|
||||
pub struct WasmInitialBalance {
|
||||
inner: wc::InitialBalance,
|
||||
|
||||
Reference in New Issue
Block a user