diff --git a/CHANGELOG.md b/CHANGELOG.md index 837f1d0e..cad34dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Cup and Handle** — rounded base with a shallow handle near the rim; bullish +1, inverse -1 (`CUP_AND_HANDLE`). +- **Rectangle / Range** — flat support and resistance; mean-reversion signal off the just-touched boundary; support +1, resistance -1 (`RECTANGLE_RANGE`). +- **Flag / Pennant** — shallow consolidation against a sharp pole; continuation in the pole direction; bull +1, bear -1 (`FLAG_PENNANT`). +- **Wedge (rising/falling)** — both trendlines slope the same way but converge; rising wedge -1, falling wedge +1 (`WEDGE`). +- **Triangle (asc/desc/sym)** — converging trendlines; ascending +1, descending -1, symmetrical follows the last swing (`TRIANGLE`). +- **Head and Shoulders** — central head flanked by two matching shoulders over a flat neckline; top -1, inverse +1 (`HEAD_AND_SHOULDERS`). +- **Triple Top / Bottom** — three matching peaks / troughs; a stronger reversal than the double; bearish -1, bullish +1 (`TRIPLE_TOP_BOTTOM`). +- **Double Top / Bottom** — twin-peak / twin-trough reversal confirmed on the second matching swing extreme; bearish -1, bullish +1 (`DOUBLE_TOP_BOTTOM`). + ## [0.5.1] - 2026-06-03 ### Added — Seasonality & Session family (12 indicators) diff --git a/README.md b/README.md index a184f697..f6fcdfa0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Wickra — streaming-first technical indicators + Wickra — streaming-first technical indicators

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -48,7 +48,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 351 indicators; start at the + every one of the 359 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), @@ -136,7 +136,7 @@ python -m benchmarks.compare_libraries ## Indicators -351 streaming-first indicators across twenty-one families. Every one passes the +359 streaming-first indicators across twenty-two 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). @@ -158,6 +158,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview). | 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 | +| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle | | 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 | | Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range | @@ -242,7 +243,7 @@ A Python live-trading example using the public `websockets` package lives at ``` wickra/ ├── crates/ -│ ├── wickra-core/ core engine + all 351 indicators +│ ├── wickra-core/ core engine + all 359 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__/indicators.test.js b/bindings/node/__tests__/indicators.test.js index 107c17c2..37d7f33e 100644 --- a/bindings/node/__tests__/indicators.test.js +++ b/bindings/node/__tests__/indicators.test.js @@ -297,6 +297,14 @@ const candleScalar = { TasukiGap: { make: () => new wickra.TasukiGap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, UniqueThreeRiver: { make: () => new wickra.UniqueThreeRiver(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, ConcealingBabySwallow: { make: () => new wickra.ConcealingBabySwallow(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + DoubleTopBottom: { make: () => new wickra.DoubleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + TripleTopBottom: { make: () => new wickra.TripleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + HeadAndShoulders: { make: () => new wickra.HeadAndShoulders(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Triangle: { make: () => new wickra.Triangle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + Wedge: { make: () => new wickra.Wedge(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + FlagPennant: { make: () => new wickra.FlagPennant(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + RectangleRange: { make: () => new wickra.RectangleRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, + CupAndHandle: { make: () => new wickra.CupAndHandle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) }, }; for (const [name, d] of Object.entries(candleScalar)) { diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index 7684f745..6e9d82d3 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -3032,6 +3032,78 @@ export declare class ConcealingBabySwallow { isReady(): boolean warmupPeriod(): number } +export type DoubleTopBottomNode = DoubleTopBottom +export declare class DoubleTopBottom { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type TripleTopBottomNode = TripleTopBottom +export declare class TripleTopBottom { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type HeadAndShouldersNode = HeadAndShoulders +export declare class HeadAndShoulders { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type TriangleNode = Triangle +export declare class Triangle { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type WedgeNode = Wedge +export declare class Wedge { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type FlagPennantNode = FlagPennant +export declare class FlagPennant { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type RectangleRangeNode = RectangleRange +export declare class RectangleRange { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} +export type CupAndHandleNode = CupAndHandle +export declare class CupAndHandle { + constructor() + update(open: number, high: number, low: number, close: number): number | null + batch(open: Array, high: Array, low: Array, close: Array): Array + reset(): void + isReady(): boolean + warmupPeriod(): number +} export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1 export declare class OrderBookImbalanceTop1 { constructor() diff --git a/bindings/node/index.js b/bindings/node/index.js index 04cd8e3f..cfbdb0cc 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, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, 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, HT_DCPHASE, HT_TRENDMODE, 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, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn } = 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, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, 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, HT_DCPHASE, HT_TRENDMODE, 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, DoubleTopBottom, TripleTopBottom, HeadAndShoulders, Triangle, Wedge, FlagPennant, RectangleRange, CupAndHandle, 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, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn } = nativeBinding module.exports.version = version module.exports.SMA = SMA @@ -592,6 +592,14 @@ module.exports.OpeningMarubozu = OpeningMarubozu module.exports.TasukiGap = TasukiGap module.exports.UniqueThreeRiver = UniqueThreeRiver module.exports.ConcealingBabySwallow = ConcealingBabySwallow +module.exports.DoubleTopBottom = DoubleTopBottom +module.exports.TripleTopBottom = TripleTopBottom +module.exports.HeadAndShoulders = HeadAndShoulders +module.exports.Triangle = Triangle +module.exports.Wedge = Wedge +module.exports.FlagPennant = FlagPennant +module.exports.RectangleRange = RectangleRange +module.exports.CupAndHandle = CupAndHandle module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1 module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull module.exports.Microprice = Microprice diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index d9078b54..dcea756c 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -10014,6 +10014,18 @@ node_candle_pattern!( wc::ConcealingBabySwallow, "ConcealingBabySwallow" ); +node_candle_pattern!(DoubleTopBottomNode, wc::DoubleTopBottom, "DoubleTopBottom"); +node_candle_pattern!(TripleTopBottomNode, wc::TripleTopBottom, "TripleTopBottom"); +node_candle_pattern!( + HeadAndShouldersNode, + wc::HeadAndShoulders, + "HeadAndShoulders" +); +node_candle_pattern!(TriangleNode, wc::Triangle, "Triangle"); +node_candle_pattern!(WedgeNode, wc::Wedge, "Wedge"); +node_candle_pattern!(FlagPennantNode, wc::FlagPennant, "FlagPennant"); +node_candle_pattern!(RectangleRangeNode, wc::RectangleRange, "RectangleRange"); +node_candle_pattern!(CupAndHandleNode, wc::CupAndHandle, "CupAndHandle"); // ============================== Microstructure: Order Book ============================== // diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py index 1c4c9ec1..5f4dea8f 100644 --- a/bindings/python/python/wickra/__init__.py +++ b/bindings/python/python/wickra/__init__.py @@ -321,6 +321,15 @@ from ._wickra import ( TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, + # Chart patterns + CupAndHandle, + RectangleRange, + FlagPennant, + Wedge, + Triangle, + HeadAndShoulders, + TripleTopBottom, + DoubleTopBottom, # Microstructure: order book OrderBookImbalanceTop1, OrderBookImbalanceTopN, @@ -698,6 +707,15 @@ __all__ = [ "TasukiGap", "UniqueThreeRiver", "ConcealingBabySwallow", + # Chart patterns + "CupAndHandle", + "RectangleRange", + "FlagPennant", + "Wedge", + "Triangle", + "HeadAndShoulders", + "TripleTopBottom", + "DoubleTopBottom", # Microstructure: order book "OrderBookImbalanceTop1", "OrderBookImbalanceTopN", diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 5ece3b7c..891636bb 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -13675,6 +13675,14 @@ candle_pattern_no_param!( wc::ConcealingBabySwallow, "ConcealingBabySwallow" ); +candle_pattern_no_param!(PyDoubleTopBottom, wc::DoubleTopBottom, "DoubleTopBottom"); +candle_pattern_no_param!(PyTripleTopBottom, wc::TripleTopBottom, "TripleTopBottom"); +candle_pattern_no_param!(PyHeadAndShoulders, wc::HeadAndShoulders, "HeadAndShoulders"); +candle_pattern_no_param!(PyTriangle, wc::Triangle, "Triangle"); +candle_pattern_no_param!(PyWedge, wc::Wedge, "Wedge"); +candle_pattern_no_param!(PyFlagPennant, wc::FlagPennant, "FlagPennant"); +candle_pattern_no_param!(PyRectangleRange, wc::RectangleRange, "RectangleRange"); +candle_pattern_no_param!(PyCupAndHandle, wc::CupAndHandle, "CupAndHandle"); // ============================== Microstructure: Order Book ============================== // // Order-book indicators consume a depth snapshot rather than OHLCV. Streaming @@ -18196,5 +18204,13 @@ 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::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/bindings/python/tests/test_new_indicators.py b/bindings/python/tests/test_new_indicators.py index fe959d02..1fd657e2 100644 --- a/bindings/python/tests/test_new_indicators.py +++ b/bindings/python/tests/test_new_indicators.py @@ -330,6 +330,38 @@ def test_relative_strength_streaming_matches_batch(): # 6-tuple candle; the batch helper takes only the columns it needs. CANDLE_SCALAR = { + "CupAndHandle": ( + lambda: ta.CupAndHandle(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "RectangleRange": ( + lambda: ta.RectangleRange(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "FlagPennant": ( + lambda: ta.FlagPennant(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Wedge": ( + lambda: ta.Wedge(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "Triangle": ( + lambda: ta.Triangle(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "HeadAndShoulders": ( + lambda: ta.HeadAndShoulders(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "TripleTopBottom": ( + lambda: ta.TripleTopBottom(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), + "DoubleTopBottom": ( + lambda: ta.DoubleTopBottom(), + lambda ind, h, l, c, v: ind.batch(c, h, l, c), + ), "MIDPRICE": (lambda: ta.MIDPRICE(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), "AVGPRICE": (lambda: ta.AVGPRICE(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)), "DX": (lambda: ta.DX(14), lambda ind, h, l, c, v: ind.batch(h, l, c)), @@ -2354,6 +2386,81 @@ def test_granger_causality_reference(): assert t.update(1.0, 1.0) is None assert t.update(2.0, 1.5) is None + +def test_double_top_bottom_reference(): + t = ta.DoubleTopBottom() + assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0) + assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0) + assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 3)) == pytest.approx(-1.0) + + +def test_triple_top_bottom_reference(): + t = ta.TripleTopBottom() + assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0) + assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0) + assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0) + assert t.update((99.99, 119.0, 99.99, 99.99, 1.0, 4)) == pytest.approx(0.0) + assert t.update((107.1, 117.81, 107.1, 107.1, 1.0, 5)) == pytest.approx(-1.0) + + +def test_head_and_shoulders_reference(): + t = ta.HeadAndShoulders() + assert t.update((99.9, 100.0, 99.9, 99.9, 1.0, 0)) == pytest.approx(0.0) + assert t.update((90.0, 99.0, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((90.9, 120.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0) + assert t.update((92.0, 118.8, 92.0, 92.0, 1.0, 3)) == pytest.approx(0.0) + assert t.update((92.92, 101.0, 92.92, 92.92, 1.0, 4)) == pytest.approx(0.0) + assert t.update((90.9, 99.99, 90.9, 90.9, 1.0, 5)) == pytest.approx(-1.0) + + +def test_triangle_reference(): + t = ta.Triangle() + assert t.update((129.87, 130.0, 129.87, 129.87, 1.0, 0)) == pytest.approx(0.0) + assert t.update((100.0, 128.7, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0) + assert t.update((110.0, 118.8, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0) + assert t.update((111.1, 120.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0) + assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 5)) == pytest.approx(1.0) + + +def test_wedge_reference(): + t = ta.Wedge() + assert t.update((109.89, 110.0, 109.89, 109.89, 1.0, 0)) == pytest.approx(0.0) + assert t.update((90.0, 108.9, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((90.9, 100.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0) + assert t.update((94.0, 99.0, 94.0, 94.0, 1.0, 3)) == pytest.approx(0.0) + assert t.update((94.94, 103.0, 94.94, 94.94, 1.0, 4)) == pytest.approx(0.0) + assert t.update((92.7, 101.97, 92.7, 92.7, 1.0, 5)) == pytest.approx(-1.0) + + +def test_flag_pennant_reference(): + t = ta.FlagPennant() + assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0) + assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0) + assert t.update((130.0, 138.6, 130.0, 130.0, 1.0, 3)) == pytest.approx(0.0) + assert t.update((131.3, 143.0, 131.3, 131.3, 1.0, 4)) == pytest.approx(1.0) + + +def test_rectangle_range_reference(): + t = ta.RectangleRange() + assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0) + assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0) + assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0) + assert t.update((99.99, 108.9, 99.99, 99.99, 1.0, 4)) == pytest.approx(1.0) + + +def test_cup_and_handle_reference(): + t = ta.CupAndHandle() + assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0) + assert t.update((90.0, 118.8, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0) + assert t.update((90.9, 121.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0) + assert t.update((110.0, 119.79, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0) + assert t.update((111.1, 121.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0) + # --- Lifecycle ------------------------------------------------------------ diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index cab714de..85f76bb7 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -7354,6 +7354,14 @@ wasm_candle_pattern!( wc::ConcealingBabySwallow, ConcealingBabySwallow ); +wasm_candle_pattern!(WasmDoubleTopBottom, wc::DoubleTopBottom, DoubleTopBottom); +wasm_candle_pattern!(WasmTripleTopBottom, wc::TripleTopBottom, TripleTopBottom); +wasm_candle_pattern!(WasmHeadAndShoulders, wc::HeadAndShoulders, HeadAndShoulders); +wasm_candle_pattern!(WasmTriangle, wc::Triangle, Triangle); +wasm_candle_pattern!(WasmWedge, wc::Wedge, Wedge); +wasm_candle_pattern!(WasmFlagPennant, wc::FlagPennant, FlagPennant); +wasm_candle_pattern!(WasmRectangleRange, wc::RectangleRange, RectangleRange); +wasm_candle_pattern!(WasmCupAndHandle, wc::CupAndHandle, CupAndHandle); // ============================== Microstructure: Order Book ============================== // diff --git a/crates/wickra-core/src/indicators/cup_and_handle.rs b/crates/wickra-core/src/indicators/cup_and_handle.rs new file mode 100644 index 00000000..5a5544ed --- /dev/null +++ b/crates/wickra-core/src/indicators/cup_and_handle.rs @@ -0,0 +1,176 @@ +//! Cup-and-Handle (and Inverse) continuation chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Cup-and-Handle / Inverse — a rounded base (the cup) followed by a shallow +/// pullback (the handle) near the rim, then a breakout in the cup's direction. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%) and read from the +/// last four pivots: +/// +/// ```text +/// cup-and-handle (bullish, +1): Rim(high) , Cup(low) , Rim(high) , Handle(low) +/// the two rims match (±3%) ; the handle low sits ABOVE the cup low (a shallow +/// pullback) and below the right rim +/// +/// inverse (bearish, -1): Rim(low) , Cap(high) , Rim(low) , Handle(high) +/// the two rims match ; the handle high sits BELOW the cap high and above the +/// right rim +/// ``` +/// +/// The shallow handle (closer to the rim than the cup extreme) is what +/// distinguishes a cup-and-handle from a plain double bottom/top. Output is +/// `+1.0` / `-1.0` / `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct CupAndHandle { + swing: SwingTracker, + has_emitted: bool, +} + +impl CupAndHandle { + /// Construct a new Cup-and-Handle detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for CupAndHandle { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for CupAndHandle { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let n = pivots.len(); + let rim_left = pivots[n - 4]; + let extreme = pivots[n - 3]; + let rim_right = pivots[n - 2]; + let handle = pivots[n - 1]; + let rims_match = approx_equal(rim_left.price, rim_right.price, LEVEL_TOLERANCE); + + if handle.direction < 0.0 { + // Bullish cup-and-handle: rims are highs, cup is the low between them, + // handle is a shallow low above the cup but below the right rim. + if rims_match && handle.price > extreme.price && handle.price < rim_right.price { + return Some(1.0); + } + } else if rims_match && handle.price < extreme.price && handle.price > rim_right.price { + // Inverse: rims are lows, cap is the high, handle a shallow high. + return Some(-1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "CupAndHandle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = CupAndHandle::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = CupAndHandle::new(); + assert_eq!(indicator.name(), "CupAndHandle"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!CupAndHandle::default().is_ready()); + } + + #[test] + fn cup_and_handle_is_plus_one() { + // Rims 120/121, cup 90 (deep), handle 110 (shallow, above the cup). + let out = run(&[120.0, 90.0, 121.0, 110.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn inverse_cup_and_handle_is_minus_one() { + // Lead high then rims 100/101, cap 130, handle 110 (below cap, above rim). + let out = run(&[140.0, 100.0, 130.0, 101.0, 110.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn deep_handle_is_not_cup_and_handle() { + // Handle (85) below the cup low (90) → a double bottom, not cup-and-handle. + let out = run(&[120.0, 90.0, 121.0, 85.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn inverse_with_mismatched_rims_does_not_trigger() { + // Inverse shape (ends high) but the rims (100 / 90) diverge → enters the + // inverse branch yet reports no pattern. + let out = run(&[140.0, 100.0, 130.0, 90.0, 110.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = CupAndHandle::new(); + for c in candles_for_pivots(&[120.0, 90.0, 121.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 90.0, 121.0, 110.0]); + let mut a = CupAndHandle::new(); + let mut b = CupAndHandle::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/double_top_bottom.rs b/crates/wickra-core/src/indicators/double_top_bottom.rs new file mode 100644 index 00000000..be8cf903 --- /dev/null +++ b/crates/wickra-core/src/indicators/double_top_bottom.rs @@ -0,0 +1,188 @@ +//! Double Top / Double Bottom reversal chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Double Top / Double Bottom — a two-peak (or two-trough) reversal pattern. +/// +/// The detector tracks confirmed swing pivots (a non-repainting percent-threshold +/// zig-zag, [`SWING_THRESHOLD`] = 5%). A pattern is recognised on the bar that +/// confirms the **second** matching extreme: +/// +/// ```text +/// double top : … High₁ , Low , High₂ with High₁ ≈ High₂ → -1 (bearish) +/// double bottom : … Low₁ , High , Low₂ with Low₁ ≈ Low₂ → +1 (bullish) +/// ``` +/// +/// Two extremes count as the same level when they are within +/// [`LEVEL_TOLERANCE`] (3%) of each other. Because pivots strictly alternate +/// high/low, the trough between the twin tops (or the peak between the twin +/// bottoms) is guaranteed to sit beyond both, so no extra separation check is +/// needed. +/// +/// Output is `+1.0` for a double bottom, `-1.0` for a double top, and `0.0` on +/// every other bar (including warmup and bars that confirm a pivot which does +/// not complete the pattern). Like the candlestick family this detector never +/// returns `None`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, DoubleTopBottom, Indicator}; +/// +/// let mut indicator = DoubleTopBottom::new(); +/// for (i, &(high, low)) in [ +/// (100.0, 99.5), +/// (120.0, 119.5), +/// (110.0, 100.0), // confirms the first top at 120 +/// (120.0, 119.0), // confirms the trough at 100 +/// (115.0, 110.0), // confirms the second top at 120 → double top +/// ] +/// .iter() +/// .enumerate() +/// { +/// let c = Candle::new(low, high, low, low, 1.0, i as i64).unwrap(); +/// let signal = indicator.update(c).unwrap(); +/// if i == 4 { +/// assert_eq!(signal, -1.0); +/// } +/// } +/// ``` +#[derive(Debug, Clone)] +pub struct DoubleTopBottom { + swing: SwingTracker, + has_emitted: bool, +} + +impl DoubleTopBottom { + /// Construct a new Double Top / Double Bottom detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 3), + has_emitted: false, + } + } +} + +impl Default for DoubleTopBottom { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for DoubleTopBottom { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 3 { + return Some(0.0); + } + let first = pivots[pivots.len() - 3]; + let last = pivots[pivots.len() - 1]; + if approx_equal(first.price, last.price, LEVEL_TOLERANCE) { + // `last` is the just-confirmed extreme: a high → double top (bearish), + // a low → double bottom (bullish). + return Some(if last.direction > 0.0 { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // The first complete pattern needs three confirmed pivots; the earliest + // bar that can confirm a third pivot is the fifth. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "DoubleTopBottom" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = DoubleTopBottom::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = DoubleTopBottom::new(); + assert_eq!(indicator.name(), "DoubleTopBottom"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!DoubleTopBottom::default().is_ready()); + } + + #[test] + fn double_top_is_minus_one() { + // Twin highs 120 / 120 with a 100 trough → double top on the second. + let out = run(&[120.0, 100.0, 120.0]); + assert_eq!(*out.last().unwrap(), -1.0); + // All earlier bars are warmup / non-completing. + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn double_bottom_is_plus_one() { + // Lead high, then twin lows 100 / 99 around a 120 peak → double bottom. + let out = run(&[130.0, 100.0, 120.0, 99.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn unequal_tops_do_not_trigger() { + // Second top 140 diverges from the first (120) → no pattern. + let out = run(&[120.0, 100.0, 140.0]); + assert_eq!(*out.last().unwrap(), 0.0); + assert!(out.iter().all(|&x| x == 0.0)); + } + + #[test] + fn reset_clears_state() { + let mut indicator = DoubleTopBottom::new(); + for c in candles_for_pivots(&[120.0, 100.0, 120.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 100.0, 120.0]); + let mut a = DoubleTopBottom::new(); + let mut b = DoubleTopBottom::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/flag_pennant.rs b/crates/wickra-core/src/indicators/flag_pennant.rs new file mode 100644 index 00000000..0fc5bf9a --- /dev/null +++ b/crates/wickra-core/src/indicators/flag_pennant.rs @@ -0,0 +1,162 @@ +//! Flag / Pennant continuation chart pattern. + +use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Maximum size of the consolidation swing relative to the pole for a +/// flag/pennant to qualify — the pullback must retrace less than half the pole. +const MAX_RETRACE_FRACTION: f64 = 0.5; + +/// Flag / Pennant — a brief consolidation against a sharp prior move (the +/// "pole"), resolving in the pole's direction. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the +/// last three pivots `pole_start → pole_end → consolidation`: +/// +/// ```text +/// pole = |pole_end − pole_start| (the sharp impulse) +/// pullback = |consolidation − pole_end| (the shallow counter-move) +/// qualifies when pullback < 0.5 · pole +/// bull flag : pole_end is a swing high → +1 (up-pole, continuation up) +/// bear flag : pole_end is a swing low → -1 (down-pole, continuation down) +/// ``` +/// +/// The detector fires on the bar that confirms the consolidation pivot (the flag +/// is complete; the breakout is expected to follow). Output is `+1.0` / `-1.0` / +/// `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct FlagPennant { + swing: SwingTracker, + has_emitted: bool, +} + +impl FlagPennant { + /// Construct a new Flag / Pennant detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 3), + has_emitted: false, + } + } +} + +impl Default for FlagPennant { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for FlagPennant { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 3 { + return Some(0.0); + } + let n = pivots.len(); + let pole_start = pivots[n - 3]; + let pole_end = pivots[n - 2]; + let consolidation = pivots[n - 1]; + let pole = (pole_end.price - pole_start.price).abs(); + let pullback = (consolidation.price - pole_end.price).abs(); + + if pole > 0.0 && pullback < MAX_RETRACE_FRACTION * pole { + // pole_end a high → up-pole → bull flag; a low → bear flag. + return Some(if pole_end.direction > 0.0 { 1.0 } else { -1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Three confirmed pivots; the earliest confirmation of the third is bar 4. + 4 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "FlagPennant" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = FlagPennant::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = FlagPennant::new(); + assert_eq!(indicator.name(), "FlagPennant"); + assert_eq!(indicator.warmup_period(), 4); + assert!(!indicator.is_ready()); + assert!(!FlagPennant::default().is_ready()); + } + + #[test] + fn bull_flag_is_plus_one() { + // Up-pole 100 → 140 (40), shallow pullback to 130 (10 < 20) → bull flag. + let out = run(&[150.0, 100.0, 140.0, 130.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn bear_flag_is_minus_one() { + // Down-pole 140 → 100 (40), shallow pullback to 110 (10 < 20) → bear flag. + let out = run(&[140.0, 100.0, 110.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn deep_pullback_is_not_a_flag() { + // Pole 100 → 140 (40) but pullback to 104 (36 > 20) → not a flag. + let out = run(&[150.0, 100.0, 140.0, 104.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = FlagPennant::new(); + for c in candles_for_pivots(&[150.0, 100.0, 140.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 130.0]); + let mut a = FlagPennant::new(); + let mut b = FlagPennant::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/head_and_shoulders.rs b/crates/wickra-core/src/indicators/head_and_shoulders.rs new file mode 100644 index 00000000..24d536ca --- /dev/null +++ b/crates/wickra-core/src/indicators/head_and_shoulders.rs @@ -0,0 +1,191 @@ +//! Head-and-Shoulders (and Inverse) reversal chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Head-and-Shoulders / Inverse Head-and-Shoulders — a five-pivot reversal +/// pattern with a central extreme (the head) flanked by two lower/higher +/// shoulders at a similar level, joined by a roughly horizontal neckline. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised on the +/// bar that confirms the right shoulder: +/// +/// ```text +/// head-and-shoulders top (bearish, -1): +/// LeftShoulder(high) , Trough , Head(high) , Trough , RightShoulder(high) +/// Head > both shoulders ; LeftShoulder ≈ RightShoulder ; Trough₁ ≈ Trough₂ +/// +/// inverse head-and-shoulders (bullish, +1): +/// LeftShoulder(low) , Peak , Head(low) , Peak , RightShoulder(low) +/// Head < both shoulders ; LeftShoulder ≈ RightShoulder ; Peak₁ ≈ Peak₂ +/// ``` +/// +/// The shoulders must match within [`LEVEL_TOLERANCE`] (3%) and the two neckline +/// points within the same tolerance. Output is `-1.0` for a top, `+1.0` for an +/// inverse, `0.0` otherwise; never `None`. +#[derive(Debug, Clone)] +pub struct HeadAndShoulders { + swing: SwingTracker, + has_emitted: bool, +} + +impl HeadAndShoulders { + /// Construct a new Head-and-Shoulders detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for HeadAndShoulders { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for HeadAndShoulders { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let n = pivots.len(); + let left_shoulder = pivots[n - 5]; + let neck_1 = pivots[n - 4]; + let head = pivots[n - 3]; + let neck_2 = pivots[n - 2]; + let right_shoulder = pivots[n - 1]; + + let shoulders_match = + approx_equal(left_shoulder.price, right_shoulder.price, LEVEL_TOLERANCE); + let neckline_flat = approx_equal(neck_1.price, neck_2.price, LEVEL_TOLERANCE); + let head_is_peak = head.price > left_shoulder.price && head.price > right_shoulder.price; + let head_is_trough = head.price < left_shoulder.price && head.price < right_shoulder.price; + let frame_matches = shoulders_match && neckline_flat; + + if right_shoulder.direction > 0.0 { + // Head-and-shoulders top: head is the highest of the three highs. + if head_is_peak && frame_matches { + return Some(-1.0); + } + } else if head_is_trough && frame_matches { + // Inverse: head is the lowest of the three lows. + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Five confirmed pivots; the earliest confirmation of the fifth is bar 6. + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "HeadAndShoulders" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = HeadAndShoulders::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = HeadAndShoulders::new(); + assert_eq!(indicator.name(), "HeadAndShoulders"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!HeadAndShoulders::default().is_ready()); + } + + #[test] + fn head_and_shoulders_top_is_minus_one() { + // LS 100, trough 90, head 120, trough 92, RS 101. + let out = run(&[100.0, 90.0, 120.0, 92.0, 101.0]); + assert_eq!(*out.last().unwrap(), -1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn inverse_head_and_shoulders_is_plus_one() { + // Lead high then LS 100, peak 110, head 80, peak 108, RS 101. + let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 101.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn mismatched_shoulders_do_not_trigger() { + // Right shoulder (115) far from left (100) → no pattern. + let out = run(&[100.0, 90.0, 130.0, 92.0, 115.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn inverse_mismatched_shoulders_do_not_trigger() { + // Inverse shape (ends on a low) but the right shoulder (90) diverges from + // the left (100) → enters the inverse branch yet reports no pattern. + let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 90.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn equal_highs_without_taller_head_do_not_trigger() { + // Three equal highs (no dominant head) → not H&S (that is a triple top). + let out = run(&[120.0, 90.0, 120.0, 92.0, 120.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = HeadAndShoulders::new(); + for c in candles_for_pivots(&[100.0, 90.0, 120.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[100.0, 90.0, 120.0, 92.0, 101.0]); + let mut a = HeadAndShoulders::new(); + let mut b = HeadAndShoulders::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/mod.rs b/crates/wickra-core/src/indicators/mod.rs index 8addd4cf..d2c0d16d 100644 --- a/crates/wickra-core/src/indicators/mod.rs +++ b/crates/wickra-core/src/indicators/mod.rs @@ -4,6 +4,11 @@ //! [`FAMILIES`]. Every public name is re-exported flat from this module and //! from the crate root for convenience. +// Internal shared building block for the chart- and harmonic-pattern detectors. +// Declared `pub(crate)` (not `mod`) so it is excluded from the public-catalogue +// counter (`grep -c '^mod '`) and re-exported nowhere. +pub(crate) mod pattern_swing; + mod abandoned_baby; mod absolute_breadth_index; mod acceleration_bands; @@ -66,6 +71,7 @@ mod connors_rsi; mod coppock; mod counterattack; mod cumulative_volume_index; +mod cup_and_handle; mod cvd; mod cybernetic_cycle; mod day_of_week_profile; @@ -82,6 +88,7 @@ mod doji_star; mod donchian; mod donchian_stop; mod double_bollinger; +mod double_top_bottom; mod downside_gap_three_methods; mod dpo; mod dragonfly_doji; @@ -100,6 +107,7 @@ mod falling_three_methods; mod fama; mod fibonacci_pivots; mod fisher_transform; +mod flag_pennant; mod footprint; mod force_index; mod fractal_chaos_bands; @@ -116,6 +124,7 @@ mod gravestone_doji; mod hammer; mod hanging_man; mod harami; +mod head_and_shoulders; mod heikin_ashi; mod high_low_index; mod high_wave; @@ -229,6 +238,7 @@ mod quoted_spread; mod r_squared; mod realized_spread; mod recovery_factor; +mod rectangle_range; mod relative_strength_ab; mod renko_bars; mod renko_trailing_stop; @@ -308,8 +318,10 @@ mod time_of_day_return_profile; mod tpo_profile; mod trade_imbalance; mod treynor_ratio; +mod triangle; mod trima; mod trin; +mod triple_top_bottom; mod trix; mod true_range; mod tsf; @@ -343,6 +355,7 @@ mod vwap_stddev_bands; mod vwma; mod vzo; mod wave_trend; +mod wedge; mod weighted_close; mod williams_fractals; mod williams_r; @@ -417,6 +430,7 @@ pub use connors_rsi::ConnorsRsi; pub use coppock::Coppock; pub use counterattack::Counterattack; pub use cumulative_volume_index::CumulativeVolumeIndex; +pub use cup_and_handle::CupAndHandle; pub use cvd::CumulativeVolumeDelta; pub use cybernetic_cycle::CyberneticCycle; pub use day_of_week_profile::{DayOfWeekProfile, DayOfWeekProfileOutput}; @@ -433,6 +447,7 @@ pub use doji_star::DojiStar; pub use donchian::{Donchian, DonchianOutput}; pub use donchian_stop::{DonchianStop, DonchianStopOutput}; pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput}; +pub use double_top_bottom::DoubleTopBottom; pub use downside_gap_three_methods::DownsideGapThreeMethods; pub use dpo::Dpo; pub use dragonfly_doji::DragonflyDoji; @@ -451,6 +466,7 @@ pub use falling_three_methods::FallingThreeMethods; pub use fama::Fama; pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput}; pub use fisher_transform::FisherTransform; +pub use flag_pennant::FlagPennant; pub use footprint::{Footprint, FootprintLevel, FootprintOutput}; pub use force_index::ForceIndex; pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput}; @@ -467,6 +483,7 @@ pub use gravestone_doji::GravestoneDoji; pub use hammer::Hammer; pub use hanging_man::HangingMan; pub use harami::Harami; +pub use head_and_shoulders::HeadAndShoulders; pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput}; pub use high_low_index::HighLowIndex; pub use high_wave::HighWave; @@ -580,6 +597,7 @@ pub use quoted_spread::QuotedSpread; pub use r_squared::RSquared; pub use realized_spread::RealizedSpread; pub use recovery_factor::RecoveryFactor; +pub use rectangle_range::RectangleRange; pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput}; pub use renko_bars::{RenkoBars, RenkoBrick}; pub use renko_trailing_stop::RenkoTrailingStop; @@ -659,8 +677,10 @@ pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProf pub use tpo_profile::{TpoProfile, TpoProfileOutput}; pub use trade_imbalance::TradeImbalance; pub use treynor_ratio::TreynorRatio; +pub use triangle::Triangle; pub use trima::Trima; pub use trin::Trin; +pub use triple_top_bottom::TripleTopBottom; pub use trix::Trix; pub use true_range::TrueRange; pub use tsf::Tsf; @@ -694,6 +714,7 @@ pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput}; pub use vwma::Vwma; pub use vzo::Vzo; pub use wave_trend::{WaveTrend, WaveTrendOutput}; +pub use wedge::Wedge; pub use weighted_close::WeightedClose; pub use williams_fractals::{WilliamsFractals, WilliamsFractalsOutput}; pub use williams_r::WilliamsR; @@ -1159,6 +1180,19 @@ pub const FAMILIES: &[(&str, &[&str])] = &[ "VolumeByTimeProfile", ], ), + ( + "Chart Patterns", + &[ + "DoubleTopBottom", + "TripleTopBottom", + "HeadAndShoulders", + "Triangle", + "Wedge", + "FlagPennant", + "RectangleRange", + "CupAndHandle", + ], + ), ]; #[cfg(test)] @@ -1187,6 +1221,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, 351, "FAMILIES total drifted from indicator count"); + assert_eq!(total, 359, "FAMILIES total drifted from indicator count"); } } diff --git a/crates/wickra-core/src/indicators/pattern_swing.rs b/crates/wickra-core/src/indicators/pattern_swing.rs new file mode 100644 index 00000000..fe5462e1 --- /dev/null +++ b/crates/wickra-core/src/indicators/pattern_swing.rs @@ -0,0 +1,402 @@ +//! Internal swing-pivot tracker shared by the chart-pattern and harmonic-pattern +//! detectors. Not a public indicator — it carries no `Indicator` impl and is +//! re-exported nowhere, so it is excluded from the public catalogue counter. +//! +//! The tracker mirrors [`crate::indicators::ZigZag`]'s non-repainting +//! percent-threshold confirmation logic, but differs in two ways that make it a +//! reusable building block rather than a standalone indicator: +//! +//! * It is **parameter-free at the call site** — the reversal threshold is baked +//! in by each detector as a compile-time constant, so construction is +//! infallible (`const fn new`) and there is no user-facing validation branch. +//! * It **accumulates a bounded history** of the most recently confirmed pivots +//! (capped at `cap`), so a detector can inspect the last few swings to match a +//! geometric template (double top, head-and-shoulders, the XABCD legs of a +//! harmonic pattern, …). + +use crate::ohlcv::Candle; + +/// Default fractional reversal threshold for pattern swing detection (5%). A +/// pivot is confirmed once price reverses by this fraction away from the running +/// extreme. Baked in so the pattern detectors stay parameter-free, mirroring the +/// candlestick-pattern family's fixed geometric thresholds. +pub(crate) const SWING_THRESHOLD: f64 = 0.05; + +/// Default relative tolerance for two swing levels to count as "equal" (3%) — +/// the twin tops of a double top, the shoulders of a head-and-shoulders, the +/// flat boundary of a rectangle. +pub(crate) const LEVEL_TOLERANCE: f64 = 0.03; + +/// A confirmed swing pivot: the extreme price the swing turned from and its +/// direction (`+1.0` for a swing high, `-1.0` for a swing low). +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Pivot { + /// Price of the confirmed swing extreme. + pub price: f64, + /// `+1.0` if the pivot is a swing high, `-1.0` if it is a swing low. + pub direction: f64, +} + +/// Non-repainting percent-threshold swing tracker with a bounded pivot history. +/// +/// Feeding a candle returns `true` exactly on the bar where a new pivot is +/// confirmed (price has reversed by the configured fraction away from the +/// running extreme); the newly confirmed pivot is appended to [`pivots`] and the +/// oldest is dropped once the cap is exceeded. Bars that merely extend the +/// running extreme, or that move less than the threshold, return `false`. +/// +/// [`pivots`]: SwingTracker::pivots +#[derive(Debug, Clone)] +pub(crate) struct SwingTracker { + threshold: f64, + cap: usize, + state: Option, + pivots: Vec, +} + +#[derive(Debug, Clone, Copy)] +struct State { + /// `+1.0` while tracking a candidate high (uptrend), `-1.0` while tracking a + /// candidate low (downtrend). + direction: f64, + /// The running candidate extreme price. + extreme: f64, +} + +impl SwingTracker { + /// Construct a tracker with a fractional reversal `threshold` (e.g. `0.05` + /// for 5%) and a pivot history capped at `cap` entries. + /// + /// The threshold is supplied by the detectors as a compile-time constant in + /// `(0, 1)`, so no runtime validation is performed — an out-of-range + /// constant would be a library bug caught by the unit tests, not invalid + /// caller input. + pub(crate) const fn new(threshold: f64, cap: usize) -> Self { + Self { + threshold, + cap, + state: None, + pivots: Vec::new(), + } + } + + /// Feed one candle. Returns `true` when a new pivot was confirmed this bar. + pub(crate) fn update(&mut self, candle: Candle) -> bool { + let Some(s) = self.state else { + // Bootstrap: seed an uptrend tracking the first candle's high. + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + }); + return false; + }; + + if s.direction > 0.0 { + if candle.high > s.extreme { + // Extend the candidate high. + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + }); + return false; + } + if candle.low <= s.extreme * (1.0 - self.threshold) { + // Confirm the swing high; flip to tracking this bar's low. + self.push(Pivot { + price: s.extreme, + direction: 1.0, + }); + self.state = Some(State { + direction: -1.0, + extreme: candle.low, + }); + return true; + } + false + } else { + if candle.low < s.extreme { + // Extend the candidate low. + self.state = Some(State { + direction: -1.0, + extreme: candle.low, + }); + return false; + } + if candle.high >= s.extreme * (1.0 + self.threshold) { + // Confirm the swing low; flip to tracking this bar's high. + self.push(Pivot { + price: s.extreme, + direction: -1.0, + }); + self.state = Some(State { + direction: 1.0, + extreme: candle.high, + }); + return true; + } + false + } + } + + fn push(&mut self, pivot: Pivot) { + self.pivots.push(pivot); + if self.pivots.len() > self.cap { + self.pivots.remove(0); + } + } + + /// The confirmed pivots in chronological order (oldest first, newest last). + pub(crate) fn pivots(&self) -> &[Pivot] { + &self.pivots + } + + /// Clear all state, returning the tracker to its just-constructed condition. + pub(crate) fn reset(&mut self) { + self.state = None; + self.pivots.clear(); + } +} + +/// The two most recent swing highs and lows from the last four (strictly +/// alternating) pivots, returned as `(high_old, high_new, low_old, low_new)`. +/// Used by the converging/diverging trendline patterns (triangle, wedge, +/// rectangle). The slice must hold at least four pivots. +pub(crate) fn recent_legs(pivots: &[Pivot]) -> (f64, f64, f64, f64) { + let n = pivots.len(); + if pivots[n - 1].direction > 0.0 { + // … low_old, high_old, low_new, high_new (newest is a high) + ( + pivots[n - 3].price, + pivots[n - 1].price, + pivots[n - 4].price, + pivots[n - 2].price, + ) + } else { + // … high_old, low_old, high_new, low_new (newest is a low) + ( + pivots[n - 4].price, + pivots[n - 2].price, + pivots[n - 3].price, + pivots[n - 1].price, + ) + } +} + +/// Relative-tolerance equality: `true` when `a` and `b` are within `tol` +/// (a fraction) of the larger magnitude. Used to decide whether two swing +/// levels (the twin highs of a double top, the shoulders of a head-and-shoulders, +/// a harmonic Fibonacci ratio) count as "the same". +pub(crate) fn approx_equal(a: f64, b: f64, tol: f64) -> bool { + let scale = a.abs().max(b.abs()).max(f64::MIN_POSITIVE); + (a - b).abs() <= tol * scale +} + +/// Build a candle sequence that drives a `SwingTracker` (or any detector built +/// on one) to confirm exactly the given alternating pivot prices, in order. +/// +/// `pivots` must start with a **high** and strictly alternate high/low, with +/// each consecutive pair differing by at least the swing threshold (5%) in the +/// correct direction (`high > adjacent low * 1.05`). The returned vector has one +/// seed candle plus one confirming candle per pivot; pivot `k` is confirmed by +/// candle `k + 1`. Only the high/low of each candle is meaningful — the pattern +/// detectors read swings, not bodies. +#[cfg(test)] +pub(crate) fn candles_for_pivots(pivots: &[f64]) -> Vec { + fn bar(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + let mut out = vec![bar(pivots[0], pivots[0] * 0.999, 0)]; + let mut ts: i64 = 0; + for (k, &price) in pivots.iter().enumerate() { + ts += 1; + let is_high = k % 2 == 0; + let next = if k + 1 < pivots.len() { + pivots[k + 1] + } else if is_high { + price * 0.90 + } else { + price * 1.10 + }; + let candle = if is_high { + // Reverse down from the candidate high `price` to confirm it. + bar(price * 0.99, next, ts) + } else { + // Reverse up from the candidate low `price` to confirm it. + bar(next, price * 1.01, ts) + }; + out.push(candle); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn c_hl(high: f64, low: f64, ts: i64) -> Candle { + Candle::new(low, high, low, low, 1.0, ts).unwrap() + } + + #[test] + fn first_bar_only_bootstraps_no_pivot() { + let mut t = SwingTracker::new(0.05, 6); + assert!(!t.update(c_hl(100.0, 99.5, 0))); + assert!(t.pivots().is_empty()); + } + + #[test] + fn extends_candidate_high_without_confirming() { + let mut t = SwingTracker::new(0.10, 6); + assert!(!t.update(c_hl(100.0, 99.5, 0))); + // A higher high merely raises the candidate — no pivot yet. + assert!(!t.update(c_hl(110.0, 109.0, 1))); + assert!(t.pivots().is_empty()); + } + + #[test] + fn uptrend_small_move_does_not_confirm() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); + // A 1% dip is below the 10% threshold — neither extends nor confirms. + assert!(!t.update(c_hl(99.8, 99.0, 1))); + assert!(t.pivots().is_empty()); + } + + #[test] + fn confirms_high_then_low_alternating() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); // seed uptrend + let _ = t.update(c_hl(120.0, 119.5, 1)); // raise candidate high to 120 + // Drop ≥10% below 120 → confirm the high at 120, flip to downtrend. + assert!(t.update(c_hl(101.0, 100.0, 2))); + assert_eq!( + t.pivots().last().copied(), + Some(Pivot { + price: 120.0, + direction: 1.0, + }) + ); + // Now in a downtrend: a lower low extends the candidate low. + assert!(!t.update(c_hl(100.5, 90.0, 3))); + // Rise ≥10% above 90 → confirm the low at 90. + assert!(t.update(c_hl(100.0, 99.0, 4))); + assert_eq!( + t.pivots().last().copied(), + Some(Pivot { + price: 90.0, + direction: -1.0, + }) + ); + } + + #[test] + fn downtrend_tiny_rise_does_not_confirm() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); + let _ = t.update(c_hl(120.0, 119.5, 1)); + let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high, now downtrend at 90 + // A 1% bounce is below threshold — no confirmation, no new candidate low. + assert!(!t.update(c_hl(91.0, 90.5, 3))); + assert_eq!(t.pivots().len(), 1); + } + + #[test] + fn history_is_capped() { + let mut t = SwingTracker::new(0.10, 2); + // Drive an oscillation that confirms several pivots; only the last 2 stay. + let path = [ + (100.0, 99.5), + (120.0, 119.5), + (101.0, 90.0), // confirm 120 (high) + (91.0, 90.5), + (110.0, 109.0), // confirm 90 (low) + (109.0, 95.0), // confirm 110 (high) + ]; + for (i, (h, l)) in path.iter().enumerate() { + let _ = t.update(c_hl(*h, *l, i64::try_from(i).unwrap())); + } + assert_eq!(t.pivots().len(), 2); + // The two most recent confirmations: low 90 then high 110. + assert_eq!(t.pivots()[0].price, 90.0); + assert_eq!(t.pivots()[1].price, 110.0); + } + + #[test] + fn reset_clears_state_and_history() { + let mut t = SwingTracker::new(0.10, 6); + let _ = t.update(c_hl(100.0, 99.5, 0)); + let _ = t.update(c_hl(120.0, 119.5, 1)); + let _ = t.update(c_hl(101.0, 90.0, 2)); + assert_eq!(t.pivots().len(), 1); + t.reset(); + assert!(t.pivots().is_empty()); + // After reset the next bar bootstraps again (returns false). + assert!(!t.update(c_hl(100.0, 99.5, 0))); + } + + #[test] + fn recent_legs_extracts_highs_and_lows_either_ending() { + // Newest pivot a high: [low_old, high_old, low_new, high_new]. + let ending_high = [ + Pivot { + price: 100.0, + direction: -1.0, + }, + Pivot { + price: 120.0, + direction: 1.0, + }, + Pivot { + price: 110.0, + direction: -1.0, + }, + Pivot { + price: 121.0, + direction: 1.0, + }, + ]; + assert_eq!(recent_legs(&ending_high), (120.0, 121.0, 100.0, 110.0)); + // Newest pivot a low: [high_old, low_old, high_new, low_new]. + let ending_low = [ + Pivot { + price: 120.0, + direction: 1.0, + }, + Pivot { + price: 100.0, + direction: -1.0, + }, + Pivot { + price: 110.0, + direction: 1.0, + }, + Pivot { + price: 99.0, + direction: -1.0, + }, + ]; + assert_eq!(recent_legs(&ending_low), (120.0, 110.0, 100.0, 99.0)); + } + + #[test] + fn candles_for_pivots_realizes_the_requested_swings() { + let want = [120.0, 100.0, 125.0, 95.0]; + let mut t = SwingTracker::new(0.05, 6); + for candle in candles_for_pivots(&want) { + let _ = t.update(candle); + } + let got: Vec = t.pivots().iter().map(|p| p.price).collect(); + assert_eq!(got, want); + // Directions alternate starting from a high. + assert_eq!(t.pivots()[0].direction, 1.0); + assert_eq!(t.pivots()[1].direction, -1.0); + } + + #[test] + fn approx_equal_relative_tolerance() { + assert!(approx_equal(100.0, 102.0, 0.03)); // 2% apart, within 3% + assert!(!approx_equal(100.0, 110.0, 0.03)); // 10% apart, outside 3% + assert!(approx_equal(0.0, 0.0, 0.01)); // both zero + assert!(approx_equal(-50.0, -49.0, 0.05)); // negative magnitudes + } +} diff --git a/crates/wickra-core/src/indicators/rectangle_range.rs b/crates/wickra-core/src/indicators/rectangle_range.rs new file mode 100644 index 00000000..2d300ed4 --- /dev/null +++ b/crates/wickra-core/src/indicators/rectangle_range.rs @@ -0,0 +1,155 @@ +//! Rectangle / Range chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Rectangle / Range — price oscillating between a roughly horizontal support +/// and resistance, a mean-reversion (range-trading) structure. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised when the +/// last two highs and the last two lows are each flat within [`LEVEL_TOLERANCE`] +/// (3%): +/// +/// ```text +/// flat highs (resistance) AND flat lows (support): +/// last pivot a low → +1 (a bounce off support — buy the range) +/// last pivot a high → -1 (a rejection at resistance — sell the range) +/// ``` +/// +/// Unlike the breakout patterns the rectangle is range-bound, so the sign +/// encodes the actionable mean-reversion direction of the just-confirmed touch. +/// Output is `+1.0` / `-1.0` / `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct RectangleRange { + swing: SwingTracker, + has_emitted: bool, +} + +impl RectangleRange { + /// Construct a new Rectangle / Range detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for RectangleRange { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for RectangleRange { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let (high_old, high_new, low_old, low_new) = recent_legs(pivots); + let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE); + let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE); + if flat_highs && flat_lows { + let last_is_high = pivots[pivots.len() - 1].direction > 0.0; + return Some(if last_is_high { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "RectangleRange" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = RectangleRange::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = RectangleRange::new(); + assert_eq!(indicator.name(), "RectangleRange"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!RectangleRange::default().is_ready()); + } + + #[test] + fn range_bounce_off_support_is_plus_one() { + // Flat highs (120, 121), flat lows (100, 99); last pivot a low → +1. + let out = run(&[120.0, 100.0, 121.0, 99.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn range_rejection_at_resistance_is_minus_one() { + // Same range but ending on a high pivot → -1. + let out = run(&[130.0, 100.0, 120.0, 99.0, 121.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn trending_highs_are_not_a_rectangle() { + // Rising highs break the flat-resistance requirement → no rectangle. + let out = run(&[120.0, 100.0, 140.0, 99.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = RectangleRange::new(); + for c in candles_for_pivots(&[120.0, 100.0, 121.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0]); + let mut a = RectangleRange::new(); + let mut b = RectangleRange::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/triangle.rs b/crates/wickra-core/src/indicators/triangle.rs new file mode 100644 index 00000000..22e25860 --- /dev/null +++ b/crates/wickra-core/src/indicators/triangle.rs @@ -0,0 +1,181 @@ +//! Triangle (ascending / descending / symmetrical) chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Triangle — a consolidation pattern bounded by two converging trendlines, +/// detected from the two most recent swing highs and lows. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated on every +/// bar that confirms a new pivot once four pivots exist: +/// +/// ```text +/// ascending : flat highs + rising lows → +1 (bullish bias) +/// descending : falling highs + flat lows → -1 (bearish bias) +/// symmetrical : falling highs + rising lows → +1 if the last pivot is a low +/// (an up-bounce), else -1 +/// ``` +/// +/// "Flat" means the two highs (or lows) are within [`LEVEL_TOLERANCE`] (3%) of +/// each other; "rising"/"falling" means they differ by more than that tolerance. +/// The symmetrical case is directionally neutral, so its sign follows the +/// momentum of the most recently confirmed swing. Output is `+1.0` / `-1.0` / +/// `0.0`; never `None`. +#[derive(Debug, Clone)] +pub struct Triangle { + swing: SwingTracker, + has_emitted: bool, +} + +impl Triangle { + /// Construct a new Triangle detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for Triangle { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Triangle { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let (high_old, high_new, low_old, low_new) = recent_legs(pivots); + let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE); + let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE); + let rising_lows = low_new > low_old * (1.0 + LEVEL_TOLERANCE); + let falling_highs = high_new < high_old * (1.0 - LEVEL_TOLERANCE); + let last_is_high = pivots[pivots.len() - 1].direction > 0.0; + + if flat_highs && rising_lows { + return Some(1.0); // ascending + } + if falling_highs && flat_lows { + return Some(-1.0); // descending + } + if falling_highs && rising_lows { + // symmetrical: lean with the latest swing's momentum. + return Some(if last_is_high { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Triangle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Triangle::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Triangle::new(); + assert_eq!(indicator.name(), "Triangle"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!Triangle::default().is_ready()); + } + + #[test] + fn ascending_triangle_is_plus_one() { + // Flat highs (120, 120), rising lows (100 → 110). + let out = run(&[130.0, 100.0, 120.0, 110.0, 120.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn descending_triangle_is_minus_one() { + // Falling highs (120 → 110), flat lows (100, 99). + let out = run(&[120.0, 100.0, 110.0, 99.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn symmetrical_triangle_ending_low_is_plus_one() { + // Falling highs (120 → 113), rising lows (100 → 106); last pivot a low. + let out = run(&[120.0, 100.0, 113.0, 106.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn symmetrical_triangle_ending_high_is_minus_one() { + // Same convergence but ending on a high pivot. + let out = run(&[130.0, 100.0, 120.0, 106.0, 113.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn expanding_swings_are_not_a_triangle() { + // Rising highs and falling lows (broadening) → no converging triangle. + let out = run(&[110.0, 100.0, 130.0, 80.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Triangle::new(); + for c in candles_for_pivots(&[130.0, 100.0, 120.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[130.0, 100.0, 120.0, 110.0, 120.0]); + let mut a = Triangle::new(); + let mut b = Triangle::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/triple_top_bottom.rs b/crates/wickra-core/src/indicators/triple_top_bottom.rs new file mode 100644 index 00000000..913c3942 --- /dev/null +++ b/crates/wickra-core/src/indicators/triple_top_bottom.rs @@ -0,0 +1,160 @@ +//! Triple Top / Triple Bottom reversal chart pattern. + +use crate::indicators::pattern_swing::{ + approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD, +}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Triple Top / Triple Bottom — a three-peak (or three-trough) reversal pattern, +/// a stronger variant of the double top/bottom. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%). A pattern is +/// recognised on the bar that confirms the **third** matching extreme: +/// +/// ```text +/// triple top : High₁ , Low , High₂ , Low , High₃ High₁ ≈ High₂ ≈ High₃ → -1 +/// triple bottom : Low₁ , High, Low₂ , High, Low₃ Low₁ ≈ Low₂ ≈ Low₃ → +1 +/// ``` +/// +/// The three same-direction extremes (positions `n-5`, `n-3`, `n-1` in the pivot +/// history) must all lie within [`LEVEL_TOLERANCE`] (3%) of one another. +/// +/// Output is `+1.0` for a triple bottom, `-1.0` for a triple top, and `0.0` +/// otherwise; never `None`. +#[derive(Debug, Clone)] +pub struct TripleTopBottom { + swing: SwingTracker, + has_emitted: bool, +} + +impl TripleTopBottom { + /// Construct a new Triple Top / Triple Bottom detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 5), + has_emitted: false, + } + } +} + +impl Default for TripleTopBottom { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for TripleTopBottom { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 5 { + return Some(0.0); + } + let n = pivots.len(); + let first = pivots[n - 5]; + let middle = pivots[n - 3]; + let last = pivots[n - 1]; + let outer_match = approx_equal(first.price, middle.price, LEVEL_TOLERANCE); + let inner_match = approx_equal(middle.price, last.price, LEVEL_TOLERANCE); + if outer_match && inner_match { + return Some(if last.direction > 0.0 { -1.0 } else { 1.0 }); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Five confirmed pivots are needed; the earliest bar that can confirm a + // fifth pivot is the sixth. + 6 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "TripleTopBottom" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = TripleTopBottom::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = TripleTopBottom::new(); + assert_eq!(indicator.name(), "TripleTopBottom"); + assert_eq!(indicator.warmup_period(), 6); + assert!(!indicator.is_ready()); + assert!(!TripleTopBottom::default().is_ready()); + } + + #[test] + fn triple_top_is_minus_one() { + // Three ~equal highs (120, 121, 119) → triple top on the third. + let out = run(&[120.0, 100.0, 121.0, 99.0, 119.0]); + assert_eq!(*out.last().unwrap(), -1.0); + assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0)); + } + + #[test] + fn triple_bottom_is_plus_one() { + // Lead high then three ~equal lows (100, 99, 101) → triple bottom. + let out = run(&[130.0, 100.0, 120.0, 99.0, 122.0, 101.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn unequal_third_peak_does_not_trigger() { + // Third high (140) diverges from the first two (120, 121) → no pattern. + let out = run(&[120.0, 100.0, 121.0, 99.0, 140.0]); + assert_eq!(*out.last().unwrap(), 0.0); + assert!(out.iter().all(|&x| x == 0.0)); + } + + #[test] + fn reset_clears_state() { + let mut indicator = TripleTopBottom::new(); + for c in candles_for_pivots(&[120.0, 100.0, 121.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0, 119.0]); + let mut a = TripleTopBottom::new(); + let mut b = TripleTopBottom::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/wedge.rs b/crates/wickra-core/src/indicators/wedge.rs new file mode 100644 index 00000000..cc08da55 --- /dev/null +++ b/crates/wickra-core/src/indicators/wedge.rs @@ -0,0 +1,157 @@ +//! Wedge (rising / falling) reversal chart pattern. + +use crate::indicators::pattern_swing::{recent_legs, SwingTracker, SWING_THRESHOLD}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Wedge — a pattern where both trendlines slope the same way but converge, +/// signalling exhaustion of the prevailing move. +/// +/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the +/// last two swing highs and lows: +/// +/// ```text +/// rising wedge : highs rising AND lows rising, lows rising faster → -1 (bearish) +/// falling wedge : highs falling AND lows falling, highs falling faster → +1 (bullish) +/// ``` +/// +/// Convergence is the key: in a rising wedge the lower trendline climbs faster +/// than the upper (the range narrows from below); in a falling wedge the upper +/// trendline drops faster than the lower. Output is `+1.0` / `-1.0` / `0.0`; +/// never `None`. +#[derive(Debug, Clone)] +pub struct Wedge { + swing: SwingTracker, + has_emitted: bool, +} + +impl Wedge { + /// Construct a new Wedge detector. + pub const fn new() -> Self { + Self { + swing: SwingTracker::new(SWING_THRESHOLD, 4), + has_emitted: false, + } + } +} + +impl Default for Wedge { + fn default() -> Self { + Self::new() + } +} + +impl Indicator for Wedge { + type Input = Candle; + type Output = f64; + + fn update(&mut self, candle: Candle) -> Option { + self.has_emitted = true; + if !self.swing.update(candle) { + return Some(0.0); + } + let pivots = self.swing.pivots(); + if pivots.len() < 4 { + return Some(0.0); + } + let (high_old, high_new, low_old, low_new) = recent_legs(pivots); + let high_slope = high_new - high_old; + let low_slope = low_new - low_old; + + // Rising wedge: both lines slope up, lower line steeper (converging) → bearish. + if high_slope > 0.0 && low_slope > 0.0 && low_slope > high_slope { + return Some(-1.0); + } + // Falling wedge: both lines slope down, upper line steeper → bullish. + if high_slope < 0.0 && low_slope < 0.0 && high_slope < low_slope { + return Some(1.0); + } + Some(0.0) + } + + fn reset(&mut self) { + self.swing.reset(); + self.has_emitted = false; + } + + fn warmup_period(&self) -> usize { + // Four confirmed pivots; the earliest confirmation of the fourth is bar 5. + 5 + } + + fn is_ready(&self) -> bool { + self.has_emitted + } + + fn name(&self) -> &'static str { + "Wedge" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::indicators::pattern_swing::candles_for_pivots; + use crate::traits::BatchExt; + + fn run(pivots: &[f64]) -> Vec { + let mut indicator = Wedge::new(); + candles_for_pivots(pivots) + .into_iter() + .map(|c| indicator.update(c).unwrap()) + .collect() + } + + #[test] + fn accessors_and_metadata() { + let indicator = Wedge::new(); + assert_eq!(indicator.name(), "Wedge"); + assert_eq!(indicator.warmup_period(), 5); + assert!(!indicator.is_ready()); + assert!(!Wedge::default().is_ready()); + } + + #[test] + fn rising_wedge_is_minus_one() { + // Highs 100 → 103 (+3), lows 90 → 94 (+4, steeper) → rising wedge. + let out = run(&[110.0, 90.0, 100.0, 94.0, 103.0]); + assert_eq!(*out.last().unwrap(), -1.0); + } + + #[test] + fn falling_wedge_is_plus_one() { + // Highs 120 → 106 (-14, steeper), lows 100 → 99 (-1) → falling wedge. + let out = run(&[120.0, 100.0, 106.0, 99.0]); + assert_eq!(*out.last().unwrap(), 1.0); + } + + #[test] + fn diverging_swings_are_not_a_wedge() { + // Rising highs but falling lows (broadening) → no wedge. + let out = run(&[110.0, 100.0, 130.0, 80.0]); + assert_eq!(*out.last().unwrap(), 0.0); + } + + #[test] + fn reset_clears_state() { + let mut indicator = Wedge::new(); + for c in candles_for_pivots(&[110.0, 90.0, 100.0]) { + let _ = indicator.update(c); + } + indicator.reset(); + assert!(!indicator.is_ready()); + let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap(); + assert_eq!(indicator.update(c), Some(0.0)); + } + + #[test] + fn batch_equals_streaming() { + let candles = candles_for_pivots(&[110.0, 90.0, 100.0, 94.0, 103.0]); + let mut a = Wedge::new(); + let mut b = Wedge::new(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index b3e9339f..66acb496 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -68,39 +68,40 @@ pub use indicators::{ ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, - Coppock, Counterattack, CumulativeVolumeDelta, CumulativeVolumeIndex, CyberneticCycle, - DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, - DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar, - Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, - DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, - EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, + Coppock, Counterattack, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, + CyberneticCycle, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, + DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, + DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, + DoubleBollingerOutput, DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, + DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama, - FibonacciPivots, FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput, - ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate, - FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility, - GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, - HiLoActivator, HighLowIndex, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, - HistoricalVolatility, Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, - HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, - InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, - InstantaneousTrendline, IntradayVolatilityProfile, IntradayVolatilityProfileOutput, - InverseFisherTransform, InvertedHammer, Jma, KagiBars, KalmanHedgeRatio, - KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, - Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, - LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, - LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, - LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, - MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, - MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, - McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, - MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, - OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, - OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, - OrderBookImbalanceTopN, OuHalfLife, OvernightGap, OvernightIntradayReturn, - OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, - PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, - PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, - RealizedSpread, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, + FibonacciPivots, FibonacciPivotsOutput, FisherTransform, FlagPennant, Footprint, + FootprintOutput, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, + FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, + GarmanKlassVolatility, GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, + HeadAndShoulders, HeikinAshi, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighWave, Hikkake, + HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma, HomingPigeon, HtDcPhase, + HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, + Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio, + InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayVolatilityProfile, + IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, Jma, KagiBars, + KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, + Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, + LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, + LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, + LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio, + MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, + MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, + McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, + MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, + MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, + OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput, + OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, + OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex, + PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, + PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, + PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, + RecoveryFactor, RectangleRange, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, @@ -116,15 +117,16 @@ pub use indicators::{ TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput, - TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, - TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, - UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, - ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, - VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, 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, + TradeImbalance, TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Trix, TrueRange, Tsf, + Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, + UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, + UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, + VerticalHorizontalFilter, Vidya, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, + VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, + Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, Wedge, + 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 diff --git a/docs/README.md b/docs/README.md index 66f3717b..235e2e9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ That includes: [Python](https://docs.wickra.org/Quickstart-Python), [Node](https://docs.wickra.org/Quickstart-Node), and [WASM](https://docs.wickra.org/Quickstart-WASM). -- A per-indicator deep dive for every one of the **351 indicators** across +- A per-indicator deep dive for every one of the **359 indicators** across the sixteen families (Moving Averages, Momentum Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands, Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots & diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index b989743a..fc04e3ca 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -22,7 +22,7 @@ //! WeightedClose. use libfuzzer_sys::fuzz_target; -use wickra_core::{AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag}; +use wickra_core::{AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, CupAndHandle, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeadAndShoulders, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, Triangle, TripleTopBottom, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag}; /// Convert a flat `f64` stream into a `Vec` by chunking it into /// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV @@ -372,4 +372,14 @@ fuzz_target!(|data: Vec| { drive(|| SessionVwap::new(0), &candles); + // --- Chart Patterns --- + drive(CupAndHandle::new, &candles); + drive(RectangleRange::new, &candles); + drive(FlagPennant::new, &candles); + drive(Wedge::new, &candles); + drive(Triangle::new, &candles); + drive(HeadAndShoulders::new, &candles); + drive(TripleTopBottom::new, &candles); + drive(DoubleTopBottom::new, &candles); + });