diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a937c83..62c1f42c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+- **Volume-by-Time Profile** — mean traded volume bucketed by intraday time (`VOLUME_BY_TIME_PROFILE`).
+- **Intraday Volatility Profile** — return standard deviation bucketed by intraday time (`INTRADAY_VOLATILITY_PROFILE`).
+- **Day-of-Week Profile** — mean bar return bucketed by weekday (`DAY_OF_WEEK_PROFILE`).
+- **Time-of-Day Return Profile** — mean bar return bucketed by intraday time (`TIME_OF_DAY_RETURN_PROFILE`).
+- **Seasonal Z-Score** — z-score of the current return versus the same hour-of-day history (`SEASONAL_Z_SCORE`).
+- **Turn-of-Month** — mean daily return inside the turn-of-month window (`TURN_OF_MONTH`).
+- **Overnight/Intraday Return** — decomposition of session return into overnight and intraday legs (`OVERNIGHT_INTRADAY_RETURN`).
+- **Overnight Gap** — close-to-open return across the session boundary (`OVERNIGHT_GAP`).
+- **Average Daily Range** — mean high-low range of the last N completed sessions (`AVERAGE_DAILY_RANGE`).
+- **Session Range** — per-session (Asia/EU/US) high-low range (`SESSION_RANGE`).
+- **Session High/Low** — running high and low of the current session (`SESSION_HIGH_LOW`).
+- **Session VWAP** — session-anchored volume-weighted average price (`SESSION_VWAP`).
## [0.5.0] - 2026-06-03
diff --git a/README.md b/README.md
index 59f576cd..5593ebd4 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
- every one of the 339 indicators; start at the
+ every one of the 351 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
-339 streaming-first indicators across twenty families. Every one passes the
+351 streaming-first indicators across twenty-one 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).
@@ -162,6 +162,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
+| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
`−1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
@@ -240,7 +241,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
-│ ├── wickra-core/ core engine + all 339 indicators
+│ ├── wickra-core/ core engine + all 351 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__/seasonality.test.js b/bindings/node/__tests__/seasonality.test.js
new file mode 100644
index 00000000..d6eecbdf
--- /dev/null
+++ b/bindings/node/__tests__/seasonality.test.js
@@ -0,0 +1,96 @@
+// Streaming-vs-batch equivalence and reference values for the Seasonality &
+// Session family. These indicators consume the full candle (open, high, low,
+// close, volume, timestamp), so they have a dedicated suite.
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const wickra = require('..');
+
+const HOUR = 3_600_000;
+const N = 240;
+const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
+const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
+const high = close.map((c, i) => Math.max(open[i], c) + 1);
+const low = close.map((c, i) => Math.min(open[i], c) - 1);
+const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
+const ts = Array.from({ length: N }, (_, i) => i * HOUR);
+
+function eq(a, b) {
+ if (Number.isNaN(a)) return Number.isNaN(b);
+ return Math.abs(a - b) < 1e-9;
+}
+
+function streamScalar(ind, i) {
+ const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
+ return v === null || v === undefined ? NaN : v;
+}
+
+function checkScalar(name, make) {
+ test(`${name} streaming equals batch`, () => {
+ const a = make();
+ const b = make();
+ const batch = b.batch(open, high, low, close, volume, ts);
+ for (let i = 0; i < N; i += 1) {
+ assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
+ }
+ });
+}
+
+function checkMatrix(name, make, k, pick) {
+ test(`${name} streaming equals batch`, () => {
+ const a = make();
+ const b = make();
+ const batch = b.batch(open, high, low, close, volume, ts);
+ for (let i = 0; i < N; i += 1) {
+ const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
+ for (let j = 0; j < k; j += 1) {
+ const s = out === null || out === undefined ? NaN : pick(out, j);
+ assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
+ }
+ }
+ });
+}
+
+checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
+checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
+checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
+checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
+checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
+
+checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
+checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
+checkMatrix(
+ 'OvernightIntradayReturn',
+ () => new wickra.OvernightIntradayReturn(0),
+ 2,
+ (o, j) => (j === 0 ? o.overnight : o.intraday),
+);
+checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
+checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
+checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
+checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
+
+test('SessionVwap reference value', () => {
+ const vwap = new wickra.SessionVwap(0);
+ assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
+ assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
+ assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
+});
+
+test('OvernightGap reference value', () => {
+ const gap = new wickra.OvernightGap(0);
+ assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
+ assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
+});
+
+test('SessionHighLow reference object', () => {
+ const shl = new wickra.SessionHighLow(0);
+ shl.update(100, 105, 99, 101, 1, 0);
+ const out = shl.update(101, 108, 100, 107, 1, HOUR);
+ assert.ok(eq(out.high, 108));
+ assert.ok(eq(out.low, 99));
+});
+
+test('AverageDailyRange rejects zero period', () => {
+ assert.throws(() => new wickra.AverageDailyRange(0, 0));
+});
diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts
index c68b9a50..7684f745 100644
--- a/bindings/node/index.d.ts
+++ b/bindings/node/index.d.ts
@@ -349,6 +349,19 @@ export interface PnfColumnValue {
high: number
low: number
}
+export interface SessionHighLowValue {
+ high: number
+ low: number
+}
+export interface SessionRangeValue {
+ asia: number
+ eu: number
+ us: number
+}
+export interface OvernightIntradayReturnValue {
+ overnight: number
+ intraday: number
+}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -3557,3 +3570,121 @@ export declare class Alpha {
isReady(): boolean
warmupPeriod(): number
}
+export type SessionVwapNode = SessionVwap
+export declare class SessionVwap {
+ constructor(utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+ utcOffsetMinutes(): number
+}
+export type OvernightGapNode = OvernightGap
+export declare class OvernightGap {
+ constructor(utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+ utcOffsetMinutes(): number
+}
+export type SeasonalZScoreNode = SeasonalZScore
+export declare class SeasonalZScore {
+ constructor(utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+ utcOffsetMinutes(): number
+}
+export type TimeOfDayReturnProfileNode = TimeOfDayReturnProfile
+export declare class TimeOfDayReturnProfile {
+ constructor(buckets: number, utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+ buckets(): number
+ utcOffsetMinutes(): number
+}
+export type IntradayVolatilityProfileNode = IntradayVolatilityProfile
+export declare class IntradayVolatilityProfile {
+ constructor(buckets: number, utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+ buckets(): number
+ utcOffsetMinutes(): number
+}
+export type VolumeByTimeProfileNode = VolumeByTimeProfile
+export declare class VolumeByTimeProfile {
+ constructor(buckets: number, utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+ buckets(): number
+ utcOffsetMinutes(): number
+}
+export type DayOfWeekProfileNode = DayOfWeekProfile
+export declare class DayOfWeekProfile {
+ constructor(utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+ utcOffsetMinutes(): number
+}
+export type AverageDailyRangeNode = AverageDailyRange
+export declare class AverageDailyRange {
+ constructor(period: number, utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type TurnOfMonthNode = TurnOfMonth
+export declare class TurnOfMonth {
+ constructor(nFirst: number, nLast: number, utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type SessionHighLowNode = SessionHighLow
+export declare class SessionHighLow {
+ constructor(utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionHighLowValue | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type SessionRangeNode = SessionRange
+export declare class SessionRange {
+ constructor(utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionRangeValue | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
+export type OvernightIntradayReturnNode = OvernightIntradayReturn
+export declare class OvernightIntradayReturn {
+ constructor(utcOffsetMinutes: number)
+ update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): OvernightIntradayReturnValue | null
+ batch(open: Array, high: Array, low: Array, close: Array, volume: Array, timestamp: Array): Array
+ reset(): void
+ isReady(): boolean
+ warmupPeriod(): number
+}
diff --git a/bindings/node/index.js b/bindings/node/index.js
index 2a1a14ca..04cd8e3f 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 } = 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, 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
@@ -652,3 +652,15 @@ module.exports.RenkoBars = RenkoBars
module.exports.KagiBars = KagiBars
module.exports.PointAndFigureBars = PointAndFigureBars
module.exports.Alpha = Alpha
+module.exports.SessionVwap = SessionVwap
+module.exports.OvernightGap = OvernightGap
+module.exports.SeasonalZScore = SeasonalZScore
+module.exports.TimeOfDayReturnProfile = TimeOfDayReturnProfile
+module.exports.IntradayVolatilityProfile = IntradayVolatilityProfile
+module.exports.VolumeByTimeProfile = VolumeByTimeProfile
+module.exports.DayOfWeekProfile = DayOfWeekProfile
+module.exports.AverageDailyRange = AverageDailyRange
+module.exports.TurnOfMonth = TurnOfMonth
+module.exports.SessionHighLow = SessionHighLow
+module.exports.SessionRange = SessionRange
+module.exports.OvernightIntradayReturn = OvernightIntradayReturn
diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs
index d49af0b3..d9078b54 100644
--- a/bindings/node/src/lib.rs
+++ b/bindings/node/src/lib.rs
@@ -13398,3 +13398,615 @@ impl AlphaNode {
self.inner.warmup_period() as u32
}
}
+
+// ====================== Seasonality & Session (full-candle) ======================
+//
+// These read the wall-clock fields of `Candle::timestamp`, so the bindings take
+// the full candle (open, high, low, close, volume, timestamp) rather than the
+// high/low/close slice used by the candle indicators above.
+
+fn season_candles(
+ open: &[f64],
+ high: &[f64],
+ low: &[f64],
+ close: &[f64],
+ volume: &[f64],
+ timestamp: &[i64],
+) -> napi::Result> {
+ let n = open.len();
+ if [
+ high.len(),
+ low.len(),
+ close.len(),
+ volume.len(),
+ timestamp.len(),
+ ]
+ .iter()
+ .any(|&x| x != n)
+ {
+ return Err(NapiError::from_reason(
+ "open, high, low, close, volume, timestamp must be equal length".to_string(),
+ ));
+ }
+ let mut out = Vec::with_capacity(n);
+ for i in 0..n {
+ out.push(
+ wc::Candle::new(open[i], high[i], low[i], close[i], volume[i], timestamp[i])
+ .map_err(map_err)?,
+ );
+ }
+ Ok(out)
+}
+
+macro_rules! node_seasonality_offset_scalar {
+ ($wrapper:ident, $node_name:literal, $rust_ty:ty) => {
+ #[napi(js_name = $node_name)]
+ pub struct $wrapper {
+ inner: $rust_ty,
+ }
+ #[napi]
+ impl $wrapper {
+ #[napi(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: <$rust_ty>::new(utc_offset_minutes),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result> {
+ Ok(self.inner.update(
+ wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?,
+ ))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ Ok(candles
+ .into_iter()
+ .map(|c| self.inner.update(c).unwrap_or(f64::NAN))
+ .collect())
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+ #[napi(js_name = "utcOffsetMinutes")]
+ pub fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ }
+ };
+}
+
+macro_rules! node_seasonality_bucket_profile {
+ ($wrapper:ident, $node_name:literal, $rust_ty:ty) => {
+ #[napi(js_name = $node_name)]
+ pub struct $wrapper {
+ inner: $rust_ty,
+ }
+ #[napi]
+ impl $wrapper {
+ #[napi(constructor)]
+ pub fn new(buckets: u32, utc_offset_minutes: i32) -> napi::Result {
+ Ok(Self {
+ inner: <$rust_ty>::new(buckets as usize, utc_offset_minutes)
+ .map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result>> {
+ Ok(self
+ .inner
+ .update(
+ wc::Candle::new(open, high, low, close, volume, timestamp)
+ .map_err(map_err)?,
+ )
+ .map(|o| o.bins))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let k = self.inner.params().0;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * k];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ for (j, b) in o.bins.iter().enumerate() {
+ out[i * k + j] = *b;
+ }
+ }
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+ #[napi(js_name = "buckets")]
+ pub fn buckets(&self) -> u32 {
+ self.inner.params().0 as u32
+ }
+ #[napi(js_name = "utcOffsetMinutes")]
+ pub fn utc_offset_minutes(&self) -> i32 {
+ self.inner.params().1
+ }
+ }
+ };
+}
+
+macro_rules! node_seasonality_offset_profile {
+ ($wrapper:ident, $node_name:literal, $rust_ty:ty, $k:expr) => {
+ #[napi(js_name = $node_name)]
+ pub struct $wrapper {
+ inner: $rust_ty,
+ }
+ #[napi]
+ impl $wrapper {
+ #[napi(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: <$rust_ty>::new(utc_offset_minutes),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result>> {
+ Ok(self
+ .inner
+ .update(
+ wc::Candle::new(open, high, low, close, volume, timestamp)
+ .map_err(map_err)?,
+ )
+ .map(|o| o.bins))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let k = $k;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * k];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ for (j, b) in o.bins.iter().enumerate() {
+ out[i * k + j] = *b;
+ }
+ }
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+ #[napi(js_name = "utcOffsetMinutes")]
+ pub fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ }
+ };
+}
+
+node_seasonality_offset_scalar!(SessionVwapNode, "SessionVwap", wc::SessionVwap);
+node_seasonality_offset_scalar!(OvernightGapNode, "OvernightGap", wc::OvernightGap);
+node_seasonality_offset_scalar!(SeasonalZScoreNode, "SeasonalZScore", wc::SeasonalZScore);
+node_seasonality_bucket_profile!(
+ TimeOfDayReturnProfileNode,
+ "TimeOfDayReturnProfile",
+ wc::TimeOfDayReturnProfile
+);
+node_seasonality_bucket_profile!(
+ IntradayVolatilityProfileNode,
+ "IntradayVolatilityProfile",
+ wc::IntradayVolatilityProfile
+);
+node_seasonality_bucket_profile!(
+ VolumeByTimeProfileNode,
+ "VolumeByTimeProfile",
+ wc::VolumeByTimeProfile
+);
+node_seasonality_offset_profile!(
+ DayOfWeekProfileNode,
+ "DayOfWeekProfile",
+ wc::DayOfWeekProfile,
+ 7
+);
+
+#[napi(js_name = "AverageDailyRange")]
+pub struct AverageDailyRangeNode {
+ inner: wc::AverageDailyRange,
+}
+#[napi]
+impl AverageDailyRangeNode {
+ #[napi(constructor)]
+ pub fn new(period: u32, utc_offset_minutes: i32) -> napi::Result {
+ Ok(Self {
+ inner: wc::AverageDailyRange::new(period as usize, utc_offset_minutes)
+ .map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result> {
+ Ok(self
+ .inner
+ .update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ Ok(candles
+ .into_iter()
+ .map(|c| self.inner.update(c).unwrap_or(f64::NAN))
+ .collect())
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
+
+#[napi(js_name = "TurnOfMonth")]
+pub struct TurnOfMonthNode {
+ inner: wc::TurnOfMonth,
+}
+#[napi]
+impl TurnOfMonthNode {
+ #[napi(constructor)]
+ pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> napi::Result {
+ Ok(Self {
+ inner: wc::TurnOfMonth::new(n_first, n_last, utc_offset_minutes).map_err(map_err)?,
+ })
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result> {
+ Ok(self
+ .inner
+ .update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ Ok(candles
+ .into_iter()
+ .map(|c| self.inner.update(c).unwrap_or(f64::NAN))
+ .collect())
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
+
+#[napi(object)]
+pub struct SessionHighLowValue {
+ pub high: f64,
+ pub low: f64,
+}
+
+#[napi(js_name = "SessionHighLow")]
+pub struct SessionHighLowNode {
+ inner: wc::SessionHighLow,
+}
+#[napi]
+impl SessionHighLowNode {
+ #[napi(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::SessionHighLow::new(utc_offset_minutes),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result> {
+ Ok(self
+ .inner
+ .update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?)
+ .map(|o| SessionHighLowValue {
+ high: o.high,
+ low: o.low,
+ }))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * 2];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ out[i * 2] = o.high;
+ out[i * 2 + 1] = o.low;
+ }
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
+
+#[napi(object)]
+pub struct SessionRangeValue {
+ pub asia: f64,
+ pub eu: f64,
+ pub us: f64,
+}
+
+#[napi(js_name = "SessionRange")]
+pub struct SessionRangeNode {
+ inner: wc::SessionRange,
+}
+#[napi]
+impl SessionRangeNode {
+ #[napi(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::SessionRange::new(utc_offset_minutes),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result> {
+ Ok(self
+ .inner
+ .update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?)
+ .map(|o| SessionRangeValue {
+ asia: o.asia,
+ eu: o.eu,
+ us: o.us,
+ }))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * 3];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ out[i * 3] = o.asia;
+ out[i * 3 + 1] = o.eu;
+ out[i * 3 + 2] = o.us;
+ }
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
+
+#[napi(object)]
+pub struct OvernightIntradayReturnValue {
+ pub overnight: f64,
+ pub intraday: f64,
+}
+
+#[napi(js_name = "OvernightIntradayReturn")]
+pub struct OvernightIntradayReturnNode {
+ inner: wc::OvernightIntradayReturn,
+}
+#[napi]
+impl OvernightIntradayReturnNode {
+ #[napi(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::OvernightIntradayReturn::new(utc_offset_minutes),
+ }
+ }
+ #[napi]
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> napi::Result> {
+ Ok(self
+ .inner
+ .update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?)
+ .map(|o| OvernightIntradayReturnValue {
+ overnight: o.overnight,
+ intraday: o.intraday,
+ }))
+ }
+ #[napi]
+ pub fn batch(
+ &mut self,
+ open: Vec,
+ high: Vec,
+ low: Vec,
+ close: Vec,
+ volume: Vec,
+ timestamp: Vec,
+ ) -> napi::Result> {
+ let candles = season_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * 2];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ out[i * 2] = o.overnight;
+ out[i * 2 + 1] = o.intraday;
+ }
+ }
+ Ok(out)
+ }
+ #[napi]
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[napi(js_name = "isReady")]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[napi(js_name = "warmupPeriod")]
+ pub fn warmup_period(&self) -> u32 {
+ self.inner.warmup_period() as u32
+ }
+}
diff --git a/bindings/python/python/wickra/__init__.py b/bindings/python/python/wickra/__init__.py
index 4d96cc64..1c4c9ec1 100644
--- a/bindings/python/python/wickra/__init__.py
+++ b/bindings/python/python/wickra/__init__.py
@@ -385,6 +385,19 @@ from ._wickra import (
TreynorRatio,
InformationRatio,
Alpha,
+ # Seasonality & Session
+ SessionVwap,
+ SessionHighLow,
+ SessionRange,
+ AverageDailyRange,
+ OvernightGap,
+ OvernightIntradayReturn,
+ TurnOfMonth,
+ SeasonalZScore,
+ TimeOfDayReturnProfile,
+ DayOfWeekProfile,
+ IntradayVolatilityProfile,
+ VolumeByTimeProfile,
)
__all__ = [
@@ -749,4 +762,17 @@ __all__ = [
"TreynorRatio",
"InformationRatio",
"Alpha",
+ # Seasonality & Session
+ "SessionVwap",
+ "SessionHighLow",
+ "SessionRange",
+ "AverageDailyRange",
+ "OvernightGap",
+ "OvernightIntradayReturn",
+ "TurnOfMonth",
+ "SeasonalZScore",
+ "TimeOfDayReturnProfile",
+ "DayOfWeekProfile",
+ "IntradayVolatilityProfile",
+ "VolumeByTimeProfile",
]
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index 61fd62aa..5ece3b7c 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -17243,6 +17243,593 @@ impl PyPointAndFigureBars {
// ============================== Module ==============================
+// ====================== Seasonality & Session (full-candle) ======================
+//
+// These indicators read the wall-clock fields of `Candle::timestamp`, so the
+// bindings consume the FULL candle (open, high, low, close, volume, timestamp)
+// — unlike the high/low/close candle indicators above.
+
+fn build_seasonality_candles<'py>(
+ open: &PyReadonlyArray1<'py, f64>,
+ high: &PyReadonlyArray1<'py, f64>,
+ low: &PyReadonlyArray1<'py, f64>,
+ close: &PyReadonlyArray1<'py, f64>,
+ volume: &PyReadonlyArray1<'py, f64>,
+ timestamp: &PyReadonlyArray1<'py, i64>,
+) -> PyResult> {
+ let o = open
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let h = high
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let l = low
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let c = close
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let v = volume
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let t = timestamp
+ .as_slice()
+ .map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
+ let n = o.len();
+ if [h.len(), l.len(), c.len(), v.len(), t.len()]
+ .iter()
+ .any(|&x| x != n)
+ {
+ return Err(PyValueError::new_err(
+ "open, high, low, close, volume, timestamp must be equal length",
+ ));
+ }
+ let mut candles = Vec::with_capacity(n);
+ for i in 0..n {
+ candles.push(wc::Candle::new(o[i], h[i], l[i], c[i], v[i], t[i]).map_err(map_err)?);
+ }
+ Ok(candles)
+}
+
+macro_rules! py_seasonality_offset_scalar {
+ ($pytype:ident, $name:literal, $rust:ident) => {
+ #[pyclass(name = $name, module = "wickra._wickra", skip_from_py_object)]
+ #[derive(Clone)]
+ struct $pytype {
+ inner: wc::$rust,
+ }
+ #[pymethods]
+ impl $pytype {
+ #[new]
+ #[pyo3(signature = (utc_offset_minutes = 0))]
+ fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::$rust::new(utc_offset_minutes),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ Ok(self.inner.update(extract_candle(candle)?))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles =
+ build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let out: Vec = candles
+ .into_iter()
+ .map(|c| self.inner.update(c).unwrap_or(f64::NAN))
+ .collect();
+ Ok(out.into_pyarray(py))
+ }
+ #[getter]
+ fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ format!(
+ "{}(utc_offset_minutes={})",
+ $name,
+ self.inner.utc_offset_minutes()
+ )
+ }
+ }
+ };
+}
+
+macro_rules! py_seasonality_bucket_profile {
+ ($pytype:ident, $name:literal, $rust:ident) => {
+ #[pyclass(name = $name, module = "wickra._wickra", skip_from_py_object)]
+ #[derive(Clone)]
+ struct $pytype {
+ inner: wc::$rust,
+ }
+ #[pymethods]
+ impl $pytype {
+ #[new]
+ #[pyo3(signature = (buckets = 24, utc_offset_minutes = 0))]
+ fn new(buckets: usize, utc_offset_minutes: i32) -> PyResult {
+ Ok(Self {
+ inner: wc::$rust::new(buckets, utc_offset_minutes).map_err(map_err)?,
+ })
+ }
+ fn update<'py>(
+ &mut self,
+ py: Python<'py>,
+ candle: &Bound<'_, PyAny>,
+ ) -> PyResult>>> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c).map(|o| o.bins.into_pyarray(py)))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles =
+ build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let k = self.inner.params().0;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * k];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ for (j, b) in o.bins.iter().enumerate() {
+ out[i * k + j] = *b;
+ }
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn params(&self) -> (usize, i32) {
+ self.inner.params()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (buckets, offset) = self.inner.params();
+ format!("{}(buckets={buckets}, utc_offset_minutes={offset})", $name)
+ }
+ }
+ };
+}
+
+macro_rules! py_seasonality_offset_profile {
+ ($pytype:ident, $name:literal, $rust:ident, $k:expr) => {
+ #[pyclass(name = $name, module = "wickra._wickra", skip_from_py_object)]
+ #[derive(Clone)]
+ struct $pytype {
+ inner: wc::$rust,
+ }
+ #[pymethods]
+ impl $pytype {
+ #[new]
+ #[pyo3(signature = (utc_offset_minutes = 0))]
+ fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::$rust::new(utc_offset_minutes),
+ }
+ }
+ fn update<'py>(
+ &mut self,
+ py: Python<'py>,
+ candle: &Bound<'_, PyAny>,
+ ) -> PyResult>>> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c).map(|o| o.bins.into_pyarray(py)))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles =
+ build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let k = $k;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * k];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ for (j, b) in o.bins.iter().enumerate() {
+ out[i * k + j] = *b;
+ }
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ format!(
+ "{}(utc_offset_minutes={})",
+ $name,
+ self.inner.utc_offset_minutes()
+ )
+ }
+ }
+ };
+}
+
+py_seasonality_offset_scalar!(PySessionVwap, "SessionVwap", SessionVwap);
+py_seasonality_offset_scalar!(PyOvernightGap, "OvernightGap", OvernightGap);
+py_seasonality_offset_scalar!(PySeasonalZScore, "SeasonalZScore", SeasonalZScore);
+py_seasonality_bucket_profile!(
+ PyTimeOfDayReturnProfile,
+ "TimeOfDayReturnProfile",
+ TimeOfDayReturnProfile
+);
+py_seasonality_bucket_profile!(
+ PyIntradayVolatilityProfile,
+ "IntradayVolatilityProfile",
+ IntradayVolatilityProfile
+);
+py_seasonality_bucket_profile!(
+ PyVolumeByTimeProfile,
+ "VolumeByTimeProfile",
+ VolumeByTimeProfile
+);
+py_seasonality_offset_profile!(PyDayOfWeekProfile, "DayOfWeekProfile", DayOfWeekProfile, 7);
+
+#[pyclass(
+ name = "AverageDailyRange",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyAverageDailyRange {
+ inner: wc::AverageDailyRange,
+}
+#[pymethods]
+impl PyAverageDailyRange {
+ #[new]
+ #[pyo3(signature = (period = 14, utc_offset_minutes = 0))]
+ fn new(period: usize, utc_offset_minutes: i32) -> PyResult {
+ Ok(Self {
+ inner: wc::AverageDailyRange::new(period, utc_offset_minutes).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ Ok(self.inner.update(extract_candle(candle)?))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let out: Vec = candles
+ .into_iter()
+ .map(|c| self.inner.update(c).unwrap_or(f64::NAN))
+ .collect();
+ Ok(out.into_pyarray(py))
+ }
+ #[getter]
+ fn params(&self) -> (usize, i32) {
+ self.inner.params()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (period, offset) = self.inner.params();
+ format!("AverageDailyRange(period={period}, utc_offset_minutes={offset})")
+ }
+}
+
+#[pyclass(name = "TurnOfMonth", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PyTurnOfMonth {
+ inner: wc::TurnOfMonth,
+}
+#[pymethods]
+impl PyTurnOfMonth {
+ #[new]
+ #[pyo3(signature = (n_first = 3, n_last = 1, utc_offset_minutes = 0))]
+ fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> PyResult {
+ Ok(Self {
+ inner: wc::TurnOfMonth::new(n_first, n_last, utc_offset_minutes).map_err(map_err)?,
+ })
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ Ok(self.inner.update(extract_candle(candle)?))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let out: Vec = candles
+ .into_iter()
+ .map(|c| self.inner.update(c).unwrap_or(f64::NAN))
+ .collect();
+ Ok(out.into_pyarray(py))
+ }
+ #[getter]
+ fn params(&self) -> (u32, u32, i32) {
+ self.inner.params()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ let (n_first, n_last, offset) = self.inner.params();
+ format!("TurnOfMonth(n_first={n_first}, n_last={n_last}, utc_offset_minutes={offset})")
+ }
+}
+
+#[pyclass(
+ name = "SessionHighLow",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PySessionHighLow {
+ inner: wc::SessionHighLow,
+}
+#[pymethods]
+impl PySessionHighLow {
+ #[new]
+ #[pyo3(signature = (utc_offset_minutes = 0))]
+ fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::SessionHighLow::new(utc_offset_minutes),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c).map(|o| (o.high, o.low)))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * 2];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ out[i * 2] = o.high;
+ out[i * 2 + 1] = o.low;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ format!(
+ "SessionHighLow(utc_offset_minutes={})",
+ self.inner.utc_offset_minutes()
+ )
+ }
+}
+
+#[pyclass(name = "SessionRange", module = "wickra._wickra", skip_from_py_object)]
+#[derive(Clone)]
+struct PySessionRange {
+ inner: wc::SessionRange,
+}
+#[pymethods]
+impl PySessionRange {
+ #[new]
+ #[pyo3(signature = (utc_offset_minutes = 0))]
+ fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::SessionRange::new(utc_offset_minutes),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c).map(|o| (o.asia, o.eu, o.us)))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * 3];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ out[i * 3] = o.asia;
+ out[i * 3 + 1] = o.eu;
+ out[i * 3 + 2] = o.us;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ format!(
+ "SessionRange(utc_offset_minutes={})",
+ self.inner.utc_offset_minutes()
+ )
+ }
+}
+
+#[pyclass(
+ name = "OvernightIntradayReturn",
+ module = "wickra._wickra",
+ skip_from_py_object
+)]
+#[derive(Clone)]
+struct PyOvernightIntradayReturn {
+ inner: wc::OvernightIntradayReturn,
+}
+#[pymethods]
+impl PyOvernightIntradayReturn {
+ #[new]
+ #[pyo3(signature = (utc_offset_minutes = 0))]
+ fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ inner: wc::OvernightIntradayReturn::new(utc_offset_minutes),
+ }
+ }
+ fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult> {
+ let c = extract_candle(candle)?;
+ Ok(self.inner.update(c).map(|o| (o.overnight, o.intraday)))
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn batch<'py>(
+ &mut self,
+ py: Python<'py>,
+ open: PyReadonlyArray1<'py, f64>,
+ high: PyReadonlyArray1<'py, f64>,
+ low: PyReadonlyArray1<'py, f64>,
+ close: PyReadonlyArray1<'py, f64>,
+ volume: PyReadonlyArray1<'py, f64>,
+ timestamp: PyReadonlyArray1<'py, i64>,
+ ) -> PyResult>> {
+ let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, ×tamp)?;
+ let n = candles.len();
+ let mut out = vec![f64::NAN; n * 2];
+ for (i, c) in candles.into_iter().enumerate() {
+ if let Some(o) = self.inner.update(c) {
+ out[i * 2] = o.overnight;
+ out[i * 2 + 1] = o.intraday;
+ }
+ }
+ Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
+ .expect("shape consistent")
+ .into_pyarray(py))
+ }
+ #[getter]
+ fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ fn reset(&mut self) {
+ self.inner.reset();
+ }
+ fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ fn __repr__(&self) -> String {
+ format!(
+ "OvernightIntradayReturn(utc_offset_minutes={})",
+ self.inner.utc_offset_minutes()
+ )
+ }
+}
+
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -17596,5 +18183,18 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::()?;
m.add_class::()?;
m.add_class::()?;
+ // Family 16: Seasonality & Session.
+ 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::()?;
+ m.add_class::()?;
Ok(())
}
diff --git a/bindings/python/tests/test_seasonality.py b/bindings/python/tests/test_seasonality.py
new file mode 100644
index 00000000..2e0aba1f
--- /dev/null
+++ b/bindings/python/tests/test_seasonality.py
@@ -0,0 +1,132 @@
+"""Streaming-vs-batch equivalence and reference values for the Seasonality &
+Session family.
+
+These indicators read the full candle (including ``timestamp``), so they have a
+dedicated test rather than joining the timestamp-less parametrize harness in
+``test_new_indicators.py``.
+"""
+
+import numpy as np
+import pytest
+
+import wickra as ta
+
+HOUR_MS = 3_600_000
+
+
+@pytest.fixture(scope="module")
+def candle_columns():
+ """240 hourly candles (10 days) with valid OHLCV and epoch-ms timestamps."""
+ n = 240
+ t = np.arange(n, dtype=np.float64)
+ close = 100.0 + np.sin(t * 0.3) * 5.0 + np.cos(t * 0.1) * 3.0
+ open_ = close + np.sin(t * 0.5) * 0.5
+ high = np.maximum(open_, close) + 1.0
+ low = np.minimum(open_, close) - 1.0
+ volume = 1000.0 + (t % 24) * 50.0
+ timestamp = (np.arange(n, dtype=np.int64)) * HOUR_MS
+ return open_, high, low, close, volume, timestamp
+
+
+def _candles(cols):
+ open_, high, low, close, volume, timestamp = cols
+ return [
+ (open_[i], high[i], low[i], close[i], volume[i], int(timestamp[i]))
+ for i in range(len(close))
+ ]
+
+
+def _check_scalar(make, cols):
+ candles = _candles(cols)
+ a, b = make(), make()
+ stream = np.array(
+ [np.nan if (v := a.update(c)) is None else v for c in candles],
+ dtype=np.float64,
+ )
+ batch = np.asarray(b.batch(*cols))
+ np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
+
+
+def _check_matrix(make, k, cols):
+ candles = _candles(cols)
+ a, b = make(), make()
+ rows = []
+ for c in candles:
+ out = a.update(c)
+ rows.append(np.full(k, np.nan) if out is None else np.asarray(out, dtype=float))
+ stream = np.vstack(rows)
+ batch = np.asarray(b.batch(*cols))
+ assert batch.shape == (len(candles), k)
+ np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
+
+
+SCALAR = [
+ lambda: ta.SessionVwap(0),
+ lambda: ta.OvernightGap(0),
+ lambda: ta.SeasonalZScore(0),
+ lambda: ta.AverageDailyRange(3, 0),
+ lambda: ta.TurnOfMonth(3, 1, 0),
+]
+
+MATRIX = [
+ (lambda: ta.SessionHighLow(0), 2),
+ (lambda: ta.SessionRange(0), 3),
+ (lambda: ta.OvernightIntradayReturn(0), 2),
+ (lambda: ta.TimeOfDayReturnProfile(24, 0), 24),
+ (lambda: ta.IntradayVolatilityProfile(12, 0), 12),
+ (lambda: ta.VolumeByTimeProfile(24, 0), 24),
+ (lambda: ta.DayOfWeekProfile(0), 7),
+]
+
+
+@pytest.mark.parametrize("make", SCALAR)
+def test_scalar_streaming_equals_batch(make, candle_columns):
+ _check_scalar(make, candle_columns)
+
+
+@pytest.mark.parametrize("make,k", MATRIX)
+def test_matrix_streaming_equals_batch(make, k, candle_columns):
+ _check_matrix(make, k, candle_columns)
+
+
+def test_session_vwap_reference():
+ vwap = ta.SessionVwap(0)
+ # typical = close for a flat candle; volume-weighted within the day.
+ v1 = vwap.update((100.0, 100.0, 100.0, 100.0, 10.0, 0))
+ assert v1 == pytest.approx(100.0)
+ v2 = vwap.update((110.0, 110.0, 110.0, 110.0, 30.0, HOUR_MS))
+ assert v2 == pytest.approx(107.5)
+ # New day re-anchors.
+ v3 = vwap.update((200.0, 200.0, 200.0, 200.0, 5.0, 24 * HOUR_MS))
+ assert v3 == pytest.approx(200.0)
+
+
+def test_overnight_gap_reference():
+ gap = ta.OvernightGap(0)
+ assert gap.update((99.0, 101.0, 98.0, 100.0, 1.0, 0)) is None
+ g = gap.update((105.0, 106.0, 104.0, 105.5, 1.0, 24 * HOUR_MS))
+ assert g == pytest.approx(0.05)
+
+
+def test_session_high_low_reference():
+ shl = ta.SessionHighLow(0)
+ shl.update((100.0, 105.0, 99.0, 101.0, 1.0, 0))
+ out = shl.update((101.0, 108.0, 100.0, 107.0, 1.0, HOUR_MS))
+ assert out == (108.0, 99.0)
+
+
+def test_volume_by_time_profile_reference():
+ prof = ta.VolumeByTimeProfile(24, 0)
+ out = prof.update((100.0, 100.0, 100.0, 100.0, 500.0, HOUR_MS)) # 01:00 -> bucket 1
+ assert out[1] == pytest.approx(500.0)
+ assert out[0] == pytest.approx(0.0)
+
+
+def test_rejects_zero_buckets():
+ with pytest.raises(ValueError):
+ ta.TimeOfDayReturnProfile(0, 0)
+
+
+def test_average_daily_range_rejects_zero_period():
+ with pytest.raises(ValueError):
+ ta.AverageDailyRange(0, 0)
diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs
index 909d2f12..cab714de 100644
--- a/bindings/wasm/src/lib.rs
+++ b/bindings/wasm/src/lib.rs
@@ -10226,3 +10226,390 @@ impl WasmAlpha {
self.inner.warmup_period()
}
}
+
+// ====================== Seasonality & Session (full-candle) ======================
+//
+// These read the wall-clock fields of `Candle::timestamp`. JS passes `timestamp`
+// as a BigInt (epoch milliseconds). Following the multi-input precedent
+// (microstructure / derivatives), WASM exposes streaming `update` only — no
+// batch over ragged multi-arrays.
+
+macro_rules! wasm_seasonality_offset_scalar {
+ ($wrapper:ident, $js:ident, $rust:ty) => {
+ #[wasm_bindgen(js_name = $js)]
+ pub struct $wrapper {
+ inner: $rust,
+ }
+ #[wasm_bindgen(js_class = $js)]
+ impl $wrapper {
+ #[wasm_bindgen(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> $wrapper {
+ Self {
+ inner: <$rust>::new(utc_offset_minutes),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result, JsError> {
+ Ok(self.inner.update(
+ wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?,
+ ))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ #[wasm_bindgen(js_name = utcOffsetMinutes)]
+ pub fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ }
+ };
+}
+
+macro_rules! wasm_seasonality_bucket_profile {
+ ($wrapper:ident, $js:ident, $rust:ty) => {
+ #[wasm_bindgen(js_name = $js)]
+ pub struct $wrapper {
+ inner: $rust,
+ }
+ #[wasm_bindgen(js_class = $js)]
+ impl $wrapper {
+ #[wasm_bindgen(constructor)]
+ pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<$wrapper, JsError> {
+ Ok(Self {
+ inner: <$rust>::new(buckets, utc_offset_minutes).map_err(map_err)?,
+ })
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result {
+ let c =
+ wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
+ Ok(match self.inner.update(c) {
+ Some(o) => Float64Array::from(o.bins.as_slice()).into(),
+ None => JsValue::NULL,
+ })
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ #[wasm_bindgen(js_name = utcOffsetMinutes)]
+ pub fn utc_offset_minutes(&self) -> i32 {
+ self.inner.params().1
+ }
+ }
+ };
+}
+
+macro_rules! wasm_seasonality_offset_profile {
+ ($wrapper:ident, $js:ident, $rust:ty) => {
+ #[wasm_bindgen(js_name = $js)]
+ pub struct $wrapper {
+ inner: $rust,
+ }
+ #[wasm_bindgen(js_class = $js)]
+ impl $wrapper {
+ #[wasm_bindgen(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> $wrapper {
+ Self {
+ inner: <$rust>::new(utc_offset_minutes),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result {
+ let c =
+ wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
+ Ok(match self.inner.update(c) {
+ Some(o) => Float64Array::from(o.bins.as_slice()).into(),
+ None => JsValue::NULL,
+ })
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+ #[wasm_bindgen(js_name = utcOffsetMinutes)]
+ pub fn utc_offset_minutes(&self) -> i32 {
+ self.inner.utc_offset_minutes()
+ }
+ }
+ };
+}
+
+wasm_seasonality_offset_scalar!(WasmSessionVwap, SessionVwap, wc::SessionVwap);
+wasm_seasonality_offset_scalar!(WasmOvernightGap, OvernightGap, wc::OvernightGap);
+wasm_seasonality_offset_scalar!(WasmSeasonalZScore, SeasonalZScore, wc::SeasonalZScore);
+wasm_seasonality_bucket_profile!(
+ WasmTimeOfDayReturnProfile,
+ TimeOfDayReturnProfile,
+ wc::TimeOfDayReturnProfile
+);
+wasm_seasonality_bucket_profile!(
+ WasmIntradayVolatilityProfile,
+ IntradayVolatilityProfile,
+ wc::IntradayVolatilityProfile
+);
+wasm_seasonality_bucket_profile!(
+ WasmVolumeByTimeProfile,
+ VolumeByTimeProfile,
+ wc::VolumeByTimeProfile
+);
+wasm_seasonality_offset_profile!(WasmDayOfWeekProfile, DayOfWeekProfile, wc::DayOfWeekProfile);
+
+#[wasm_bindgen(js_name = AverageDailyRange)]
+pub struct WasmAverageDailyRange {
+ inner: wc::AverageDailyRange,
+}
+#[wasm_bindgen(js_class = AverageDailyRange)]
+impl WasmAverageDailyRange {
+ #[wasm_bindgen(constructor)]
+ pub fn new(period: usize, utc_offset_minutes: i32) -> Result {
+ Ok(Self {
+ inner: wc::AverageDailyRange::new(period, utc_offset_minutes).map_err(map_err)?,
+ })
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result, JsError> {
+ Ok(self
+ .inner
+ .update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
+
+#[wasm_bindgen(js_name = TurnOfMonth)]
+pub struct WasmTurnOfMonth {
+ inner: wc::TurnOfMonth,
+}
+#[wasm_bindgen(js_class = TurnOfMonth)]
+impl WasmTurnOfMonth {
+ #[wasm_bindgen(constructor)]
+ pub fn new(
+ n_first: u32,
+ n_last: u32,
+ utc_offset_minutes: i32,
+ ) -> Result {
+ Ok(Self {
+ inner: wc::TurnOfMonth::new(n_first, n_last, utc_offset_minutes).map_err(map_err)?,
+ })
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result, JsError> {
+ Ok(self
+ .inner
+ .update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
+
+#[wasm_bindgen(js_name = SessionHighLow)]
+pub struct WasmSessionHighLow {
+ inner: wc::SessionHighLow,
+}
+#[wasm_bindgen(js_class = SessionHighLow)]
+impl WasmSessionHighLow {
+ #[wasm_bindgen(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> WasmSessionHighLow {
+ Self {
+ inner: wc::SessionHighLow::new(utc_offset_minutes),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result {
+ let c = wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
+ Ok(match self.inner.update(c) {
+ Some(o) => {
+ let obj = Object::new();
+ Reflect::set(&obj, &"high".into(), &o.high.into()).ok();
+ Reflect::set(&obj, &"low".into(), &o.low.into()).ok();
+ obj.into()
+ }
+ None => JsValue::NULL,
+ })
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
+
+#[wasm_bindgen(js_name = SessionRange)]
+pub struct WasmSessionRange {
+ inner: wc::SessionRange,
+}
+#[wasm_bindgen(js_class = SessionRange)]
+impl WasmSessionRange {
+ #[wasm_bindgen(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> WasmSessionRange {
+ Self {
+ inner: wc::SessionRange::new(utc_offset_minutes),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result {
+ let c = wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
+ Ok(match self.inner.update(c) {
+ Some(o) => {
+ let obj = Object::new();
+ Reflect::set(&obj, &"asia".into(), &o.asia.into()).ok();
+ Reflect::set(&obj, &"eu".into(), &o.eu.into()).ok();
+ Reflect::set(&obj, &"us".into(), &o.us.into()).ok();
+ obj.into()
+ }
+ None => JsValue::NULL,
+ })
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
+
+#[wasm_bindgen(js_name = OvernightIntradayReturn)]
+pub struct WasmOvernightIntradayReturn {
+ inner: wc::OvernightIntradayReturn,
+}
+#[wasm_bindgen(js_class = OvernightIntradayReturn)]
+impl WasmOvernightIntradayReturn {
+ #[wasm_bindgen(constructor)]
+ pub fn new(utc_offset_minutes: i32) -> WasmOvernightIntradayReturn {
+ Self {
+ inner: wc::OvernightIntradayReturn::new(utc_offset_minutes),
+ }
+ }
+ pub fn update(
+ &mut self,
+ open: f64,
+ high: f64,
+ low: f64,
+ close: f64,
+ volume: f64,
+ timestamp: i64,
+ ) -> Result {
+ let c = wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
+ Ok(match self.inner.update(c) {
+ Some(o) => {
+ let obj = Object::new();
+ Reflect::set(&obj, &"overnight".into(), &o.overnight.into()).ok();
+ Reflect::set(&obj, &"intraday".into(), &o.intraday.into()).ok();
+ obj.into()
+ }
+ None => JsValue::NULL,
+ })
+ }
+ pub fn reset(&mut self) {
+ self.inner.reset();
+ }
+ #[wasm_bindgen(js_name = isReady)]
+ pub fn is_ready(&self) -> bool {
+ self.inner.is_ready()
+ }
+ #[wasm_bindgen(js_name = warmupPeriod)]
+ pub fn warmup_period(&self) -> usize {
+ self.inner.warmup_period()
+ }
+}
diff --git a/crates/wickra-core/src/calendar.rs b/crates/wickra-core/src/calendar.rs
new file mode 100644
index 00000000..fbd4ea80
--- /dev/null
+++ b/crates/wickra-core/src/calendar.rs
@@ -0,0 +1,203 @@
+//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
+//!
+//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
+//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
+//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
+//! relevant exchange session rather than UTC. This module turns an epoch
+//! millisecond instant into its civil fields using Howard Hinnant's
+//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
+//!
+//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
+//! the Unix epoch decompose correctly without a dedicated negative-input branch.
+
+/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct CivilTime {
+ /// Proleptic Gregorian year (can be negative for instants before year 1).
+ pub(crate) year: i64,
+ /// Month of year, `1..=12`.
+ pub(crate) month: u32,
+ /// Day of month, `1..=31`.
+ pub(crate) day: u32,
+ /// Hour of day, `0..=23`.
+ pub(crate) hour: u32,
+ /// Minute of hour, `0..=59`.
+ pub(crate) minute: u32,
+ /// Day of week with Monday as `0` through Sunday as `6`.
+ pub(crate) weekday: u32,
+}
+
+impl CivilTime {
+ /// Minute of day, `0..=1439`.
+ pub(crate) const fn minute_of_day(&self) -> u32 {
+ self.hour * 60 + self.minute
+ }
+}
+
+/// Decompose an epoch-millisecond instant into local civil fields.
+///
+/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
+/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
+pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
+ let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
+ let days = local_secs.div_euclid(86_400);
+ let secs_of_day = local_secs.rem_euclid(86_400);
+ let hour = (secs_of_day / 3600) as u32;
+ let minute = ((secs_of_day % 3600) / 60) as u32;
+ let (year, month, day) = civil_from_days(days);
+ // 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
+ let weekday = (days + 3).rem_euclid(7) as u32;
+ CivilTime {
+ year,
+ month,
+ day,
+ hour,
+ minute,
+ weekday,
+ }
+}
+
+/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
+///
+/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
+fn civil_from_days(z: i64) -> (i64, u32, u32) {
+ let z = z + 719_468;
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let doe = z - era * 146_097; // [0, 146096]
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
+ let year = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
+ let mp = (5 * doy + 2) / 153; // [0, 11]
+ let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
+ let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
+ (if month <= 2 { year + 1 } else { year }, month, day)
+}
+
+/// Whether `year` is a Gregorian leap year.
+pub(crate) const fn is_leap(year: i64) -> bool {
+ (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
+}
+
+/// Number of days in `month` (`1..=12`) of `year`.
+pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
+ match month {
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
+ 4 | 6 | 9 | 11 => 30,
+ _ => {
+ if is_leap(year) {
+ 29
+ } else {
+ 28
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn epoch_zero_is_thursday_midnight() {
+ let t = civil_from_timestamp(0, 0);
+ assert_eq!(
+ t,
+ CivilTime {
+ year: 1970,
+ month: 1,
+ day: 1,
+ hour: 0,
+ minute: 0,
+ weekday: 3, // Thursday
+ }
+ );
+ assert_eq!(t.minute_of_day(), 0);
+ }
+
+ #[test]
+ fn known_utc_instant_mid_year() {
+ // 2021-06-15 13:45:00 UTC = 1623764700 s.
+ let t = civil_from_timestamp(1_623_764_700_000, 0);
+ assert_eq!(t.year, 2021);
+ assert_eq!(t.month, 6);
+ assert_eq!(t.day, 15);
+ assert_eq!(t.hour, 13);
+ assert_eq!(t.minute, 45);
+ assert_eq!(t.weekday, 1); // Tuesday
+ assert_eq!(t.minute_of_day(), 13 * 60 + 45);
+ }
+
+ #[test]
+ fn new_year_2021_is_friday() {
+ // 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
+ let t = civil_from_timestamp(1_609_459_200_000, 0);
+ assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
+ assert_eq!(t.weekday, 4); // Friday
+ }
+
+ #[test]
+ fn positive_offset_rolls_to_next_day() {
+ // 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
+ let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
+ let t = civil_from_timestamp(base, 60);
+ assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
+ assert_eq!((t.hour, t.minute), (0, 30));
+ assert_eq!(t.weekday, 5); // Saturday
+ }
+
+ #[test]
+ fn negative_offset_rolls_to_previous_day() {
+ // 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
+ let base = 1_609_459_200_000 + 30 * 60 * 1000;
+ let t = civil_from_timestamp(base, -60);
+ assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
+ assert_eq!((t.hour, t.minute), (23, 30));
+ assert_eq!(t.weekday, 3); // Thursday
+ }
+
+ #[test]
+ fn sub_epoch_millis_floor_correctly() {
+ // -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
+ let t = civil_from_timestamp(-1, 0);
+ assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
+ assert_eq!((t.hour, t.minute), (23, 59));
+ assert_eq!(t.weekday, 2); // Wednesday
+ }
+
+ #[test]
+ fn far_negative_day_count_hits_pre_era_branch() {
+ // A day count below -719468 drives `z + 719468` negative, exercising the
+ // `z - 146096` era branch in civil_from_days (year < 1).
+ let (year, month, day) = civil_from_days(-1_000_000);
+ // -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
+ // Gregorian, astronomical year numbering where year 0 exists).
+ assert_eq!((year, month, day), (-768, 2, 4));
+ }
+
+ #[test]
+ fn leap_year_rules() {
+ assert!(is_leap(2000));
+ assert!(!is_leap(1900));
+ assert!(is_leap(2024));
+ assert!(!is_leap(2023));
+ }
+
+ #[test]
+ fn days_in_month_all_cases() {
+ assert_eq!(days_in_month(2023, 1), 31);
+ assert_eq!(days_in_month(2023, 4), 30);
+ assert_eq!(days_in_month(2023, 2), 28);
+ assert_eq!(days_in_month(2024, 2), 29);
+ assert_eq!(days_in_month(2023, 12), 31);
+ assert_eq!(days_in_month(2023, 11), 30);
+ }
+
+ #[test]
+ fn leap_day_decodes() {
+ // 2024-02-29 12:00 UTC.
+ let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
+ let t = civil_from_timestamp(secs * 1000, 0);
+ assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
+ assert_eq!(t.hour, 12);
+ }
+}
diff --git a/crates/wickra-core/src/indicators/average_daily_range.rs b/crates/wickra-core/src/indicators/average_daily_range.rs
new file mode 100644
index 00000000..15d33e50
--- /dev/null
+++ b/crates/wickra-core/src/indicators/average_daily_range.rs
@@ -0,0 +1,231 @@
+//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
+//! completed calendar-day sessions.
+
+use std::collections::VecDeque;
+
+use crate::calendar::civil_from_timestamp;
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Average Daily Range over the last `period` completed sessions.
+///
+/// The indicator tracks the running high / low of the current session (the
+/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
+/// `utc_offset_minutes`). When a new day begins, the just-finished session's
+/// range (`high - low`) joins a rolling window of the last `period` completed
+/// days, and the reported value is their mean. The current, still-forming day is
+/// excluded until it closes. No value is produced until the first session
+/// completes.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, AverageDailyRange};
+///
+/// let hour = 3_600_000;
+/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
+/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
+/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
+/// // First bar of day 2 closes day 1: ADR = 10.
+/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
+/// assert!((v - 10.0).abs() < 1e-9);
+/// ```
+#[derive(Debug, Clone)]
+pub struct AverageDailyRange {
+ period: usize,
+ utc_offset_minutes: i32,
+ day_key: Option<(i64, u32, u32)>,
+ cur_high: f64,
+ cur_low: f64,
+ completed: VecDeque,
+ sum: f64,
+}
+
+impl AverageDailyRange {
+ /// Construct an ADR indicator over `period` completed days.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::PeriodZero`] if `period == 0`.
+ pub fn new(period: usize, utc_offset_minutes: i32) -> Result {
+ if period == 0 {
+ return Err(Error::PeriodZero);
+ }
+ Ok(Self {
+ period,
+ utc_offset_minutes,
+ day_key: None,
+ cur_high: f64::NEG_INFINITY,
+ cur_low: f64::INFINITY,
+ completed: VecDeque::with_capacity(period),
+ sum: 0.0,
+ })
+ }
+
+ /// Configured `(period, utc_offset_minutes)`.
+ pub const fn params(&self) -> (usize, i32) {
+ (self.period, self.utc_offset_minutes)
+ }
+
+ /// Most recent ADR if at least one session has completed.
+ pub fn value(&self) -> Option {
+ if self.completed.is_empty() {
+ None
+ } else {
+ Some(self.sum / self.completed.len() as f64)
+ }
+ }
+}
+
+impl Indicator for AverageDailyRange {
+ type Input = Candle;
+ type Output = f64;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
+ let key = (civil.year, civil.month, civil.day);
+ match self.day_key {
+ Some(prev) if prev == key => {
+ if candle.high > self.cur_high {
+ self.cur_high = candle.high;
+ }
+ if candle.low < self.cur_low {
+ self.cur_low = candle.low;
+ }
+ }
+ Some(_) => {
+ let range = self.cur_high - self.cur_low;
+ self.completed.push_back(range);
+ self.sum += range;
+ if self.completed.len() > self.period {
+ self.sum -= self
+ .completed
+ .pop_front()
+ .expect("len > period implies a front element");
+ }
+ self.day_key = Some(key);
+ self.cur_high = candle.high;
+ self.cur_low = candle.low;
+ }
+ None => {
+ self.day_key = Some(key);
+ self.cur_high = candle.high;
+ self.cur_low = candle.low;
+ }
+ }
+ self.value()
+ }
+
+ fn reset(&mut self) {
+ self.day_key = None;
+ self.cur_high = f64::NEG_INFINITY;
+ self.cur_low = f64::INFINITY;
+ self.completed.clear();
+ self.sum = 0.0;
+ }
+
+ fn warmup_period(&self) -> usize {
+ self.period
+ }
+
+ fn is_ready(&self) -> bool {
+ !self.completed.is_empty()
+ }
+
+ fn name(&self) -> &'static str {
+ "AverageDailyRange"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ const HOUR: i64 = 3_600_000;
+ const DAY: i64 = 24 * HOUR;
+
+ fn c(high: f64, low: f64, ts: i64) -> Candle {
+ let mid = f64::midpoint(high, low);
+ Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn rejects_zero_period() {
+ assert!(matches!(
+ AverageDailyRange::new(0, 0),
+ Err(Error::PeriodZero)
+ ));
+ }
+
+ #[test]
+ fn metadata_and_accessors() {
+ let adr = AverageDailyRange::new(5, -60).unwrap();
+ assert_eq!(adr.params(), (5, -60));
+ assert_eq!(adr.name(), "AverageDailyRange");
+ assert_eq!(adr.warmup_period(), 5);
+ assert!(!adr.is_ready());
+ assert!(adr.value().is_none());
+ }
+
+ #[test]
+ fn averages_completed_day_ranges() {
+ let mut adr = AverageDailyRange::new(3, 0).unwrap();
+ // Day 1: range 10.
+ assert!(adr.update(c(110.0, 100.0, 0)).is_none());
+ assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
+ // Day 2 opens -> day 1 (range 10) completes.
+ let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
+ assert_relative_eq!(v, 10.0);
+ assert!(adr.is_ready());
+ // Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
+ let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
+ assert_relative_eq!(v, 10.0);
+ }
+
+ #[test]
+ fn rolls_off_oldest_day_beyond_period() {
+ let mut adr = AverageDailyRange::new(2, 0).unwrap();
+ adr.update(c(110.0, 100.0, 0)); // day 1 range 10
+ let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
+ assert_relative_eq!(v, 10.0);
+ // Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
+ let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
+ assert_relative_eq!(v, 12.5);
+ // Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
+ let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
+ assert_relative_eq!(v, 17.5);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut adr = AverageDailyRange::new(2, 0).unwrap();
+ adr.update(c(110.0, 100.0, 0));
+ adr.update(c(120.0, 110.0, DAY));
+ adr.reset();
+ assert!(!adr.is_ready());
+ assert!(adr.value().is_none());
+ assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..60)
+ .map(|i| {
+ c(
+ 110.0 + f64::from(i % 5),
+ 100.0 - f64::from(i % 3),
+ i64::from(i) * 6 * HOUR,
+ )
+ })
+ .collect();
+ let mut a = AverageDailyRange::new(4, 0).unwrap();
+ let mut b = AverageDailyRange::new(4, 0).unwrap();
+ assert_eq!(
+ a.batch(&candles),
+ candles.iter().map(|x| b.update(*x)).collect::>()
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/day_of_week_profile.rs b/crates/wickra-core/src/indicators/day_of_week_profile.rs
new file mode 100644
index 00000000..8a312bee
--- /dev/null
+++ b/crates/wickra-core/src/indicators/day_of_week_profile.rs
@@ -0,0 +1,202 @@
+//! Day-of-Week Profile — the mean bar return for each weekday.
+
+use crate::calendar::civil_from_timestamp;
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+const DAYS: usize = 7;
+
+/// Day-of-Week Profile output: the per-weekday mean return.
+///
+/// `bins[i]` is the mean simple return of all bars whose local weekday was `i`,
+/// with Monday as `0` through Sunday as `6`. Weekdays with no bars read `0.0`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct DayOfWeekProfileOutput {
+ /// Per-weekday mean return, Monday first. Always length 7.
+ pub bins: Vec,
+}
+
+/// Mean bar return bucketed by local weekday (Monday `0` .. Sunday `6`).
+///
+/// Each bar's simple return `close / previous_close - 1` is accumulated into the
+/// bucket of its local weekday (the wall-clock day of
+/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`), and the
+/// profile reports the running mean per weekday. The first bar produces no output.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, DayOfWeekProfile};
+///
+/// let day = 24 * 3_600_000;
+/// let mut prof = DayOfWeekProfile::new(0);
+/// // 1970-01-01 was a Thursday (weekday 3).
+/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
+/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, day).unwrap()).unwrap();
+/// assert_eq!(out.bins.len(), 7);
+/// ```
+#[derive(Debug, Clone)]
+pub struct DayOfWeekProfile {
+ utc_offset_minutes: i32,
+ prev_close: Option,
+ sum: [f64; DAYS],
+ count: [u64; DAYS],
+ last: Option,
+}
+
+impl DayOfWeekProfile {
+ /// Construct a Day-of-Week Profile with the given UTC offset (minutes).
+ pub const fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ utc_offset_minutes,
+ prev_close: None,
+ sum: [0.0; DAYS],
+ count: [0; DAYS],
+ last: None,
+ }
+ }
+
+ /// Configured UTC offset in minutes.
+ pub const fn utc_offset_minutes(&self) -> i32 {
+ self.utc_offset_minutes
+ }
+
+ /// Most recent profile if at least one return has been recorded.
+ pub fn value(&self) -> Option<&DayOfWeekProfileOutput> {
+ self.last.as_ref()
+ }
+
+ fn snapshot(&self) -> DayOfWeekProfileOutput {
+ let bins = self
+ .sum
+ .iter()
+ .zip(&self.count)
+ .map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
+ .collect();
+ DayOfWeekProfileOutput { bins }
+ }
+}
+
+impl Indicator for DayOfWeekProfile {
+ type Input = Candle;
+ type Output = DayOfWeekProfileOutput;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
+ let result = if let Some(prev) = self.prev_close {
+ let ret = if prev == 0.0 {
+ 0.0
+ } else {
+ candle.close / prev - 1.0
+ };
+ let day = civil.weekday as usize;
+ self.sum[day] += ret;
+ self.count[day] += 1;
+ let out = self.snapshot();
+ self.last = Some(out.clone());
+ Some(out)
+ } else {
+ None
+ };
+ self.prev_close = Some(candle.close);
+ result
+ }
+
+ fn reset(&mut self) {
+ self.prev_close = None;
+ self.sum = [0.0; DAYS];
+ self.count = [0; DAYS];
+ self.last = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ 2
+ }
+
+ fn is_ready(&self) -> bool {
+ self.last.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "DayOfWeekProfile"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ const DAY: i64 = 24 * 3_600_000;
+
+ fn c(close: f64, ts: i64) -> Candle {
+ Candle::new(close, close, close, close, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn metadata_and_accessors() {
+ let prof = DayOfWeekProfile::new(60);
+ assert_eq!(prof.utc_offset_minutes(), 60);
+ assert_eq!(prof.name(), "DayOfWeekProfile");
+ assert_eq!(prof.warmup_period(), 2);
+ assert!(!prof.is_ready());
+ assert!(prof.value().is_none());
+ }
+
+ #[test]
+ fn buckets_by_weekday() {
+ let mut prof = DayOfWeekProfile::new(0);
+ // 1970-01-01 Thursday (3); 01-02 Friday (4).
+ assert!(prof.update(c(100.0, 0)).is_none());
+ let out = prof.update(c(101.0, DAY)).unwrap(); // Friday return +0.01
+ assert_eq!(out.bins.len(), 7);
+ assert_relative_eq!(out.bins[4], 0.01); // Friday
+ assert_relative_eq!(out.bins[3], 0.0); // Thursday had no return
+ assert!(prof.is_ready());
+ }
+
+ #[test]
+ fn averages_same_weekday_across_weeks() {
+ let mut prof = DayOfWeekProfile::new(0);
+ prof.update(c(100.0, 0)); // Thu
+ prof.update(c(101.0, DAY)); // Fri +0.01
+ // Jump to next Friday (7 days later from day 0 -> +7 days, weekday 4).
+ prof.update(c(100.0, 7 * DAY)); // Thu+? actually day 7 -> weekday (7+3)%7=3 Thu
+ let out = prof.update(c(103.0, 8 * DAY)).unwrap(); // day 8 -> Fri, return
+ // Friday now has two samples; both positive.
+ assert!(out.bins[4] > 0.0);
+ }
+
+ #[test]
+ fn zero_prev_close_uses_zero_return() {
+ let mut prof = DayOfWeekProfile::new(0);
+ prof.update(c(0.0, 0));
+ let out = prof.update(c(5.0, DAY)).unwrap();
+ assert_relative_eq!(out.bins[4], 0.0); // Friday, guarded return 0
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut prof = DayOfWeekProfile::new(0);
+ prof.update(c(100.0, 0));
+ prof.update(c(101.0, DAY));
+ prof.reset();
+ assert!(!prof.is_ready());
+ assert!(prof.value().is_none());
+ assert!(prof.update(c(100.0, 2 * DAY)).is_none());
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..30)
+ .map(|i| c(100.0 + f64::from(i % 5), i64::from(i) * DAY))
+ .collect();
+ let mut a = DayOfWeekProfile::new(0);
+ let mut b = DayOfWeekProfile::new(0);
+ assert_eq!(
+ a.batch(&candles),
+ candles.iter().map(|x| b.update(*x)).collect::>()
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/intraday_volatility_profile.rs b/crates/wickra-core/src/indicators/intraday_volatility_profile.rs
new file mode 100644
index 00000000..c4f24afb
--- /dev/null
+++ b/crates/wickra-core/src/indicators/intraday_volatility_profile.rs
@@ -0,0 +1,236 @@
+//! Intraday Volatility Profile — the return volatility in each intraday bucket.
+
+use crate::calendar::civil_from_timestamp;
+use crate::error::{Error, Result};
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Intraday Volatility Profile output: the per-bucket return standard deviation.
+///
+/// `bins[i]` is the sample standard deviation of the simple returns of all bars
+/// whose local time-of-day fell in bucket `i`. Buckets with fewer than two
+/// samples read `0.0`.
+#[derive(Debug, Clone, PartialEq)]
+pub struct IntradayVolatilityProfileOutput {
+ /// Per-bucket return standard deviation, earliest bucket first.
+ pub bins: Vec,
+}
+
+/// Return volatility bucketed by local time of day.
+///
+/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
+/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each
+/// bar's simple return `close / previous_close - 1` updates the per-bucket
+/// running variance (Welford), and the profile reports the per-bucket sample
+/// standard deviation. The first bar produces no output.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, IntradayVolatilityProfile};
+///
+/// let hour = 3_600_000;
+/// let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
+/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
+/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, hour).unwrap()).unwrap();
+/// assert_eq!(out.bins.len(), 24);
+/// ```
+#[derive(Debug, Clone)]
+pub struct IntradayVolatilityProfile {
+ buckets: usize,
+ utc_offset_minutes: i32,
+ prev_close: Option,
+ count: Vec,
+ mean: Vec,
+ m2: Vec,
+ last: Option,
+}
+
+impl IntradayVolatilityProfile {
+ /// Construct an Intraday Volatility Profile with `buckets` intraday slices.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::PeriodZero`] if `buckets == 0`.
+ pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result {
+ if buckets == 0 {
+ return Err(Error::PeriodZero);
+ }
+ Ok(Self {
+ buckets,
+ utc_offset_minutes,
+ prev_close: None,
+ count: vec![0; buckets],
+ mean: vec![0.0; buckets],
+ m2: vec![0.0; buckets],
+ last: None,
+ })
+ }
+
+ /// Configured `(buckets, utc_offset_minutes)`.
+ pub const fn params(&self) -> (usize, i32) {
+ (self.buckets, self.utc_offset_minutes)
+ }
+
+ /// Most recent profile if at least one return has been recorded.
+ pub fn value(&self) -> Option<&IntradayVolatilityProfileOutput> {
+ self.last.as_ref()
+ }
+
+ fn bucket_of(&self, minute_of_day: u32) -> usize {
+ let raw = (minute_of_day as usize * self.buckets) / 1440;
+ raw.min(self.buckets - 1)
+ }
+
+ fn snapshot(&self) -> IntradayVolatilityProfileOutput {
+ let bins = self
+ .count
+ .iter()
+ .zip(&self.m2)
+ .map(|(n, m2)| {
+ if *n >= 2 {
+ (m2 / (*n - 1) as f64).sqrt()
+ } else {
+ 0.0
+ }
+ })
+ .collect();
+ IntradayVolatilityProfileOutput { bins }
+ }
+}
+
+impl Indicator for IntradayVolatilityProfile {
+ type Input = Candle;
+ type Output = IntradayVolatilityProfileOutput;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
+ let result = if let Some(prev) = self.prev_close {
+ let ret = if prev == 0.0 {
+ 0.0
+ } else {
+ candle.close / prev - 1.0
+ };
+ let bucket = self.bucket_of(civil.minute_of_day());
+ self.count[bucket] += 1;
+ let delta = ret - self.mean[bucket];
+ self.mean[bucket] += delta / self.count[bucket] as f64;
+ let delta2 = ret - self.mean[bucket];
+ self.m2[bucket] += delta * delta2;
+ let out = self.snapshot();
+ self.last = Some(out.clone());
+ Some(out)
+ } else {
+ None
+ };
+ self.prev_close = Some(candle.close);
+ result
+ }
+
+ fn reset(&mut self) {
+ self.prev_close = None;
+ self.count.iter_mut().for_each(|x| *x = 0);
+ self.mean.iter_mut().for_each(|x| *x = 0.0);
+ self.m2.iter_mut().for_each(|x| *x = 0.0);
+ self.last = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ 2
+ }
+
+ fn is_ready(&self) -> bool {
+ self.last.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "IntradayVolatilityProfile"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ const HOUR: i64 = 3_600_000;
+ const DAY: i64 = 24 * HOUR;
+
+ fn c(close: f64, ts: i64) -> Candle {
+ Candle::new(close, close, close, close, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn rejects_zero_buckets() {
+ assert!(matches!(
+ IntradayVolatilityProfile::new(0, 0),
+ Err(Error::PeriodZero)
+ ));
+ }
+
+ #[test]
+ fn metadata_and_accessors() {
+ let prof = IntradayVolatilityProfile::new(24, 90).unwrap();
+ assert_eq!(prof.params(), (24, 90));
+ assert_eq!(prof.name(), "IntradayVolatilityProfile");
+ assert_eq!(prof.warmup_period(), 2);
+ assert!(!prof.is_ready());
+ assert!(prof.value().is_none());
+ }
+
+ #[test]
+ fn single_sample_bucket_has_zero_vol() {
+ let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
+ assert!(prof.update(c(100.0, 0)).is_none());
+ let out = prof.update(c(101.0, HOUR)).unwrap();
+ assert_eq!(out.bins.len(), 24);
+ assert_relative_eq!(out.bins[1], 0.0); // only one sample in bucket 1
+ assert!(prof.is_ready());
+ }
+
+ #[test]
+ fn std_matches_manual_two_samples() {
+ let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
+ prof.update(c(100.0, 0)); // 00:00
+ prof.update(c(101.0, HOUR)); // 01:00 r=0.01 into bucket 1
+ // Next day 01:00, r2 = 0.03 into bucket 1.
+ let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap();
+ // sample std of {0.01, 0.03} = sqrt(((.01-.02)^2+(.03-.02)^2)/1) = 0.01414..
+ let mean = 0.02;
+ let expected = (((0.01_f64 - mean).powi(2) + (0.03 - mean).powi(2)) / 1.0).sqrt();
+ assert_relative_eq!(out.bins[1], expected, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn zero_prev_close_uses_zero_return() {
+ let mut prof = IntradayVolatilityProfile::new(4, 0).unwrap();
+ prof.update(c(0.0, 0));
+ let out = prof.update(c(5.0, HOUR)).unwrap();
+ assert_relative_eq!(out.bins[0], 0.0);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
+ prof.update(c(100.0, 0));
+ prof.update(c(101.0, HOUR));
+ prof.reset();
+ assert!(!prof.is_ready());
+ assert!(prof.value().is_none());
+ assert!(prof.update(c(100.0, DAY)).is_none());
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..50)
+ .map(|i| c(100.0 + f64::from(i % 6), i64::from(i) * HOUR))
+ .collect();
+ let mut a = IntradayVolatilityProfile::new(12, 0).unwrap();
+ let mut b = IntradayVolatilityProfile::new(12, 0).unwrap();
+ 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 1e1195dd..8addd4cf 100644
--- a/crates/wickra-core/src/indicators/mod.rs
+++ b/crates/wickra-core/src/indicators/mod.rs
@@ -29,6 +29,7 @@ mod atr;
mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
+mod average_daily_range;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
@@ -67,6 +68,7 @@ mod counterattack;
mod cumulative_volume_index;
mod cvd;
mod cybernetic_cycle;
+mod day_of_week_profile;
mod decycler;
mod decycler_oscillator;
mod dema;
@@ -136,6 +138,7 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
+mod intraday_volatility_profile;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
@@ -202,6 +205,8 @@ mod on_neck;
mod opening_marubozu;
mod opening_range;
mod ou_half_life;
+mod overnight_gap;
+mod overnight_intraday_return;
mod pain_index;
mod pair_spread_zscore;
mod pairwise_beta;
@@ -242,7 +247,11 @@ mod rvi;
mod rvi_volatility;
mod rwi;
mod sar_ext;
+mod seasonal_z_score;
mod separating_lines;
+mod session_high_low;
+mod session_range;
+mod session_vwap;
mod sharpe_ratio;
mod shooting_star;
mod short_line;
@@ -295,6 +304,7 @@ mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
+mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod treynor_ratio;
@@ -306,6 +316,7 @@ mod tsf;
mod tsi;
mod tsv;
mod ttm_squeeze;
+mod turn_of_month;
mod tweezer;
mod two_crows;
mod typical_price;
@@ -322,6 +333,7 @@ mod variance_ratio;
mod vertical_horizontal_filter;
mod vidya;
mod volty_stop;
+mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod vortex;
@@ -368,6 +380,7 @@ pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
+pub use average_daily_range::AverageDailyRange;
pub use average_drawdown::AverageDrawdown;
pub use avg_price::AvgPrice;
pub use awesome_oscillator::AwesomeOscillator;
@@ -406,6 +419,7 @@ pub use counterattack::Counterattack;
pub use cumulative_volume_index::CumulativeVolumeIndex;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
+pub use day_of_week_profile::{DayOfWeekProfile, DayOfWeekProfileOutput};
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
pub use dema::Dema;
@@ -475,6 +489,7 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
+pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
@@ -541,6 +556,8 @@ pub use on_neck::OnNeck;
pub use opening_marubozu::OpeningMarubozu;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use ou_half_life::OuHalfLife;
+pub use overnight_gap::OvernightGap;
+pub use overnight_intraday_return::{OvernightIntradayReturn, OvernightIntradayReturnOutput};
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
@@ -581,7 +598,11 @@ pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sar_ext::SarExt;
+pub use seasonal_z_score::SeasonalZScore;
pub use separating_lines::SeparatingLines;
+pub use session_high_low::{SessionHighLow, SessionHighLowOutput};
+pub use session_range::{SessionRange, SessionRangeOutput};
+pub use session_vwap::SessionVwap;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use short_line::ShortLine;
@@ -634,6 +655,7 @@ pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
+pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use treynor_ratio::TreynorRatio;
@@ -645,6 +667,7 @@ pub use tsf::Tsf;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
+pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
@@ -661,6 +684,7 @@ pub use variance_ratio::VarianceRatio;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volty_stop::VoltyStop;
+pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use vortex::{Vortex, VortexOutput};
@@ -1118,6 +1142,23 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TickIndex",
],
),
+ (
+ "Seasonality & Session",
+ &[
+ "SessionVwap",
+ "SessionHighLow",
+ "SessionRange",
+ "AverageDailyRange",
+ "OvernightGap",
+ "OvernightIntradayReturn",
+ "TurnOfMonth",
+ "SeasonalZScore",
+ "TimeOfDayReturnProfile",
+ "DayOfWeekProfile",
+ "IntradayVolatilityProfile",
+ "VolumeByTimeProfile",
+ ],
+ ),
];
#[cfg(test)]
@@ -1146,6 +1187,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, 339, "FAMILIES total drifted from indicator count");
+ assert_eq!(total, 351, "FAMILIES total drifted from indicator count");
}
}
diff --git a/crates/wickra-core/src/indicators/overnight_gap.rs b/crates/wickra-core/src/indicators/overnight_gap.rs
new file mode 100644
index 00000000..7eb58866
--- /dev/null
+++ b/crates/wickra-core/src/indicators/overnight_gap.rs
@@ -0,0 +1,191 @@
+//! Overnight Gap — the return from the previous session's close to the current
+//! session's open, detected automatically at each day boundary.
+
+use crate::calendar::civil_from_timestamp;
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Close-to-open overnight gap as a simple return.
+///
+/// At every local day boundary the indicator computes
+/// `open / previous_close - 1`, where `previous_close` is the close of the last
+/// bar of the prior session and `open` is the open of the first bar of the new
+/// session. The value holds for the rest of the session until the next boundary.
+/// The boundary is the wall-clock day of [`Candle::timestamp`](crate::Candle)
+/// shifted by `utc_offset_minutes`. The first session yields no gap (there is no
+/// prior close to compare against).
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, OvernightGap};
+///
+/// let hour = 3_600_000;
+/// let mut gap = OvernightGap::new(0);
+/// // Day 1 closes at 100.
+/// assert!(gap.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
+/// // Day 2 opens at 105 -> gap = 105 / 100 - 1 = 0.05.
+/// let g = gap.update(Candle::new(105.0, 106.0, 104.0, 105.5, 1.0, 24 * hour).unwrap()).unwrap();
+/// assert!((g - 0.05).abs() < 1e-9);
+/// ```
+#[derive(Debug, Clone)]
+pub struct OvernightGap {
+ utc_offset_minutes: i32,
+ day_key: Option<(i64, u32, u32)>,
+ last_close: Option,
+ gap: Option,
+}
+
+impl OvernightGap {
+ /// Construct an Overnight Gap indicator with the given UTC offset (minutes).
+ pub const fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ utc_offset_minutes,
+ day_key: None,
+ last_close: None,
+ gap: None,
+ }
+ }
+
+ /// Configured UTC offset in minutes.
+ pub const fn utc_offset_minutes(&self) -> i32 {
+ self.utc_offset_minutes
+ }
+
+ /// Most recent overnight gap if at least one day boundary has been crossed.
+ pub const fn value(&self) -> Option {
+ self.gap
+ }
+}
+
+impl Indicator for OvernightGap {
+ type Input = Candle;
+ type Output = f64;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
+ let key = (civil.year, civil.month, civil.day);
+ if self.day_key != Some(key) {
+ if let Some(prev_close) = self.last_close {
+ self.gap = Some(if prev_close == 0.0 {
+ 0.0
+ } else {
+ candle.open / prev_close - 1.0
+ });
+ }
+ self.day_key = Some(key);
+ }
+ self.last_close = Some(candle.close);
+ self.gap
+ }
+
+ fn reset(&mut self) {
+ self.day_key = None;
+ self.last_close = None;
+ self.gap = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ 2
+ }
+
+ fn is_ready(&self) -> bool {
+ self.gap.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "OvernightGap"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ const HOUR: i64 = 3_600_000;
+
+ fn c(open: f64, close: f64, ts: i64) -> Candle {
+ let high = open.max(close);
+ let low = open.min(close);
+ Candle::new(open, high, low, close, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn metadata_and_accessors() {
+ let gap = OvernightGap::new(330);
+ assert_eq!(gap.utc_offset_minutes(), 330);
+ assert_eq!(gap.name(), "OvernightGap");
+ assert_eq!(gap.warmup_period(), 2);
+ assert!(!gap.is_ready());
+ assert!(gap.value().is_none());
+ }
+
+ #[test]
+ fn first_session_has_no_gap() {
+ let mut gap = OvernightGap::new(0);
+ assert!(gap.update(c(99.0, 100.0, 0)).is_none());
+ // Same day, still no gap.
+ assert!(gap.update(c(100.0, 101.0, HOUR)).is_none());
+ assert!(!gap.is_ready());
+ }
+
+ #[test]
+ fn computes_gap_at_day_boundary() {
+ let mut gap = OvernightGap::new(0);
+ gap.update(c(99.0, 100.0, 0)); // day 1 closes 100
+ let g = gap.update(c(105.0, 105.5, 24 * HOUR)).unwrap();
+ assert_relative_eq!(g, 0.05);
+ assert!(gap.is_ready());
+ // Holds for the rest of the session.
+ let same = gap.update(c(106.0, 107.0, 25 * HOUR)).unwrap();
+ assert_relative_eq!(same, 0.05);
+ }
+
+ #[test]
+ fn negative_gap_down() {
+ let mut gap = OvernightGap::new(0);
+ gap.update(c(99.0, 100.0, 0));
+ let g = gap.update(c(90.0, 91.0, 24 * HOUR)).unwrap();
+ assert_relative_eq!(g, -0.1);
+ }
+
+ #[test]
+ fn zero_prev_close_yields_zero_gap() {
+ let mut gap = OvernightGap::new(0);
+ gap.update(c(0.0, 0.0, 0)); // degenerate day 1 closing at 0
+ let g = gap.update(c(5.0, 6.0, 24 * HOUR)).unwrap();
+ assert_relative_eq!(g, 0.0);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut gap = OvernightGap::new(0);
+ gap.update(c(99.0, 100.0, 0));
+ gap.update(c(105.0, 105.5, 24 * HOUR));
+ gap.reset();
+ assert!(!gap.is_ready());
+ assert!(gap.value().is_none());
+ assert!(gap.update(c(10.0, 11.0, 48 * HOUR)).is_none());
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..50)
+ .map(|i| {
+ c(
+ 100.0 + f64::from(i % 7),
+ 100.0 + f64::from(i % 5),
+ i64::from(i) * 6 * HOUR,
+ )
+ })
+ .collect();
+ let mut a = OvernightGap::new(0);
+ let mut b = OvernightGap::new(0);
+ assert_eq!(
+ a.batch(&candles),
+ candles.iter().map(|x| b.update(*x)).collect::>()
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/overnight_intraday_return.rs b/crates/wickra-core/src/indicators/overnight_intraday_return.rs
new file mode 100644
index 00000000..b27e5828
--- /dev/null
+++ b/crates/wickra-core/src/indicators/overnight_intraday_return.rs
@@ -0,0 +1,225 @@
+//! Overnight vs. Intraday Return — decomposes a session's total return into its
+//! overnight (close-to-open) and intraday (open-to-close) components.
+
+use crate::calendar::civil_from_timestamp;
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// The two return components of the current session.
+///
+/// `overnight` is fixed at the session open (`open / previous_close - 1`);
+/// `intraday` updates with every bar (`close / open - 1`). Compounding the two —
+/// `(1 + overnight)(1 + intraday) - 1` — reconstructs the full previous-close to
+/// latest-close return.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct OvernightIntradayReturnOutput {
+ /// Close-to-open return carried into the session.
+ pub overnight: f64,
+ /// Open-to-latest-close return accumulated within the session.
+ pub intraday: f64,
+}
+
+/// Overnight / intraday return decomposition, re-anchored at each local day
+/// boundary of [`Candle::timestamp`](crate::Candle) shifted by
+/// `utc_offset_minutes`.
+///
+/// The first session yields no output (there is no prior close to anchor the
+/// overnight leg); from the second session onward every bar reports both
+/// components.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, OvernightIntradayReturn};
+///
+/// let hour = 3_600_000;
+/// let mut oi = OvernightIntradayReturn::new(0);
+/// // Day 1 closes at 100.
+/// assert!(oi.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
+/// // Day 2 opens 110 (overnight +10%), closes 121 (intraday +10%).
+/// let v = oi.update(Candle::new(110.0, 122.0, 109.0, 121.0, 1.0, 24 * hour).unwrap()).unwrap();
+/// assert!((v.overnight - 0.10).abs() < 1e-9);
+/// assert!((v.intraday - 0.10).abs() < 1e-9);
+/// ```
+#[derive(Debug, Clone)]
+pub struct OvernightIntradayReturn {
+ utc_offset_minutes: i32,
+ day_key: Option<(i64, u32, u32)>,
+ last_close: Option,
+ today_open: f64,
+ overnight: Option,
+ last: Option,
+}
+
+impl OvernightIntradayReturn {
+ /// Construct the indicator with the given UTC offset (minutes).
+ pub const fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ utc_offset_minutes,
+ day_key: None,
+ last_close: None,
+ today_open: 0.0,
+ overnight: None,
+ last: None,
+ }
+ }
+
+ /// Configured UTC offset in minutes.
+ pub const fn utc_offset_minutes(&self) -> i32 {
+ self.utc_offset_minutes
+ }
+
+ /// Most recent decomposition if at least one day boundary has been crossed.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+}
+
+impl Indicator for OvernightIntradayReturn {
+ type Input = Candle;
+ type Output = OvernightIntradayReturnOutput;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
+ let key = (civil.year, civil.month, civil.day);
+ if self.day_key != Some(key) {
+ if let Some(prev_close) = self.last_close {
+ self.overnight = Some(if prev_close == 0.0 {
+ 0.0
+ } else {
+ candle.open / prev_close - 1.0
+ });
+ }
+ self.today_open = candle.open;
+ self.day_key = Some(key);
+ }
+ self.last_close = Some(candle.close);
+ let overnight = self.overnight?;
+ let intraday = if self.today_open == 0.0 {
+ 0.0
+ } else {
+ candle.close / self.today_open - 1.0
+ };
+ let out = OvernightIntradayReturnOutput {
+ overnight,
+ intraday,
+ };
+ self.last = Some(out);
+ Some(out)
+ }
+
+ fn reset(&mut self) {
+ self.day_key = None;
+ self.last_close = None;
+ self.today_open = 0.0;
+ self.overnight = None;
+ self.last = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ 2
+ }
+
+ fn is_ready(&self) -> bool {
+ self.last.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "OvernightIntradayReturn"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ const HOUR: i64 = 3_600_000;
+
+ fn c(open: f64, close: f64, ts: i64) -> Candle {
+ let high = open.max(close) + 1.0;
+ let low = open.min(close) - 1.0;
+ Candle::new(open, high, low.max(0.0), close, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn metadata_and_accessors() {
+ let oi = OvernightIntradayReturn::new(-300);
+ assert_eq!(oi.utc_offset_minutes(), -300);
+ assert_eq!(oi.name(), "OvernightIntradayReturn");
+ assert_eq!(oi.warmup_period(), 2);
+ assert!(!oi.is_ready());
+ assert!(oi.value().is_none());
+ }
+
+ #[test]
+ fn first_session_yields_none() {
+ let mut oi = OvernightIntradayReturn::new(0);
+ assert!(oi.update(c(99.0, 100.0, 0)).is_none());
+ assert!(oi.update(c(100.0, 102.0, HOUR)).is_none());
+ assert!(!oi.is_ready());
+ }
+
+ #[test]
+ fn decomposes_overnight_and_intraday() {
+ let mut oi = OvernightIntradayReturn::new(0);
+ oi.update(c(99.0, 100.0, 0)); // day 1 close 100
+ let v = oi.update(c(110.0, 121.0, 24 * HOUR)).unwrap();
+ assert_relative_eq!(v.overnight, 0.10);
+ assert_relative_eq!(v.intraday, 0.10);
+ assert!(oi.is_ready());
+ }
+
+ #[test]
+ fn intraday_updates_through_the_session() {
+ let mut oi = OvernightIntradayReturn::new(0);
+ oi.update(c(99.0, 100.0, 0));
+ oi.update(c(110.0, 110.0, 24 * HOUR)); // open 110, close 110 -> intraday 0
+ let later = oi.update(c(111.0, 132.0, 25 * HOUR)).unwrap();
+ assert_relative_eq!(later.overnight, 0.10); // fixed at open
+ assert_relative_eq!(later.intraday, 0.20); // 132 / 110 - 1
+ }
+
+ #[test]
+ fn zero_anchors_yield_zero_components() {
+ let mut oi = OvernightIntradayReturn::new(0);
+ oi.update(c(1.0, 0.0, 0)); // day 1 closes at 0
+ // Day 2 opens at 0: overnight uses zero prev_close -> 0; intraday uses
+ // zero today_open -> 0.
+ let candle = Candle::new(0.0, 5.0, 0.0, 4.0, 1.0, 24 * HOUR).unwrap();
+ let v = oi.update(candle).unwrap();
+ assert_relative_eq!(v.overnight, 0.0);
+ assert_relative_eq!(v.intraday, 0.0);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut oi = OvernightIntradayReturn::new(0);
+ oi.update(c(99.0, 100.0, 0));
+ oi.update(c(110.0, 121.0, 24 * HOUR));
+ oi.reset();
+ assert!(!oi.is_ready());
+ assert!(oi.value().is_none());
+ assert!(oi.update(c(50.0, 55.0, 48 * HOUR)).is_none());
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..48)
+ .map(|i| {
+ c(
+ 100.0 + f64::from(i % 6),
+ 100.0 + f64::from(i % 4),
+ i64::from(i) * 8 * HOUR,
+ )
+ })
+ .collect();
+ let mut a = OvernightIntradayReturn::new(0);
+ let mut b = OvernightIntradayReturn::new(0);
+ assert_eq!(
+ a.batch(&candles),
+ candles.iter().map(|x| b.update(*x)).collect::>()
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/seasonal_z_score.rs b/crates/wickra-core/src/indicators/seasonal_z_score.rs
new file mode 100644
index 00000000..207955d3
--- /dev/null
+++ b/crates/wickra-core/src/indicators/seasonal_z_score.rs
@@ -0,0 +1,232 @@
+//! Seasonal Z-Score — how far the current bar's return sits from the historical
+//! mean return of bars in the *same hour of day*, in standard deviations.
+
+use crate::calendar::civil_from_timestamp;
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+const HOURS: usize = 24;
+
+/// Seasonal Z-Score keyed on hour of day.
+///
+/// For every bar the indicator forms the simple return `close / previous_close - 1`
+/// and compares it to the running mean and standard deviation of all prior
+/// returns that fell in the *same* local hour (the wall-clock hour of
+/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`). The
+/// output is `(return - hour_mean) / hour_std`. A bucket needs at least two prior
+/// samples before it can emit; a bucket with zero historical variance reports
+/// `0.0`. The per-hour statistics use Welford's online algorithm.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, SeasonalZScore};
+///
+/// let day = 24 * 3_600_000;
+/// let mut z = SeasonalZScore::new(0);
+/// // Same hour each day so they share a bucket; close grows then jumps.
+/// for (i, close) in [100.0, 101.0, 103.0].iter().enumerate() {
+/// z.update(Candle::new(*close, *close, *close, *close, 1.0, i as i64 * day).unwrap());
+/// }
+/// // Fourth same-hour sample has two priors in the bucket -> emits a z-score.
+/// let out = z.update(Candle::new(110.0, 110.0, 110.0, 110.0, 1.0, 3 * day).unwrap());
+/// assert!(out.is_some());
+/// ```
+#[derive(Debug, Clone)]
+pub struct SeasonalZScore {
+ utc_offset_minutes: i32,
+ prev_close: Option,
+ count: [u64; HOURS],
+ mean: [f64; HOURS],
+ m2: [f64; HOURS],
+ last: Option,
+}
+
+impl SeasonalZScore {
+ /// Construct a Seasonal Z-Score indicator with the given UTC offset (minutes).
+ pub const fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ utc_offset_minutes,
+ prev_close: None,
+ count: [0; HOURS],
+ mean: [0.0; HOURS],
+ m2: [0.0; HOURS],
+ last: None,
+ }
+ }
+
+ /// Configured UTC offset in minutes.
+ pub const fn utc_offset_minutes(&self) -> i32 {
+ self.utc_offset_minutes
+ }
+
+ /// Most recent z-score if a populated bucket has produced one.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+
+ fn z_for(&self, hour: usize, ret: f64) -> Option {
+ if self.count[hour] < 2 {
+ return None;
+ }
+ let variance = self.m2[hour] / (self.count[hour] - 1) as f64;
+ if variance > 0.0 {
+ Some((ret - self.mean[hour]) / variance.sqrt())
+ } else {
+ Some(0.0)
+ }
+ }
+
+ fn accumulate(&mut self, hour: usize, ret: f64) {
+ self.count[hour] += 1;
+ let delta = ret - self.mean[hour];
+ self.mean[hour] += delta / self.count[hour] as f64;
+ let delta2 = ret - self.mean[hour];
+ self.m2[hour] += delta * delta2;
+ }
+}
+
+impl Indicator for SeasonalZScore {
+ type Input = Candle;
+ type Output = f64;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
+ let hour = civil.hour as usize;
+ let result = if let Some(prev) = self.prev_close {
+ let ret = if prev == 0.0 {
+ 0.0
+ } else {
+ candle.close / prev - 1.0
+ };
+ let z = self.z_for(hour, ret);
+ self.accumulate(hour, ret);
+ z
+ } else {
+ None
+ };
+ self.prev_close = Some(candle.close);
+ if result.is_some() {
+ self.last = result;
+ }
+ result
+ }
+
+ fn reset(&mut self) {
+ self.prev_close = None;
+ self.count = [0; HOURS];
+ self.mean = [0.0; HOURS];
+ self.m2 = [0.0; HOURS];
+ self.last = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ 2
+ }
+
+ fn is_ready(&self) -> bool {
+ self.last.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "SeasonalZScore"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ const DAY: i64 = 24 * 3_600_000;
+
+ fn c(close: f64, ts: i64) -> Candle {
+ Candle::new(close, close, close, close, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn metadata_and_accessors() {
+ let z = SeasonalZScore::new(120);
+ assert_eq!(z.utc_offset_minutes(), 120);
+ assert_eq!(z.name(), "SeasonalZScore");
+ assert_eq!(z.warmup_period(), 2);
+ assert!(!z.is_ready());
+ assert!(z.value().is_none());
+ }
+
+ #[test]
+ fn no_output_until_bucket_has_two_priors() {
+ let mut z = SeasonalZScore::new(0);
+ // Each bar shares the same hour bucket (same time-of-day, daily spacing).
+ assert!(z.update(c(100.0, 0)).is_none()); // first: no return
+ assert!(z.update(c(101.0, DAY)).is_none()); // return #1 -> bucket has 0 priors
+ assert!(z.update(c(102.0, 2 * DAY)).is_none()); // return #2 -> bucket has 1 prior
+ // return #3 -> bucket has 2 priors -> emits.
+ assert!(z.update(c(104.0, 3 * DAY)).is_some());
+ assert!(z.is_ready());
+ }
+
+ #[test]
+ fn z_score_matches_manual_welford() {
+ let mut z = SeasonalZScore::new(0);
+ // Returns into one hourly bucket: r1 = 0.01, r2 = 0.02, r3 = 0.03.
+ z.update(c(100.0, 0));
+ z.update(c(101.0, DAY)); // r1 = 0.01
+ z.update(c(103.02, 2 * DAY)); // r2 = 0.02
+ // Priors {0.01, 0.02}: mean 0.015, sample std = sqrt(((.005)^2*2)/1).
+ let mean = 0.015;
+ let std = (((0.01_f64 - mean).powi(2) + (0.02 - mean).powi(2)) / 1.0).sqrt();
+ let r3 = 0.03;
+ let expected = (r3 - mean) / std;
+ let close = 103.02 * (1.0 + r3);
+ let out = z.update(c(close, 3 * DAY)).unwrap();
+ assert_relative_eq!(out, expected, epsilon = 1e-9);
+ }
+
+ #[test]
+ fn zero_variance_bucket_reports_zero() {
+ let mut z = SeasonalZScore::new(0);
+ // Constant return into the bucket -> variance 0 -> z = 0.
+ z.update(c(100.0, 0));
+ z.update(c(110.0, DAY)); // r1 = 0.10
+ z.update(c(121.0, 2 * DAY)); // r2 = 0.10
+ let out = z.update(c(133.1, 3 * DAY)).unwrap(); // r3 = 0.10
+ assert_relative_eq!(out, 0.0);
+ }
+
+ #[test]
+ fn zero_prev_close_uses_zero_return() {
+ let mut z = SeasonalZScore::new(0);
+ z.update(c(0.0, 0)); // prev close 0
+ z.update(c(0.0, DAY)); // ret = 0 (guarded), bucket sample
+ z.update(c(0.0, 2 * DAY)); // ret = 0, bucket now 2 priors
+ let out = z.update(c(0.0, 3 * DAY)).unwrap();
+ assert_relative_eq!(out, 0.0);
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut z = SeasonalZScore::new(0);
+ for i in 0..4 {
+ z.update(c(100.0 + f64::from(i), i64::from(i) * DAY));
+ }
+ z.reset();
+ assert!(!z.is_ready());
+ assert!(z.value().is_none());
+ assert!(z.update(c(100.0, 4 * DAY)).is_none());
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..50)
+ .map(|i| c(100.0 + f64::from(i % 9), i64::from(i) * 3 * 3_600_000))
+ .collect();
+ let mut a = SeasonalZScore::new(0);
+ let mut b = SeasonalZScore::new(0);
+ assert_eq!(
+ a.batch(&candles),
+ candles.iter().map(|x| b.update(*x)).collect::>()
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/session_high_low.rs b/crates/wickra-core/src/indicators/session_high_low.rs
new file mode 100644
index 00000000..0d32a64e
--- /dev/null
+++ b/crates/wickra-core/src/indicators/session_high_low.rs
@@ -0,0 +1,226 @@
+//! Session High/Low — the running high and low of the current calendar-day
+//! session, re-anchored automatically at each day boundary.
+
+use crate::calendar::civil_from_timestamp;
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Session High/Low output: the high and low established so far in the current
+/// session.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct SessionHighLowOutput {
+ /// Highest high seen since the current session opened.
+ pub high: f64,
+ /// Lowest low seen since the current session opened.
+ pub low: f64,
+}
+
+/// Running high / low of the current session, keyed off the wall-clock day of
+/// [`Candle::timestamp`](crate::Candle).
+///
+/// Unlike [`crate::OpeningRange`] or [`crate::InitialBalance`], which require the
+/// caller to invoke `reset()` at every session boundary, this indicator detects
+/// the boundary itself: whenever a candle falls on a different local calendar
+/// day (after shifting by `utc_offset_minutes`) the high / low are re-anchored to
+/// that candle. `utc_offset_minutes` lets callers align the day boundary to an
+/// exchange session — `0` for UTC, `-300` for U.S. Eastern standard time.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, SessionHighLow};
+///
+/// // One bar per hour; the day rolls over after 24 bars at UTC.
+/// let mut shl = SessionHighLow::new(0);
+/// let hour = 3_600_000;
+/// shl.update(Candle::new(100.0, 105.0, 99.0, 101.0, 1.0, 0).unwrap());
+/// let v = shl.update(Candle::new(101.0, 108.0, 100.0, 107.0, 1.0, hour).unwrap()).unwrap();
+/// assert_eq!(v.high, 108.0);
+/// assert_eq!(v.low, 99.0);
+/// // A bar on the next day re-anchors to that bar alone.
+/// let v = shl.update(Candle::new(50.0, 51.0, 49.0, 50.0, 1.0, 24 * hour).unwrap()).unwrap();
+/// assert_eq!(v.high, 51.0);
+/// assert_eq!(v.low, 49.0);
+/// ```
+#[derive(Debug, Clone)]
+pub struct SessionHighLow {
+ utc_offset_minutes: i32,
+ day_key: Option<(i64, u32, u32)>,
+ high: f64,
+ low: f64,
+ last: Option,
+}
+
+impl SessionHighLow {
+ /// Construct a Session High/Low indicator with the given UTC offset (minutes).
+ pub const fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ utc_offset_minutes,
+ day_key: None,
+ high: f64::NEG_INFINITY,
+ low: f64::INFINITY,
+ last: None,
+ }
+ }
+
+ /// Configured UTC offset in minutes.
+ pub const fn utc_offset_minutes(&self) -> i32 {
+ self.utc_offset_minutes
+ }
+
+ /// Most recent output if at least one bar has been seen.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+}
+
+impl Indicator for SessionHighLow {
+ type Input = Candle;
+ type Output = SessionHighLowOutput;
+
+ fn update(&mut self, candle: Candle) -> Option {
+ let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
+ let key = (civil.year, civil.month, civil.day);
+ if self.day_key == Some(key) {
+ if candle.high > self.high {
+ self.high = candle.high;
+ }
+ if candle.low < self.low {
+ self.low = candle.low;
+ }
+ } else {
+ self.day_key = Some(key);
+ self.high = candle.high;
+ self.low = candle.low;
+ }
+ let out = SessionHighLowOutput {
+ high: self.high,
+ low: self.low,
+ };
+ self.last = Some(out);
+ Some(out)
+ }
+
+ fn reset(&mut self) {
+ self.day_key = None;
+ self.high = f64::NEG_INFINITY;
+ self.low = f64::INFINITY;
+ self.last = None;
+ }
+
+ fn warmup_period(&self) -> usize {
+ 1
+ }
+
+ fn is_ready(&self) -> bool {
+ self.last.is_some()
+ }
+
+ fn name(&self) -> &'static str {
+ "SessionHighLow"
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::traits::BatchExt;
+ use approx::assert_relative_eq;
+
+ const HOUR: i64 = 3_600_000;
+
+ fn c(high: f64, low: f64, ts: i64) -> Candle {
+ let mid = f64::midpoint(high, low);
+ Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
+ }
+
+ #[test]
+ fn metadata_and_accessors() {
+ let shl = SessionHighLow::new(-300);
+ assert_eq!(shl.utc_offset_minutes(), -300);
+ assert_eq!(shl.name(), "SessionHighLow");
+ assert_eq!(shl.warmup_period(), 1);
+ assert!(!shl.is_ready());
+ assert!(shl.value().is_none());
+ }
+
+ #[test]
+ fn tracks_high_low_within_day() {
+ let mut shl = SessionHighLow::new(0);
+ let first = shl.update(c(105.0, 99.0, 0)).unwrap();
+ assert_relative_eq!(first.high, 105.0);
+ assert_relative_eq!(first.low, 99.0);
+ assert!(shl.is_ready());
+ let second = shl.update(c(108.0, 100.0, HOUR)).unwrap();
+ assert_relative_eq!(second.high, 108.0);
+ assert_relative_eq!(second.low, 99.0);
+ // A narrower bar does not shrink the range.
+ let third = shl.update(c(106.0, 101.0, 2 * HOUR)).unwrap();
+ assert_relative_eq!(third.high, 108.0);
+ assert_relative_eq!(third.low, 99.0);
+ // A bar with a lower low extends the range downward (same day).
+ let fourth = shl.update(c(107.0, 95.0, 3 * HOUR)).unwrap();
+ assert_relative_eq!(fourth.high, 108.0);
+ assert_relative_eq!(fourth.low, 95.0);
+ }
+
+ #[test]
+ fn re_anchors_on_new_day() {
+ let mut shl = SessionHighLow::new(0);
+ shl.update(c(105.0, 99.0, 0));
+ shl.update(c(108.0, 100.0, HOUR));
+ let next = shl.update(c(51.0, 49.0, 24 * HOUR)).unwrap();
+ assert_relative_eq!(next.high, 51.0);
+ assert_relative_eq!(next.low, 49.0);
+ }
+
+ #[test]
+ fn utc_offset_shifts_day_boundary() {
+ // Two bars 1h apart straddling UTC midnight. At UTC they are different
+ // days; at +120 min they fall on the same local day.
+ let pre = 23 * HOUR; // 1970-01-01 23:00 UTC
+ let post = 24 * HOUR; // 1970-01-02 00:00 UTC
+ let mut utc = SessionHighLow::new(0);
+ utc.update(c(105.0, 99.0, pre));
+ let rolled = utc.update(c(108.0, 100.0, post)).unwrap();
+ assert_relative_eq!(rolled.high, 108.0);
+ assert_relative_eq!(rolled.low, 100.0); // re-anchored
+
+ let mut shifted = SessionHighLow::new(120);
+ shifted.update(c(105.0, 99.0, pre));
+ let same = shifted.update(c(108.0, 100.0, post)).unwrap();
+ assert_relative_eq!(same.high, 108.0);
+ assert_relative_eq!(same.low, 99.0); // same local day, range kept
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut shl = SessionHighLow::new(0);
+ shl.update(c(105.0, 99.0, 0));
+ shl.reset();
+ assert!(!shl.is_ready());
+ assert!(shl.value().is_none());
+ let after = shl.update(c(60.0, 50.0, HOUR)).unwrap();
+ assert_relative_eq!(after.high, 60.0);
+ assert_relative_eq!(after.low, 50.0);
+ }
+
+ #[test]
+ fn batch_equals_streaming() {
+ let candles: Vec = (0..30)
+ .map(|i| {
+ c(
+ 100.0 + f64::from(i),
+ 90.0 + f64::from(i) * 0.5,
+ i64::from(i) * HOUR,
+ )
+ })
+ .collect();
+ let mut a = SessionHighLow::new(0);
+ let mut b = SessionHighLow::new(0);
+ assert_eq!(
+ a.batch(&candles),
+ candles.iter().map(|x| b.update(*x)).collect::>()
+ );
+ }
+}
diff --git a/crates/wickra-core/src/indicators/session_range.rs b/crates/wickra-core/src/indicators/session_range.rs
new file mode 100644
index 00000000..06fa49d8
--- /dev/null
+++ b/crates/wickra-core/src/indicators/session_range.rs
@@ -0,0 +1,248 @@
+//! Session Range — the high-minus-low range accumulated within each of the
+//! three canonical trading sessions (Asia / EU / US) of the current day.
+
+use crate::calendar::civil_from_timestamp;
+use crate::ohlcv::Candle;
+use crate::traits::Indicator;
+
+/// Session Range output: the current day's range within each session.
+///
+/// A session with no bars yet reports `0.0`. All three reset at the local day
+/// boundary.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct SessionRangeOutput {
+ /// High − low within the Asia session (local hours `00:00..08:00`).
+ pub asia: f64,
+ /// High − low within the EU session (local hours `08:00..16:00`).
+ pub eu: f64,
+ /// High − low within the US session (local hours `16:00..24:00`).
+ pub us: f64,
+}
+
+#[derive(Debug, Clone, Copy)]
+struct Extent {
+ high: f64,
+ low: f64,
+}
+
+impl Extent {
+ const EMPTY: Self = Self {
+ high: f64::NEG_INFINITY,
+ low: f64::INFINITY,
+ };
+
+ fn add(&mut self, candle: Candle) {
+ if candle.high > self.high {
+ self.high = candle.high;
+ }
+ if candle.low < self.low {
+ self.low = candle.low;
+ }
+ }
+
+ fn range(self) -> f64 {
+ if self.high >= self.low {
+ self.high - self.low
+ } else {
+ 0.0
+ }
+ }
+}
+
+/// Per-session high-low range, keyed off the wall-clock hour of
+/// [`Candle::timestamp`](crate::Candle).
+///
+/// The local day (after shifting by `utc_offset_minutes`) is split into three
+/// eight-hour sessions: **Asia** `00:00..08:00`, **EU** `08:00..16:00`, **US**
+/// `16:00..24:00`. Each session accumulates its own high / low; the reported
+/// range is `high - low`, or `0.0` before that session has seen a bar. All three
+/// re-anchor automatically at the day boundary.
+///
+/// # Example
+///
+/// ```
+/// use wickra_core::{Candle, Indicator, SessionRange};
+///
+/// let hour = 3_600_000;
+/// let mut sr = SessionRange::new(0);
+/// // 02:00 UTC — Asia session.
+/// sr.update(Candle::new(100.0, 104.0, 98.0, 101.0, 1.0, 2 * hour).unwrap());
+/// // 10:00 UTC — EU session.
+/// let v = sr.update(Candle::new(101.0, 110.0, 100.0, 109.0, 1.0, 10 * hour).unwrap()).unwrap();
+/// assert_eq!(v.asia, 6.0);
+/// assert_eq!(v.eu, 10.0);
+/// assert_eq!(v.us, 0.0);
+/// ```
+#[derive(Debug, Clone)]
+pub struct SessionRange {
+ utc_offset_minutes: i32,
+ day_key: Option<(i64, u32, u32)>,
+ sessions: [Extent; 3],
+ last: Option,
+}
+
+impl SessionRange {
+ /// Construct a Session Range indicator with the given UTC offset (minutes).
+ pub const fn new(utc_offset_minutes: i32) -> Self {
+ Self {
+ utc_offset_minutes,
+ day_key: None,
+ sessions: [Extent::EMPTY; 3],
+ last: None,
+ }
+ }
+
+ /// Configured UTC offset in minutes.
+ pub const fn utc_offset_minutes(&self) -> i32 {
+ self.utc_offset_minutes
+ }
+
+ /// Most recent output if at least one bar has been seen.
+ pub const fn value(&self) -> Option {
+ self.last
+ }
+
+ fn snapshot(&self) -> SessionRangeOutput {
+ SessionRangeOutput {
+ asia: self.sessions[0].range(),
+ eu: self.sessions[1].range(),
+ us: self.sessions[2].range(),
+ }
+ }
+}
+
+impl Indicator for SessionRange {
+ type Input = Candle;
+ type Output = SessionRangeOutput;
+
+ fn update(&mut self, candle: Candle) -> Option