diff --git a/CHANGELOG.md b/CHANGELOG.md
index 206fc8e5..16eba364 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Anchored RSI** — a cumulative Relative Strength Index whose averaging begins at a runtime-chosen anchor bar (`set_anchor`), the momentum counterpart to Anchored VWAP. Every up- and down-move since the anchor is weighted equally, so it reports the RSI of the entire move since the anchor point. Scalar input, Momentum Oscillators family; available in Rust, Python, Node and WASM.
- **Volume Profile** — the full per-bin volume distribution over a rolling window, exposing the raw histogram (price bounds plus per-bin volume) that Value Area reduces to POC/VAH/VAL. Market Profile family; candle input, available in Rust, Python, Node and WASM.
- **TPO Profile** — the Time-Price-Opportunity (market-profile letter) distribution: a volume-agnostic count of how many periods traded at each price level over a rolling window. Market Profile family; candle input, available in Rust, Python, Node and WASM.
+- **Alt-Chart Bars** — a new `BarBuilder` trait and family of price-driven chart constructors that emit a variable number of completed bars per candle (so they are deliberately not `Indicator`s): **Renko** (fixed box-size bricks with the 2-box reversal rule), **Kagi** (reversal-amount line segments), and **Point & Figure** (box-size X/O columns with an N-box reversal). Available in Rust, Python, Node and WASM.
## [0.4.4] - 2026-06-02
diff --git a/README.md b/README.md
index 3ed247ce..7026647c 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
- every one of the 292 indicators; start at the
+ every one of the 295 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
-292 streaming-first indicators across eighteen families. Every one passes the
+295 streaming-first indicators across nineteen families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -155,6 +155,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
+| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint |
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
@@ -238,7 +239,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
-│ ├── wickra-core/ core engine + all 292 indicators
+│ ├── wickra-core/ core engine + all 295 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
diff --git a/bindings/node/__tests__/completeness.test.js b/bindings/node/__tests__/completeness.test.js
index a8575dda..09a9ed9c 100644
--- a/bindings/node/__tests__/completeness.test.js
+++ b/bindings/node/__tests__/completeness.test.js
@@ -8,16 +8,25 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
+// Bar builders (Renko / Kagi / Point & Figure) implement the `BarBuilder`
+// contract, not `Indicator`: they emit a variable number of completed bars per
+// candle and have no fixed warmup or ready state. They expose update/batch/reset
+// but intentionally not isReady/warmupPeriod, so they are excluded from the
+// Indicator completeness contract below (their interface is covered by the
+// dedicated bar-builder tests).
+const BAR_BUILDERS = new Set(['RenkoBars', 'KagiBars', 'PointAndFigureBars']);
+
// An "indicator class" is an exported constructor whose prototype carries the
-// streaming `update` method. This excludes `version` (a plain function) and any
-// non-indicator export.
+// streaming `update` method. This excludes `version` (a plain function), the bar
+// builders, and any non-indicator export.
function indicatorClasses() {
return Object.keys(wickra).filter((name) => {
const value = wickra[name];
return (
typeof value === 'function' &&
value.prototype &&
- typeof value.prototype.update === 'function'
+ typeof value.prototype.update === 'function' &&
+ !BAR_BUILDERS.has(name)
);
});
}
diff --git a/bindings/node/__tests__/indicators.test.js b/bindings/node/__tests__/indicators.test.js
index d1a36272..b4793866 100644
--- a/bindings/node/__tests__/indicators.test.js
+++ b/bindings/node/__tests__/indicators.test.js
@@ -1272,3 +1272,37 @@ test('TpoProfile counts time at price, volume-agnostic', () => {
assert.ok(Math.abs(out.priceHigh - 14) < 1e-9);
assert.deepEqual(out.counts, [1, 2, 2, 1]);
});
+
+test('RenkoBars prints aligned bricks and reverses on two boxes', () => {
+ const r = new wickra.RenkoBars(1.0);
+ assert.deepEqual(r.update(10), []); // seed
+ const up = r.update(13);
+ assert.equal(up.length, 3);
+ assert.ok(Math.abs(up[0].open - 10) < 1e-9 && Math.abs(up[0].close - 11) < 1e-9);
+ assert.ok(up.every((b) => b.direction === 1));
+ const down = r.update(10);
+ assert.equal(down.length, 2);
+ assert.ok(down.every((b) => b.direction === -1));
+});
+
+test('KagiBars closes a segment on a reversal', () => {
+ const k = new wickra.KagiBars(2.0);
+ k.update(10);
+ k.update(11);
+ k.update(15);
+ const seg = k.update(12);
+ assert.equal(seg.length, 1);
+ assert.equal(seg[0].direction, 1);
+ assert.ok(Math.abs(seg[0].start - 10) < 1e-9 && Math.abs(seg[0].end - 15) < 1e-9);
+});
+
+test('PointAndFigureBars closes a column on a 3-box reversal', () => {
+ const pnf = new wickra.PointAndFigureBars(1.0, 3);
+ pnf.update(10);
+ pnf.update(13);
+ pnf.update(15);
+ const col = pnf.update(12);
+ assert.equal(col.length, 1);
+ assert.equal(col[0].direction, 1);
+ assert.ok(Math.abs(col[0].high - 15) < 1e-9 && Math.abs(col[0].low - 10) < 1e-9);
+});
diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts
index 2a42540c..8e18e191 100644
--- a/bindings/node/index.d.ts
+++ b/bindings/node/index.d.ts
@@ -310,6 +310,21 @@ export interface LiquidationFeaturesValue {
total: number
imbalance: number
}
+export interface RenkoBrickValue {
+ open: number
+ close: number
+ direction: number
+}
+export interface KagiSegmentValue {
+ start: number
+ end: number
+ direction: number
+}
+export interface PnfColumnValue {
+ direction: number
+ high: number
+ low: number
+}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -3022,6 +3037,31 @@ export declare class InformationRatio {
isReady(): boolean
warmupPeriod(): number
}
+export type RenkoBarsNode = RenkoBars
+export declare class RenkoBars {
+ constructor(boxSize: number)
+ update(close: number): Array
+ batch(close: Array): Array
+ boxSize(): number
+ reset(): void
+}
+export type KagiBarsNode = KagiBars
+export declare class KagiBars {
+ constructor(reversal: number)
+ update(close: number): Array
+ batch(close: Array): Array
+ reversal(): number
+ reset(): void
+}
+export type PointAndFigureBarsNode = PointAndFigureBars
+export declare class PointAndFigureBars {
+ constructor(boxSize: number, reversal: number)
+ update(close: number): Array
+ batch(close: Array): Array
+ boxSize(): number
+ reversal(): number
+ reset(): void
+}
export type AlphaNode = Alpha
export declare class Alpha {
constructor(period: number, riskFree: number)
diff --git a/bindings/node/index.js b/bindings/node/index.js
index 793866e3..b8027d23 100644
--- a/bindings/node/index.js
+++ b/bindings/node/index.js
@@ -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, 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
+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, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -604,4 +604,7 @@ module.exports.RecoveryFactor = RecoveryFactor
module.exports.KellyCriterion = KellyCriterion
module.exports.TreynorRatio = TreynorRatio
module.exports.InformationRatio = InformationRatio
+module.exports.RenkoBars = RenkoBars
+module.exports.KagiBars = KagiBars
+module.exports.PointAndFigureBars = PointAndFigureBars
module.exports.Alpha = Alpha
diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs
index d1f2d8a0..b5c191b1 100644
--- a/bindings/node/src/lib.rs
+++ b/bindings/node/src/lib.rs
@@ -16,7 +16,7 @@ use napi::Error as NapiError;
use napi::Status;
use napi_derive::napi;
use wickra_core as wc;
-use wickra_core::{BatchExt, Indicator};
+use wickra_core::{BarBuilder, BatchExt, Indicator};
fn map_err(e: wc::Error) -> NapiError {
NapiError::new(Status::InvalidArg, e.to_string())
@@ -11060,6 +11060,190 @@ impl InformationRatioNode {
}
}
+// ============================== Alt-Chart Bars ==============================
+//
+// Bar builders consume close prices and emit a variable number of completed bars
+// per input. `update(close)` returns the bars finished on that close; `batch`
+// returns all completed bars concatenated.
+
+#[napi(object)]
+pub struct RenkoBrickValue {
+ pub open: f64,
+ pub close: f64,
+ pub direction: i32,
+}
+
+#[napi(js_name = "RenkoBars")]
+pub struct RenkoBarsNode {
+ inner: wc::RenkoBars,
+}
+#[napi]
+impl RenkoBarsNode {
+ #[napi(constructor)]
+ pub fn new(box_size: f64) -> napi::Result {
+ Ok(Self {
+ inner: wc::RenkoBars::new(box_size).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, close: f64) -> napi::Result> {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ Ok(self
+ .inner
+ .update(candle)
+ .into_iter()
+ .map(|b| RenkoBrickValue {
+ open: b.open,
+ close: b.close,
+ direction: i32::from(b.direction),
+ })
+ .collect())
+ }
+ #[napi]
+ pub fn batch(&mut self, close: Vec) -> napi::Result> {
+ let mut out = Vec::new();
+ for price in close {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for b in self.inner.update(candle) {
+ out.push(RenkoBrickValue {
+ open: b.open,
+ close: b.close,
+ direction: i32::from(b.direction),
+ });
+ }
+ }
+ Ok(out)
+ }
+ #[napi(js_name = "boxSize")]
+ pub fn box_size(&self) -> f64 {
+ self.inner.box_size()
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
+#[napi(object)]
+pub struct KagiSegmentValue {
+ pub start: f64,
+ pub end: f64,
+ pub direction: i32,
+}
+
+#[napi(js_name = "KagiBars")]
+pub struct KagiBarsNode {
+ inner: wc::KagiBars,
+}
+#[napi]
+impl KagiBarsNode {
+ #[napi(constructor)]
+ pub fn new(reversal: f64) -> napi::Result {
+ Ok(Self {
+ inner: wc::KagiBars::new(reversal).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, close: f64) -> napi::Result> {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ Ok(self
+ .inner
+ .update(candle)
+ .into_iter()
+ .map(|b| KagiSegmentValue {
+ start: b.start,
+ end: b.end,
+ direction: i32::from(b.direction),
+ })
+ .collect())
+ }
+ #[napi]
+ pub fn batch(&mut self, close: Vec) -> napi::Result> {
+ let mut out = Vec::new();
+ for price in close {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for b in self.inner.update(candle) {
+ out.push(KagiSegmentValue {
+ start: b.start,
+ end: b.end,
+ direction: i32::from(b.direction),
+ });
+ }
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reversal(&self) -> f64 {
+ self.inner.reversal()
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
+#[napi(object)]
+pub struct PnfColumnValue {
+ pub direction: i32,
+ pub high: f64,
+ pub low: f64,
+}
+
+#[napi(js_name = "PointAndFigureBars")]
+pub struct PointAndFigureBarsNode {
+ inner: wc::PointAndFigureBars,
+}
+#[napi]
+impl PointAndFigureBarsNode {
+ #[napi(constructor)]
+ pub fn new(box_size: f64, reversal: u32) -> napi::Result {
+ Ok(Self {
+ inner: wc::PointAndFigureBars::new(box_size, reversal as usize).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(&mut self, close: f64) -> napi::Result> {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ Ok(self
+ .inner
+ .update(candle)
+ .into_iter()
+ .map(|col| PnfColumnValue {
+ direction: i32::from(col.direction),
+ high: col.high,
+ low: col.low,
+ })
+ .collect())
+ }
+ #[napi]
+ pub fn batch(&mut self, close: Vec) -> napi::Result> {
+ let mut out = Vec::new();
+ for price in close {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for col in self.inner.update(candle) {
+ out.push(PnfColumnValue {
+ direction: i32::from(col.direction),
+ high: col.high,
+ low: col.low,
+ });
+ }
+ }
+ Ok(out)
+ }
+ #[napi(js_name = "boxSize")]
+ pub fn box_size(&self) -> f64 {
+ self.inner.box_size()
+ }
+ #[napi]
+ pub fn reversal(&self) -> u32 {
+ self.inner.reversal() as u32
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
#[napi(js_name = "Alpha")]
pub struct AlphaNode {
inner: wc::Alpha,
diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py
index a3cdf2b0..b4045784 100644
--- a/bindings/python/python/wickra/__init__.py
+++ b/bindings/python/python/wickra/__init__.py
@@ -227,6 +227,10 @@ from ._wickra import (
TpoProfile,
InitialBalance,
OpeningRange,
+ # Alt-Chart Bars
+ RenkoBars,
+ KagiBars,
+ PointAndFigureBars,
# Candlestick patterns
Doji,
Hammer,
@@ -542,6 +546,10 @@ __all__ = [
"TpoProfile",
"InitialBalance",
"OpeningRange",
+ # Alt-Chart Bars
+ "RenkoBars",
+ "KagiBars",
+ "PointAndFigureBars",
# Candlestick patterns
"Doji",
"Hammer",
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index acbdbf55..19955e2a 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -17,7 +17,7 @@ use pyo3::exceptions::{PyTypeError, PyValueError};
use pyo3::prelude::*;
use pyo3::types::PyDict;
use wickra_core as wc;
-use wickra_core::{BatchExt, Indicator};
+use wickra_core::{BarBuilder, BatchExt, Indicator};
fn map_err(e: wc::Error) -> PyErr {
match e {
@@ -14210,6 +14210,205 @@ impl PyAlpha {
}
}
+// ============================== Alt-Chart Bars ==============================
+//
+// Bar builders consume close prices and emit a variable number of completed bars
+// per input. `update(close)` returns the bars finished on that close; `batch`
+// returns a `(k, 3)` array of all completed bars concatenated.
+
+#[pyclass(name = "RenkoBars", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyRenkoBars {
+ inner: wc::RenkoBars,
+}
+
+#[pymethods]
+impl PyRenkoBars {
+ #[new]
+ fn new(box_size: f64) -> PyResult {
+ Ok(Self {
+ inner: wc::RenkoBars::new(box_size).map_err(map_err)?,
+ })
+ }
+ /// Feed one close; returns bricks completed on it as `(open, close, direction)`.
+ fn update(&mut self, close: f64) -> PyResult> {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ Ok(self
+ .inner
+ .update(candle)
+ .into_iter()
+ .map(|b| (b.open, b.close, i64::from(b.direction)))
+ .collect())
+ }
+ /// Batch over a close column. Returns shape `(k, 3)` of `[open, close, direction]`.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ close: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let prices = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let mut rows: Vec = Vec::new();
+ let mut k = 0usize;
+ for &price in prices {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for b in self.inner.update(candle) {
+ rows.push(b.open);
+ rows.push(b.close);
+ rows.push(f64::from(b.direction));
+ k += 1;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((k, 3), rows)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn box_size(&self) -> f64 {
+ self.inner.box_size()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn __repr__(&self) -> String {
+ format!("RenkoBars(box_size={})", self.inner.box_size())
+ }
+}
+
+#[pyclass(name = "KagiBars", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyKagiBars {
+ inner: wc::KagiBars,
+}
+
+#[pymethods]
+impl PyKagiBars {
+ #[new]
+ fn new(reversal: f64) -> PyResult {
+ Ok(Self {
+ inner: wc::KagiBars::new(reversal).map_err(map_err)?,
+ })
+ }
+ /// Feed one close; returns completed segments as `(start, end, direction)`.
+ fn update(&mut self, close: f64) -> PyResult> {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ Ok(self
+ .inner
+ .update(candle)
+ .into_iter()
+ .map(|b| (b.start, b.end, i64::from(b.direction)))
+ .collect())
+ }
+ /// Batch over a close column. Returns shape `(k, 3)` of `[start, end, direction]`.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ close: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let prices = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let mut rows: Vec = Vec::new();
+ let mut k = 0usize;
+ for &price in prices {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for b in self.inner.update(candle) {
+ rows.push(b.start);
+ rows.push(b.end);
+ rows.push(f64::from(b.direction));
+ k += 1;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((k, 3), rows)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn reversal(&self) -> f64 {
+ self.inner.reversal()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn __repr__(&self) -> String {
+ format!("KagiBars(reversal={})", self.inner.reversal())
+ }
+}
+
+#[pyclass(
+ name = "PointAndFigureBars",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyPointAndFigureBars {
+ inner: wc::PointAndFigureBars,
+}
+
+#[pymethods]
+impl PyPointAndFigureBars {
+ #[new]
+ #[pyo3(signature = (box_size, reversal=3))]
+ fn new(box_size: f64, reversal: usize) -> PyResult {
+ Ok(Self {
+ inner: wc::PointAndFigureBars::new(box_size, reversal).map_err(map_err)?,
+ })
+ }
+ /// Feed one close; returns completed columns as `(direction, high, low)`.
+ fn update(&mut self, close: f64) -> PyResult> {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ Ok(self
+ .inner
+ .update(candle)
+ .into_iter()
+ .map(|c| (i64::from(c.direction), c.high, c.low))
+ .collect())
+ }
+ /// Batch over a close column. Returns shape `(k, 3)` of `[direction, high, low]`.
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ close: PyReadonlyArray1<'py, f64>,
+ ) -> PyResult>> {
+ let prices = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let mut rows: Vec = Vec::new();
+ let mut k = 0usize;
+ for &price in prices {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for col in self.inner.update(candle) {
+ rows.push(f64::from(col.direction));
+ rows.push(col.high);
+ rows.push(col.low);
+ k += 1;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((k, 3), rows)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn box_size(&self) -> f64 {
+ self.inner.box_size()
+ }
+ #[getter]
+ fn reversal(&self) -> usize {
+ self.inner.reversal()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn __repr__(&self) -> String {
+ format!(
+ "PointAndFigureBars(box_size={}, reversal={})",
+ self.inner.box_size(),
+ self.inner.reversal()
+ )
+ }
+}
+
// ============================== Module ==============================
#[pymodule]
@@ -14407,6 +14606,9 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
+ m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
// Candlestick patterns.
diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py
index c9d071c5..fe9136b0 100644
--- a/bindings/python/tests/test_new_indicators.py
+++ b/bindings/python/tests/test_new_indicators.py
@@ -2649,3 +2649,44 @@ def test_basis_indicators_streaming_equals_batch():
[streamer.update(futures[i], mark[i]) for i in range(n)], dtype=np.float64
)
assert _eq_nan(batch, streamed)
+
+
+# --- Alt-Chart Bars ------------------------------------------------------
+
+
+def test_renko_bars_reference():
+ r = ta.RenkoBars(1.0)
+ assert r.update(10.0) == [] # seed
+ assert r.update(13.0) == [(10.0, 11.0, 1), (11.0, 12.0, 1), (12.0, 13.0, 1)]
+ assert r.update(10.0) == [(12.0, 11.0, -1), (11.0, 10.0, -1)] # 2-box reversal
+
+
+def test_renko_bars_batch_shape():
+ r = ta.RenkoBars(1.0)
+ out = r.batch(np.array([10.0, 11.0, 12.0, 13.0]))
+ assert out.shape == (3, 3)
+ np.testing.assert_allclose(out[:, 2], [1.0, 1.0, 1.0])
+
+
+def test_kagi_bars_reference():
+ k = ta.KagiBars(2.0)
+ assert k.update(10.0) == [] # seed
+ assert k.update(11.0) == [] # establishes up
+ assert k.update(15.0) == [] # extends
+ assert k.update(12.0) == [(10.0, 15.0, 1)] # reversal closes up segment
+
+
+def test_point_and_figure_bars_reference():
+ pnf = ta.PointAndFigureBars(1.0, 3)
+ assert pnf.update(10.0) == [] # seed
+ assert pnf.update(13.0) == [] # starts X column
+ assert pnf.update(15.0) == [] # extends up
+ assert pnf.update(12.0) == [(1, 15.0, 10.0)] # 3-box reversal closes X column
+
+
+def test_bar_builders_reset():
+ r = ta.RenkoBars(1.0)
+ r.update(10.0)
+ r.update(15.0)
+ r.reset()
+ assert r.update(50.0) == [] # re-seeds after reset
diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs
index e6ebfa8d..9118cd8b 100644
--- a/bindings/wasm/src/lib.rs
+++ b/bindings/wasm/src/lib.rs
@@ -12,7 +12,7 @@
use js_sys::{Array, Float64Array, Object, Reflect};
use wasm_bindgen::prelude::*;
use wickra_core as wc;
-use wickra_core::{BatchExt, Indicator};
+use wickra_core::{BarBuilder, BatchExt, Indicator};
fn map_err(e: wc::Error) -> JsError {
JsError::new(&e.to_string())
@@ -8517,6 +8517,161 @@ impl WasmInformationRatio {
}
}
+// ============================== Alt-Chart Bars ==============================
+//
+// Bar builders consume close prices and emit a variable number of completed bars
+// per input. `update(close)` returns a JS array of the bars finished on that
+// close; `batch` returns all completed bars concatenated.
+
+#[wasm_bindgen(js_name = RenkoBars)]
+pub struct WasmRenkoBars {
+ inner: wc::RenkoBars,
+}
+
+#[wasm_bindgen(js_class = RenkoBars)]
+impl WasmRenkoBars {
+ #[wasm_bindgen(constructor)]
+ pub fn new(box_size: f64) -> Result {
+ Ok(Self {
+ inner: wc::RenkoBars::new(box_size).map_err(map_err)?,
+ })
+ }
+ /// Returns an array of `{ open, close, direction }` bricks completed on this close.
+ pub fn update(&mut self, close: f64) -> Result {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ let arr = Array::new();
+ for b in self.inner.update(candle) {
+ let obj = Object::new();
+ Reflect::set(&obj, &"open".into(), &b.open.into()).ok();
+ Reflect::set(&obj, &"close".into(), &b.close.into()).ok();
+ Reflect::set(&obj, &"direction".into(), &f64::from(b.direction).into()).ok();
+ arr.push(&obj);
+ }
+ Ok(arr)
+ }
+ pub fn batch(&mut self, close: &[f64]) -> Result {
+ let arr = Array::new();
+ for &price in close {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for b in self.inner.update(candle) {
+ let obj = Object::new();
+ Reflect::set(&obj, &"open".into(), &b.open.into()).ok();
+ Reflect::set(&obj, &"close".into(), &b.close.into()).ok();
+ Reflect::set(&obj, &"direction".into(), &f64::from(b.direction).into()).ok();
+ arr.push(&obj);
+ }
+ }
+ Ok(arr)
+ }
+ #[wasm_bindgen(js_name = boxSize)]
+ pub fn box_size(&self) -> f64 {
+ self.inner.box_size()
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
+#[wasm_bindgen(js_name = KagiBars)]
+pub struct WasmKagiBars {
+ inner: wc::KagiBars,
+}
+
+#[wasm_bindgen(js_class = KagiBars)]
+impl WasmKagiBars {
+ #[wasm_bindgen(constructor)]
+ pub fn new(reversal: f64) -> Result {
+ Ok(Self {
+ inner: wc::KagiBars::new(reversal).map_err(map_err)?,
+ })
+ }
+ /// Returns an array of `{ start, end, direction }` segments completed on this close.
+ pub fn update(&mut self, close: f64) -> Result {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ let arr = Array::new();
+ for b in self.inner.update(candle) {
+ let obj = Object::new();
+ Reflect::set(&obj, &"start".into(), &b.start.into()).ok();
+ Reflect::set(&obj, &"end".into(), &b.end.into()).ok();
+ Reflect::set(&obj, &"direction".into(), &f64::from(b.direction).into()).ok();
+ arr.push(&obj);
+ }
+ Ok(arr)
+ }
+ pub fn batch(&mut self, close: &[f64]) -> Result {
+ let arr = Array::new();
+ for &price in close {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for b in self.inner.update(candle) {
+ let obj = Object::new();
+ Reflect::set(&obj, &"start".into(), &b.start.into()).ok();
+ Reflect::set(&obj, &"end".into(), &b.end.into()).ok();
+ Reflect::set(&obj, &"direction".into(), &f64::from(b.direction).into()).ok();
+ arr.push(&obj);
+ }
+ }
+ Ok(arr)
+ }
+ pub fn reversal(&self) -> f64 {
+ self.inner.reversal()
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
+#[wasm_bindgen(js_name = PointAndFigureBars)]
+pub struct WasmPointAndFigureBars {
+ inner: wc::PointAndFigureBars,
+}
+
+#[wasm_bindgen(js_class = PointAndFigureBars)]
+impl WasmPointAndFigureBars {
+ #[wasm_bindgen(constructor)]
+ pub fn new(box_size: f64, reversal: usize) -> Result {
+ Ok(Self {
+ inner: wc::PointAndFigureBars::new(box_size, reversal).map_err(map_err)?,
+ })
+ }
+ /// Returns an array of `{ direction, high, low }` columns completed on this close.
+ pub fn update(&mut self, close: f64) -> Result {
+ let candle = wc::Candle::new(close, close, close, close, 1.0, 0).map_err(map_err)?;
+ let arr = Array::new();
+ for col in self.inner.update(candle) {
+ let obj = Object::new();
+ Reflect::set(&obj, &"direction".into(), &f64::from(col.direction).into()).ok();
+ Reflect::set(&obj, &"high".into(), &col.high.into()).ok();
+ Reflect::set(&obj, &"low".into(), &col.low.into()).ok();
+ arr.push(&obj);
+ }
+ Ok(arr)
+ }
+ pub fn batch(&mut self, close: &[f64]) -> Result {
+ let arr = Array::new();
+ for &price in close {
+ let candle = wc::Candle::new(price, price, price, price, 1.0, 0).map_err(map_err)?;
+ for col in self.inner.update(candle) {
+ let obj = Object::new();
+ Reflect::set(&obj, &"direction".into(), &f64::from(col.direction).into()).ok();
+ Reflect::set(&obj, &"high".into(), &col.high.into()).ok();
+ Reflect::set(&obj, &"low".into(), &col.low.into()).ok();
+ arr.push(&obj);
+ }
+ }
+ Ok(arr)
+ }
+ #[wasm_bindgen(js_name = boxSize)]
+ pub fn box_size(&self) -> f64 {
+ self.inner.box_size()
+ }
+ pub fn reversal(&self) -> usize {
+ self.inner.reversal()
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+}
+
#[wasm_bindgen(js_name = Alpha)]
pub struct WasmAlpha {
inner: wc::Alpha,
diff --git a/crates/wickra-core/src/indicators/kagi_bars.rs b/crates/wickra-core/src/indicators/kagi_bars.rs
new file mode 100644
index 00000000..a47f63ca
--- /dev/null
+++ b/crates/wickra-core/src/indicators/kagi_bars.rs
@@ -0,0 +1,283 @@
+//! Kagi bar builder — reversal-amount line segments on close prices.
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::BarBuilder;
+
+/// One completed Kagi line segment (the vertical run between two reversals).
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct KagiBar {
+ /// Price where the segment began (the previous reversal point).
+ pub start: f64,
+ /// Extreme price the segment reached before reversing.
+ pub end: f64,
+ /// `+1` for a rising segment, `-1` for a falling segment.
+ pub direction: i8,
+}
+
+/// Kagi bar builder using the fixed reversal-amount method on close prices.
+///
+/// A Kagi chart is one continuous line that extends in its current direction as
+/// long as price makes new extremes, and turns when price retraces by at least
+/// `reversal` from the latest extreme. This builder emits the **completed
+/// segment** each time the line turns:
+///
+/// - The first candle seeds the start price; the first subsequent move (of any
+/// size) sets the initial direction.
+/// - While the trend holds, new extremes extend the current segment silently.
+/// - A retracement of `>= reversal` closes the current segment (returned from
+/// [`BarBuilder::update`]) and starts a new one in the opposite direction.
+///
+/// At most one segment completes per candle, so `update` returns either an empty
+/// vector or a single [`KagiBar`].
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{BarBuilder, Candle, KagiBars};
+///
+/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
+/// let mut kagi = KagiBars::new(2.0).unwrap();
+/// kagi.update(flat(10.0)); // seed
+/// kagi.update(flat(15.0)); // rise to 15
+/// let bars = kagi.update(flat(12.0)); // retrace >= 2 -> closes the up segment
+/// assert_eq!(bars.len(), 1);
+/// assert_eq!(bars[0].direction, 1);
+/// ```
+#[derive(Debug, Clone)]
+pub struct KagiBars {
+ reversal: f64,
+ dir: i8,
+ extreme: Option,
+ segment_start: f64,
+}
+
+impl KagiBars {
+ /// Construct a Kagi builder with the given reversal amount.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::InvalidPeriod`] if `reversal` is not finite and positive.
+ pub fn new(reversal: f64) -> Result {
+ if !reversal.is_finite() || reversal <= 0.0 {
+ return Err(Error::InvalidPeriod {
+ message: "reversal must be finite and positive",
+ });
+ }
+ Ok(Self {
+ reversal,
+ dir: 0,
+ extreme: None,
+ segment_start: 0.0,
+ })
+ }
+
+ /// Configured reversal amount.
+ pub const fn reversal(&self) -> f64 {
+ self.reversal
+ }
+
+ /// Current extreme price (or the seed price before any move).
+ pub const fn extreme(&self) -> Option {
+ self.extreme
+ }
+}
+
+impl BarBuilder for KagiBars {
+ type Bar = KagiBar;
+
+ fn update(&mut self, candle: Candle) -> Vec {
+ let close = candle.close;
+ let Some(mut ext) = self.extreme else {
+ self.extreme = Some(close);
+ self.segment_start = close;
+ return Vec::new();
+ };
+ let mut bars = Vec::new();
+ match self.dir {
+ 0 => {
+ if close > ext {
+ self.dir = 1;
+ ext = close;
+ } else if close < ext {
+ self.dir = -1;
+ ext = close;
+ }
+ }
+ 1 => {
+ if close > ext {
+ ext = close;
+ } else if close <= ext - self.reversal {
+ bars.push(KagiBar {
+ start: self.segment_start,
+ end: ext,
+ direction: 1,
+ });
+ self.segment_start = ext;
+ self.dir = -1;
+ ext = close;
+ }
+ }
+ _ => {
+ if close < ext {
+ ext = close;
+ } else if close >= ext + self.reversal {
+ bars.push(KagiBar {
+ start: self.segment_start,
+ end: ext,
+ direction: -1,
+ });
+ self.segment_start = ext;
+ self.dir = 1;
+ ext = close;
+ }
+ }
+ }
+ self.extreme = Some(ext);
+ bars
+ }
+
+ fn reset(&mut self) {
+ self.dir = 0;
+ self.extreme = None;
+ self.segment_start = 0.0;
+ }
+
+ fn name(&self) -> &'static str {
+ "KagiBars"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use approx::assert_relative_eq;
+
+ fn flat(price: f64) -> Candle {
+ Candle::new(price, price, price, price, 1.0, 0).unwrap()
+ }
+
+ #[test]
+ fn rejects_invalid_reversal() {
+ assert!(matches!(
+ KagiBars::new(0.0),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ assert!(matches!(
+ KagiBars::new(-2.0),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ assert!(matches!(
+ KagiBars::new(f64::INFINITY),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let kagi = KagiBars::new(2.0).unwrap();
+ assert_eq!(kagi.name(), "KagiBars");
+ assert_relative_eq!(kagi.reversal(), 2.0, epsilon = 1e-12);
+ assert_eq!(kagi.extreme(), None);
+ }
+
+ #[test]
+ fn seeds_then_establishes_up_direction() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ assert!(kagi.update(flat(10.0)).is_empty()); // seed
+ assert_eq!(kagi.extreme(), Some(10.0));
+ assert!(kagi.update(flat(11.0)).is_empty()); // first move sets dir up
+ assert_eq!(kagi.extreme(), Some(11.0));
+ }
+
+ #[test]
+ fn establishes_down_direction_from_seed() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ kagi.update(flat(10.0));
+ assert!(kagi.update(flat(9.0)).is_empty()); // first move sets dir down
+ assert_eq!(kagi.extreme(), Some(9.0));
+ }
+
+ #[test]
+ fn extends_without_emitting() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ kagi.update(flat(10.0));
+ kagi.update(flat(11.0));
+ assert!(kagi.update(flat(15.0)).is_empty()); // new high, extend
+ assert_eq!(kagi.extreme(), Some(15.0));
+ }
+
+ #[test]
+ fn reversal_closes_up_segment() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ kagi.update(flat(10.0));
+ kagi.update(flat(11.0));
+ kagi.update(flat(15.0));
+ let bars = kagi.update(flat(12.0)); // retrace 3 >= 2
+ assert_eq!(bars.len(), 1);
+ assert_eq!(bars[0].direction, 1);
+ assert_relative_eq!(bars[0].start, 10.0, epsilon = 1e-12);
+ assert_relative_eq!(bars[0].end, 15.0, epsilon = 1e-12);
+ assert_eq!(kagi.extreme(), Some(12.0));
+ }
+
+ #[test]
+ fn reversal_closes_down_segment() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ kagi.update(flat(10.0));
+ kagi.update(flat(11.0));
+ kagi.update(flat(15.0));
+ kagi.update(flat(12.0)); // now dir down, segment_start 15, extreme 12
+ let bars = kagi.update(flat(20.0)); // rise 8 >= 2 -> closes down segment
+ assert_eq!(bars.len(), 1);
+ assert_eq!(bars[0].direction, -1);
+ assert_relative_eq!(bars[0].start, 15.0, epsilon = 1e-12);
+ assert_relative_eq!(bars[0].end, 12.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn small_pullback_does_not_reverse() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ kagi.update(flat(10.0));
+ kagi.update(flat(11.0));
+ kagi.update(flat(15.0));
+ assert!(kagi.update(flat(14.0)).is_empty()); // retrace 1 < 2
+ assert_eq!(kagi.extreme(), Some(15.0));
+ }
+
+ #[test]
+ fn down_trend_small_bounce_does_not_reverse() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ kagi.update(flat(10.0));
+ kagi.update(flat(9.0)); // dir down
+ kagi.update(flat(5.0)); // extreme 5
+ assert!(kagi.update(flat(6.0)).is_empty()); // bounce 1 < 2
+ assert_eq!(kagi.extreme(), Some(5.0));
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ kagi.update(flat(10.0));
+ kagi.update(flat(15.0));
+ kagi.reset();
+ assert_eq!(kagi.extreme(), None);
+ assert!(kagi.update(flat(99.0)).is_empty());
+ assert_eq!(kagi.extreme(), Some(99.0));
+ }
+
+ #[test]
+ fn batch_collects_completed_segments() {
+ let mut kagi = KagiBars::new(2.0).unwrap();
+ let candles = [
+ flat(10.0),
+ flat(15.0),
+ flat(12.0), // closes up segment
+ flat(20.0), // closes down segment
+ ];
+ let bars = kagi.batch(&candles);
+ assert_eq!(bars.len(), 2);
+ assert_eq!(bars[0].direction, 1);
+ assert_eq!(bars[1].direction, -1);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs
index ace1257f..3fcd321e 100644
--- a/crates/wickra-core/src/indicators/mod.rs
+++ b/crates/wickra-core/src/indicators/mod.rs
@@ -123,6 +123,7 @@ mod instantaneous_trendline;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
+mod kagi_bars;
mod kama;
mod kelly_criterion;
mod keltner;
@@ -183,6 +184,7 @@ mod percentage_trailing_stop;
mod pgo;
mod piercing_dark_cloud;
mod pmo;
+mod point_and_figure_bars;
mod ppo;
mod profit_factor;
mod psar;
@@ -192,6 +194,7 @@ mod r_squared;
mod realized_spread;
mod recovery_factor;
mod relative_strength_ab;
+mod renko_bars;
mod renko_trailing_stop;
mod rickshaw_man;
mod rising_three_methods;
@@ -415,6 +418,7 @@ pub use instantaneous_trendline::InstantaneousTrendline;
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
+pub use kagi_bars::{KagiBar, KagiBars};
pub use kama::Kama;
pub use kelly_criterion::KellyCriterion;
pub use keltner::{Keltner, KeltnerOutput};
@@ -475,6 +479,7 @@ pub use percentage_trailing_stop::PercentageTrailingStop;
pub use pgo::Pgo;
pub use piercing_dark_cloud::PiercingDarkCloud;
pub use pmo::Pmo;
+pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
pub use ppo::Ppo;
pub use profit_factor::ProfitFactor;
pub use psar::Psar;
@@ -484,6 +489,7 @@ pub use r_squared::RSquared;
pub use realized_spread::RealizedSpread;
pub use recovery_factor::RecoveryFactor;
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
+pub use renko_bars::{RenkoBars, RenkoBrick};
pub use renko_trailing_stop::RenkoTrailingStop;
pub use rickshaw_man::RickshawMan;
pub use rising_three_methods::RisingThreeMethods;
@@ -966,6 +972,10 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"Alpha",
],
),
+ (
+ "Alt-Chart Bars",
+ &["RenkoBars", "KagiBars", "PointAndFigureBars"],
+ ),
];
#[cfg(test)]
@@ -994,6 +1004,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, 287, "FAMILIES total drifted from indicator count");
+ assert_eq!(total, 290, "FAMILIES total drifted from indicator count");
}
}
diff --git a/crates/wickra-core/src/indicators/point_and_figure_bars.rs b/crates/wickra-core/src/indicators/point_and_figure_bars.rs
new file mode 100644
index 00000000..b2ebe603
--- /dev/null
+++ b/crates/wickra-core/src/indicators/point_and_figure_bars.rs
@@ -0,0 +1,296 @@
+//! Point-and-Figure bar builder — box-size columns with an N-box reversal.
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::BarBuilder;
+
+/// One completed Point-and-Figure column.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct PnfColumn {
+ /// `+1` for a rising (X) column, `-1` for a falling (O) column.
+ pub direction: i8,
+ /// Upper box edge of the column.
+ pub high: f64,
+ /// Lower box edge of the column.
+ pub low: f64,
+}
+
+/// Point-and-Figure bar builder using the fixed box-size, N-box reversal method.
+///
+/// Price is quantised to a `box_size` grid (each close maps to the box that
+/// contains it). An X column extends upward while price makes new box highs; an
+/// O column extends downward while price makes new box lows. A reversal needs
+/// price to move `reversal` boxes against the column, at which point the current
+/// column is closed (returned from [`BarBuilder::update`]) and a new column
+/// starts one box offset from the prior extreme.
+///
+/// - The first candle seeds the grid box and prints no column.
+/// - The first one-box move sets the initial column direction.
+/// - At most one column completes per candle, so `update` returns an empty
+/// vector or a single [`PnfColumn`].
+///
+/// Closes are mapped to their containing box via `floor(close / box_size)` for
+/// both directions, so the construction is fully deterministic.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{BarBuilder, Candle, PointAndFigureBars};
+///
+/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
+/// let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+/// pnf.update(flat(10.0)); // seed
+/// pnf.update(flat(15.0)); // X column up to 15
+/// let cols = pnf.update(flat(12.0)); // 3-box reversal closes the X column
+/// assert_eq!(cols.len(), 1);
+/// assert_eq!(cols[0].direction, 1);
+/// ```
+#[derive(Debug, Clone)]
+pub struct PointAndFigureBars {
+ box_size: f64,
+ reversal: usize,
+ dir: i8,
+ col_top: f64,
+ col_bottom: f64,
+ seeded: bool,
+}
+
+impl PointAndFigureBars {
+ /// Construct a Point-and-Figure builder with the given box size and reversal
+ /// (in boxes).
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::InvalidPeriod`] if `box_size` is not finite and positive,
+ /// and [`Error::PeriodZero`] if `reversal` is zero.
+ pub fn new(box_size: f64, reversal: usize) -> Result {
+ if !box_size.is_finite() || box_size <= 0.0 {
+ return Err(Error::InvalidPeriod {
+ message: "box_size must be finite and positive",
+ });
+ }
+ if reversal == 0 {
+ return Err(Error::PeriodZero);
+ }
+ Ok(Self {
+ box_size,
+ reversal,
+ dir: 0,
+ col_top: 0.0,
+ col_bottom: 0.0,
+ seeded: false,
+ })
+ }
+
+ /// Configured box size.
+ pub const fn box_size(&self) -> f64 {
+ self.box_size
+ }
+
+ /// Configured reversal, in boxes.
+ pub const fn reversal(&self) -> usize {
+ self.reversal
+ }
+
+ fn floor_box(&self, price: f64) -> f64 {
+ (price / self.box_size).floor() * self.box_size
+ }
+}
+
+impl BarBuilder for PointAndFigureBars {
+ type Bar = PnfColumn;
+
+ fn update(&mut self, candle: Candle) -> Vec {
+ let box_price = self.floor_box(candle.close);
+ if !self.seeded {
+ self.seeded = true;
+ self.col_top = box_price;
+ self.col_bottom = box_price;
+ return Vec::new();
+ }
+ let box_size = self.box_size;
+ let reversal = self.reversal as f64 * box_size;
+ let mut cols = Vec::new();
+ match self.dir {
+ 0 => {
+ if box_price >= self.col_top + box_size {
+ self.dir = 1;
+ self.col_top = box_price;
+ } else if box_price <= self.col_bottom - box_size {
+ self.dir = -1;
+ self.col_bottom = box_price;
+ }
+ }
+ 1 => {
+ if box_price > self.col_top {
+ self.col_top = box_price;
+ } else if box_price <= self.col_top - reversal {
+ cols.push(PnfColumn {
+ direction: 1,
+ high: self.col_top,
+ low: self.col_bottom,
+ });
+ self.dir = -1;
+ self.col_top -= box_size;
+ self.col_bottom = box_price;
+ }
+ }
+ _ => {
+ if box_price < self.col_bottom {
+ self.col_bottom = box_price;
+ } else if box_price >= self.col_bottom + reversal {
+ cols.push(PnfColumn {
+ direction: -1,
+ high: self.col_top,
+ low: self.col_bottom,
+ });
+ self.dir = 1;
+ self.col_bottom += box_size;
+ self.col_top = box_price;
+ }
+ }
+ }
+ cols
+ }
+
+ fn reset(&mut self) {
+ self.dir = 0;
+ self.col_top = 0.0;
+ self.col_bottom = 0.0;
+ self.seeded = false;
+ }
+
+ fn name(&self) -> &'static str {
+ "PointAndFigureBars"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use approx::assert_relative_eq;
+
+ fn flat(price: f64) -> Candle {
+ Candle::new(price, price, price, price, 1.0, 0).unwrap()
+ }
+
+ #[test]
+ fn rejects_invalid_box_size() {
+ assert!(matches!(
+ PointAndFigureBars::new(0.0, 3),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ assert!(matches!(
+ PointAndFigureBars::new(f64::NAN, 3),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ }
+
+ #[test]
+ fn rejects_zero_reversal() {
+ assert!(matches!(
+ PointAndFigureBars::new(1.0, 0),
+ Err(Error::PeriodZero)
+ ));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let pnf = PointAndFigureBars::new(0.5, 3).unwrap();
+ assert_eq!(pnf.name(), "PointAndFigureBars");
+ assert_relative_eq!(pnf.box_size(), 0.5, epsilon = 1e-12);
+ assert_eq!(pnf.reversal(), 3);
+ }
+
+ #[test]
+ fn first_candle_seeds_without_column() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ assert!(pnf.update(flat(10.0)).is_empty());
+ }
+
+ #[test]
+ fn establishes_up_then_extends() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ pnf.update(flat(10.0));
+ assert!(pnf.update(flat(13.0)).is_empty()); // start X column
+ assert!(pnf.update(flat(15.0)).is_empty()); // extend up, no completed column
+ }
+
+ #[test]
+ fn establishes_down_direction() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ pnf.update(flat(10.0));
+ assert!(pnf.update(flat(7.0)).is_empty()); // start O column
+ }
+
+ #[test]
+ fn reversal_closes_x_column() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ pnf.update(flat(10.0));
+ pnf.update(flat(13.0));
+ pnf.update(flat(15.0));
+ let cols = pnf.update(flat(12.0)); // 3-box drop from 15
+ assert_eq!(cols.len(), 1);
+ assert_eq!(cols[0].direction, 1);
+ assert_relative_eq!(cols[0].high, 15.0, epsilon = 1e-12);
+ assert_relative_eq!(cols[0].low, 10.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn reversal_closes_o_column() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ pnf.update(flat(10.0));
+ pnf.update(flat(13.0));
+ pnf.update(flat(15.0));
+ pnf.update(flat(12.0)); // now O column from 14 down
+ pnf.update(flat(10.0)); // extend O down to 10
+ let cols = pnf.update(flat(15.0)); // 3-box rise -> closes O column
+ assert_eq!(cols.len(), 1);
+ assert_eq!(cols[0].direction, -1);
+ assert_relative_eq!(cols[0].low, 10.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn small_move_prints_nothing() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ pnf.update(flat(10.0));
+ pnf.update(flat(13.0));
+ pnf.update(flat(15.0));
+ assert!(pnf.update(flat(14.0)).is_empty()); // 1-box pullback < 3
+ }
+
+ #[test]
+ fn down_column_small_bounce_prints_nothing() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ pnf.update(flat(10.0));
+ pnf.update(flat(7.0)); // O column
+ pnf.update(flat(5.0)); // extend down
+ assert!(pnf.update(flat(6.0)).is_empty()); // 1-box bounce < 3
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ pnf.update(flat(10.0));
+ pnf.update(flat(15.0));
+ pnf.reset();
+ assert!(pnf.update(flat(99.0)).is_empty()); // re-seeds
+ assert!(pnf.update(flat(100.0)).is_empty()); // first move after reseed
+ }
+
+ #[test]
+ fn batch_collects_completed_columns() {
+ let mut pnf = PointAndFigureBars::new(1.0, 3).unwrap();
+ let candles = [
+ flat(10.0),
+ flat(15.0), // X column
+ flat(12.0), // reversal -> closes X
+ flat(9.0), // extend O
+ flat(15.0), // reversal -> closes O
+ ];
+ let cols = pnf.batch(&candles);
+ assert_eq!(cols.len(), 2);
+ assert_eq!(cols[0].direction, 1);
+ assert_eq!(cols[1].direction, -1);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/renko_bars.rs b/crates/wickra-core/src/indicators/renko_bars.rs
new file mode 100644
index 00000000..eb6897e1
--- /dev/null
+++ b/crates/wickra-core/src/indicators/renko_bars.rs
@@ -0,0 +1,267 @@
+//! Renko bar builder — fixed box-size bricks with the classic reversal rule.
+
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::BarBuilder;
+
+/// One completed Renko brick.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct RenkoBrick {
+ /// Price at the brick's origin edge.
+ pub open: f64,
+ /// Price at the brick's far edge (`open ± box_size`).
+ pub close: f64,
+ /// `+1` for an up brick, `-1` for a down brick.
+ pub direction: i8,
+}
+
+/// Renko bar builder using the fixed box-size method on close prices.
+///
+/// Construction follows the classic Renko rules:
+///
+/// - The first candle seeds the reference level and prints no brick.
+/// - While the trend continues, every additional `box_size` of close movement
+/// prints one more brick in the trend direction.
+/// - A reversal requires `2 * box_size` against the trend (one box to unwind the
+/// last brick's body, one to print the first opposite brick); thereafter each
+/// further `box_size` prints another brick.
+///
+/// A single candle whose close gaps several boxes prints all the bricks it
+/// completes in one [`BarBuilder::update`] call. Bricks are perfectly aligned to
+/// the `box_size` grid relative to the seed price.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{BarBuilder, Candle, RenkoBars};
+///
+/// let flat = |price: f64| Candle::new(price, price, price, price, 1.0, 0).unwrap();
+/// let mut renko = RenkoBars::new(1.0).unwrap();
+/// assert!(renko.update(flat(10.0)).is_empty()); // seed
+/// let bricks = renko.update(flat(13.0)); // +3 boxes
+/// assert_eq!(bricks.len(), 3);
+/// assert!(bricks.iter().all(|b| b.direction == 1));
+/// ```
+#[derive(Debug, Clone)]
+pub struct RenkoBars {
+ box_size: f64,
+ level: Option,
+ dir: i8,
+}
+
+impl RenkoBars {
+ /// Construct a Renko builder with the given brick size.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::InvalidPeriod`] if `box_size` is not finite and positive.
+ pub fn new(box_size: f64) -> Result {
+ if !box_size.is_finite() || box_size <= 0.0 {
+ return Err(Error::InvalidPeriod {
+ message: "box_size must be finite and positive",
+ });
+ }
+ Ok(Self {
+ box_size,
+ level: None,
+ dir: 0,
+ })
+ }
+
+ /// Configured brick size.
+ pub const fn box_size(&self) -> f64 {
+ self.box_size
+ }
+
+ /// Current reference level (the close of the last completed brick, or the
+ /// seed price before any brick has formed).
+ pub const fn level(&self) -> Option {
+ self.level
+ }
+}
+
+impl BarBuilder for RenkoBars {
+ type Bar = RenkoBrick;
+
+ fn update(&mut self, candle: Candle) -> Vec {
+ let close = candle.close;
+ let Some(mut level) = self.level else {
+ self.level = Some(close);
+ return Vec::new();
+ };
+ let box_size = self.box_size;
+ let two = 2.0 * box_size;
+ let mut bricks = Vec::new();
+ loop {
+ if self.dir >= 0 && close >= level + box_size {
+ bricks.push(RenkoBrick {
+ open: level,
+ close: level + box_size,
+ direction: 1,
+ });
+ level += box_size;
+ self.dir = 1;
+ } else if self.dir <= 0 && close <= level - box_size {
+ bricks.push(RenkoBrick {
+ open: level,
+ close: level - box_size,
+ direction: -1,
+ });
+ level -= box_size;
+ self.dir = -1;
+ } else if self.dir > 0 && close <= level - two {
+ bricks.push(RenkoBrick {
+ open: level - box_size,
+ close: level - two,
+ direction: -1,
+ });
+ level -= two;
+ self.dir = -1;
+ } else if self.dir < 0 && close >= level + two {
+ bricks.push(RenkoBrick {
+ open: level + box_size,
+ close: level + two,
+ direction: 1,
+ });
+ level += two;
+ self.dir = 1;
+ } else {
+ break;
+ }
+ }
+ self.level = Some(level);
+ bricks
+ }
+
+ fn reset(&mut self) {
+ self.level = None;
+ self.dir = 0;
+ }
+
+ fn name(&self) -> &'static str {
+ "RenkoBars"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use approx::assert_relative_eq;
+
+ fn flat(price: f64) -> Candle {
+ Candle::new(price, price, price, price, 1.0, 0).unwrap()
+ }
+
+ #[test]
+ fn rejects_invalid_box_size() {
+ assert!(matches!(
+ RenkoBars::new(0.0),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ assert!(matches!(
+ RenkoBars::new(-1.0),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ assert!(matches!(
+ RenkoBars::new(f64::NAN),
+ Err(Error::InvalidPeriod { .. })
+ ));
+ }
+
+ #[test]
+ fn accessors_and_metadata() {
+ let renko = RenkoBars::new(2.5).unwrap();
+ assert_eq!(renko.name(), "RenkoBars");
+ assert_relative_eq!(renko.box_size(), 2.5, epsilon = 1e-12);
+ assert_eq!(renko.level(), None);
+ }
+
+ #[test]
+ fn first_candle_seeds_without_brick() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ assert!(renko.update(flat(10.0)).is_empty());
+ assert_eq!(renko.level(), Some(10.0));
+ }
+
+ #[test]
+ fn up_trend_prints_aligned_bricks() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ renko.update(flat(10.0));
+ let bricks = renko.update(flat(13.0));
+ assert_eq!(bricks.len(), 3);
+ assert_relative_eq!(bricks[0].open, 10.0, epsilon = 1e-12);
+ assert_relative_eq!(bricks[0].close, 11.0, epsilon = 1e-12);
+ assert_relative_eq!(bricks[2].close, 13.0, epsilon = 1e-12);
+ assert!(bricks.iter().all(|b| b.direction == 1));
+ assert_eq!(renko.level(), Some(13.0));
+ }
+
+ #[test]
+ fn down_trend_prints_aligned_bricks() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ renko.update(flat(10.0));
+ let bricks = renko.update(flat(7.0));
+ assert_eq!(bricks.len(), 3);
+ assert!(bricks.iter().all(|b| b.direction == -1));
+ assert_relative_eq!(bricks[2].close, 7.0, epsilon = 1e-12);
+ assert_eq!(renko.level(), Some(7.0));
+ }
+
+ #[test]
+ fn reversal_down_needs_two_boxes() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ renko.update(flat(10.0));
+ renko.update(flat(13.0)); // level 13, dir up
+ let bricks = renko.update(flat(10.0)); // drop of 3 -> reversal eats one box
+ assert_eq!(bricks.len(), 2);
+ assert!(bricks.iter().all(|b| b.direction == -1));
+ assert_relative_eq!(bricks[0].open, 12.0, epsilon = 1e-12);
+ assert_relative_eq!(bricks[0].close, 11.0, epsilon = 1e-12);
+ assert_relative_eq!(bricks[1].close, 10.0, epsilon = 1e-12);
+ assert_eq!(renko.level(), Some(10.0));
+ }
+
+ #[test]
+ fn reversal_up_needs_two_boxes() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ renko.update(flat(10.0));
+ renko.update(flat(7.0)); // level 7, dir down
+ let bricks = renko.update(flat(10.0)); // rise of 3 -> reversal
+ assert_eq!(bricks.len(), 2);
+ assert!(bricks.iter().all(|b| b.direction == 1));
+ assert_relative_eq!(bricks[0].open, 8.0, epsilon = 1e-12);
+ assert_relative_eq!(bricks[0].close, 9.0, epsilon = 1e-12);
+ assert_relative_eq!(bricks[1].close, 10.0, epsilon = 1e-12);
+ }
+
+ #[test]
+ fn small_move_prints_nothing() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ renko.update(flat(10.0));
+ renko.update(flat(13.0));
+ assert!(renko.update(flat(12.5)).is_empty()); // less than a reversal
+ assert_eq!(renko.level(), Some(13.0));
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ renko.update(flat(10.0));
+ renko.update(flat(13.0));
+ renko.reset();
+ assert_eq!(renko.level(), None);
+ // After reset the next candle seeds again.
+ assert!(renko.update(flat(50.0)).is_empty());
+ assert_eq!(renko.level(), Some(50.0));
+ }
+
+ #[test]
+ fn batch_concatenates_completed_bricks() {
+ let mut renko = RenkoBars::new(1.0).unwrap();
+ let candles = [flat(10.0), flat(12.0), flat(13.0)];
+ let bricks = renko.batch(&candles);
+ // seed at 10, then +2 then +1 => 3 up bricks total.
+ assert_eq!(bricks.len(), 3);
+ assert!(bricks.iter().all(|b| b.direction == 1));
+ }
+}
diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs
index d360c57d..688ec434 100644
--- a/crates/wickra-core/src/lib.rs
+++ b/crates/wickra-core/src/lib.rs
@@ -76,44 +76,51 @@ pub use indicators::{
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
HomingPigeon, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput,
IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
- InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion,
- Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda,
- LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
- LinRegChannel, LinRegChannelOutput, LinRegSlope, LinearRegression, LiquidationFeatures,
- LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope,
- MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
- Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, McGinleyDynamic,
- MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, MorningDojiStar,
+ InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, KagiBars, Kama,
+ KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, Kst, KstOutput, Kurtosis,
+ Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
+ LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegSlope,
+ LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine,
+ LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdIndicator, MacdOutput, Mama, MamaOutput,
+ MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
+ McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, Mom, MorningDojiStar,
MorningEveningStar, Natr, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck,
OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex, PairSpreadZScore, PairwiseBeta,
ParkinsonVolatility, PearsonCorrelation, PercentB, PercentageTrailingStop, Pgo,
- PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread,
- RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoTrailingStop, RickshawMan,
- RisingThreeMethods, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi,
- RviVolatility, Rwi, RwiOutput, SeparatingLines, SharpeRatio, ShootingStar, ShortLine,
- SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation,
- SpinningTop, StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput,
- StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi,
- Stochastic, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TakerBuySellRatio,
- Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
- TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
- TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis,
- ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting,
- Tii, TpoProfile, TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi,
- Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex,
- UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
- ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
- VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput,
- Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput,
- WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots,
- WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput,
- ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
+ PiercingDarkCloud, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread,
+ RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput,
+ RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, RogersSatchellVolatility,
+ RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SeparatingLines,
+ SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
+ SortinoRatio, SpearmanCorrelation, SpinningTop, StalledPattern, StandardError,
+ StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
+ StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother,
+ SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
+ TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
+ TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
+ TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike,
+ ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, Tii, TpoProfile,
+ TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv, TtmSqueeze,
+ TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
+ UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
+ ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop, VolumeOscillator,
+ VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vwap,
+ VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose,
+ WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput,
+ YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput,
+ Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
// strips only `*Output` companions) does not count it as a separate indicator.
pub use indicators::FootprintLevel;
+// Bar element types for the alt-chart builders, re-exported on their own lines so
+// the indicator-count tooling (which scans only the braced block above) does not
+// count them as separate indicators.
+pub use indicators::KagiBar;
+pub use indicators::PnfColumn;
+pub use indicators::RenkoBrick;
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
pub use ohlcv::{Candle, Tick};
-pub use traits::{BatchExt, Chain, Indicator};
+pub use traits::{BarBuilder, BatchExt, Chain, Indicator};
diff --git a/crates/wickra-core/src/traits.rs b/crates/wickra-core/src/traits.rs
index 3ccba7d2..babd5d4b 100644
--- a/crates/wickra-core/src/traits.rs
+++ b/crates/wickra-core/src/traits.rs
@@ -1,5 +1,7 @@
//! Core traits: the [`Indicator`] state machine and the [`BatchExt`] blanket extension.
+use crate::ohlcv::Candle;
+
/// A streaming technical indicator.
///
/// Every indicator in Wickra implements this trait. The contract is:
@@ -88,6 +90,58 @@ pub trait BatchExt: Indicator {
impl BatchExt for T {}
+/// A streaming *bar builder* — an alternative-chart constructor (Renko, Kagi,
+/// Point-and-Figure) that turns a candle stream into a stream of price-driven
+/// bars.
+///
+/// Bar builders are deliberately **not** [`Indicator`]s: a single input candle
+/// may complete zero, one, or many bars (a large move can print several Renko
+/// bricks at once), which breaks the `update -> Option