From 3ab2d6ec2d060394ce27d66fd2483c652f0455e7 Mon Sep 17 00:00:00 2001 From: kingchenc Date: Wed, 3 Jun 2026 20:31:32 +0200 Subject: [PATCH] feat(seasonality): add the Seasonality & Session family (12 indicators) (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds the **Seasonality & Session** family — the first family that reads the wall-clock fields of `Candle::timestamp`. A new private `calendar` module decomposes an epoch-millisecond instant (shifted by a per-indicator `utc_offset_minutes`) into civil fields via Howard Hinnant's branch-light `civil_from_days` algorithm. Session / day / month rollovers are detected automatically, so callers never have to invoke `reset()` at a boundary. Indicator counter **339 → 351**; family count **20 → 21**. ## Indicators | Shape | Indicators | |-------|-----------| | Scalar (`f64`) | `SessionVwap`, `AverageDailyRange`, `OvernightGap`, `TurnOfMonth`, `SeasonalZScore` | | Struct | `SessionHighLow`, `SessionRange` (Asia/EU/US), `OvernightIntradayReturn` | | Profile (`Vec`) | `TimeOfDayReturnProfile`, `DayOfWeekProfile`, `IntradayVolatilityProfile`, `VolumeByTimeProfile` | ## Bindings The input is the **full** candle (`open, high, low, close, volume, timestamp`), not the `high/low/close` slice the value-indicator helper assumes, so the Python / Node / WASM bindings are custom full-candle implementations: - **Python** — `update((o,h,l,c,v,ts))`; `batch(open, high, low, close, volume, timestamp)` → `PyArray1` (scalar) / `PyArray2` (struct & profile), warmup rows `NaN`. - **Node** — `update(open, high, low, close, volume, timestamp)`; `batch(...)` → flat `Vec`; struct outputs as `#[napi(object)]` values. - **WASM** — `update` only (multi-input precedent); profiles as `Float64Array`, structs as camelCase objects, `timestamp` as `BigInt`. ## Verification - `wickra-core`: full per-branch unit tests, **100%** coverage target; 2852 lib tests + 334 doctests green. - `cargo clippy --workspace --all-targets --all-features -- -D warnings`: clean. - Node: 428 tests (dedicated `seasonality.test.js` streaming-vs-batch). - Python: full suite + dedicated `test_seasonality.py` streaming-vs-batch. - Counter check: mod-count == counted lib block == 351. --- CHANGELOG.md | 12 + README.md | 9 +- bindings/node/__tests__/seasonality.test.js | 96 +++ bindings/node/index.d.ts | 131 ++++ bindings/node/index.js | 14 +- bindings/node/src/lib.rs | 612 ++++++++++++++++++ bindings/python/python/wickra/__init__.py | 26 + bindings/python/src/lib.rs | 600 +++++++++++++++++ bindings/python/tests/test_seasonality.py | 132 ++++ bindings/wasm/src/lib.rs | 387 +++++++++++ crates/wickra-core/src/calendar.rs | 203 ++++++ .../src/indicators/average_daily_range.rs | 231 +++++++ .../src/indicators/day_of_week_profile.rs | 202 ++++++ .../indicators/intraday_volatility_profile.rs | 236 +++++++ crates/wickra-core/src/indicators/mod.rs | 43 +- .../src/indicators/overnight_gap.rs | 191 ++++++ .../indicators/overnight_intraday_return.rs | 225 +++++++ .../src/indicators/seasonal_z_score.rs | 232 +++++++ .../src/indicators/session_high_low.rs | 226 +++++++ .../src/indicators/session_range.rs | 248 +++++++ .../src/indicators/session_vwap.rs | 199 ++++++ .../indicators/time_of_day_return_profile.rs | 226 +++++++ .../src/indicators/turn_of_month.rs | 275 ++++++++ .../src/indicators/volume_by_time_profile.rs | 198 ++++++ crates/wickra-core/src/lib.rs | 96 +-- docs/README.md | 2 +- fuzz/fuzz_targets/indicator_update_candle.rs | 29 +- 27 files changed, 5026 insertions(+), 55 deletions(-) create mode 100644 bindings/node/__tests__/seasonality.test.js create mode 100644 bindings/python/tests/test_seasonality.py create mode 100644 crates/wickra-core/src/calendar.rs create mode 100644 crates/wickra-core/src/indicators/average_daily_range.rs create mode 100644 crates/wickra-core/src/indicators/day_of_week_profile.rs create mode 100644 crates/wickra-core/src/indicators/intraday_volatility_profile.rs create mode 100644 crates/wickra-core/src/indicators/overnight_gap.rs create mode 100644 crates/wickra-core/src/indicators/overnight_intraday_return.rs create mode 100644 crates/wickra-core/src/indicators/seasonal_z_score.rs create mode 100644 crates/wickra-core/src/indicators/session_high_low.rs create mode 100644 crates/wickra-core/src/indicators/session_range.rs create mode 100644 crates/wickra-core/src/indicators/session_vwap.rs create mode 100644 crates/wickra-core/src/indicators/time_of_day_return_profile.rs create mode 100644 crates/wickra-core/src/indicators/turn_of_month.rs create mode 100644 crates/wickra-core/src/indicators/volume_by_time_profile.rs 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 @@

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

[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml) @@ -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 { + 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) { + self.day_key = Some(key); + self.sessions = [Extent::EMPTY; 3]; + } + let session = (civil.hour / 8) as usize; // 0 Asia, 1 EU, 2 US + self.sessions[session].add(candle); + let out = self.snapshot(); + self.last = Some(out); + Some(out) + } + + fn reset(&mut self) { + self.day_key = None; + self.sessions = [Extent::EMPTY; 3]; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SessionRange" + } +} + +#[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 sr = SessionRange::new(60); + assert_eq!(sr.utc_offset_minutes(), 60); + assert_eq!(sr.name(), "SessionRange"); + assert_eq!(sr.warmup_period(), 1); + assert!(!sr.is_ready()); + assert!(sr.value().is_none()); + } + + #[test] + fn assigns_bars_to_sessions() { + let mut sr = SessionRange::new(0); + let asia = sr.update(c(104.0, 98.0, 2 * HOUR)).unwrap(); + assert_relative_eq!(asia.asia, 6.0); + assert_relative_eq!(asia.eu, 0.0); + assert_relative_eq!(asia.us, 0.0); + assert!(sr.is_ready()); + let eu = sr.update(c(110.0, 100.0, 10 * HOUR)).unwrap(); + assert_relative_eq!(eu.eu, 10.0); + let us = sr.update(c(120.0, 118.0, 20 * HOUR)).unwrap(); + assert_relative_eq!(us.us, 2.0); + assert_relative_eq!(us.asia, 6.0); + } + + #[test] + fn widens_within_one_session() { + let mut sr = SessionRange::new(0); + sr.update(c(104.0, 98.0, HOUR)); + let wider = sr.update(c(106.0, 95.0, 3 * HOUR)).unwrap(); + assert_relative_eq!(wider.asia, 11.0); + } + + #[test] + fn resets_sessions_on_new_day() { + let mut sr = SessionRange::new(0); + sr.update(c(104.0, 98.0, 2 * HOUR)); + sr.update(c(110.0, 100.0, 10 * HOUR)); + let next = sr.update(c(101.0, 99.0, (24 + 2) * HOUR)).unwrap(); + assert_relative_eq!(next.asia, 2.0); + assert_relative_eq!(next.eu, 0.0); + } + + #[test] + fn utc_offset_moves_bar_between_sessions() { + // 07:00 UTC is Asia; shifted +120 min it becomes 09:00 -> EU. + let mut utc = SessionRange::new(0); + let a = utc.update(c(104.0, 98.0, 7 * HOUR)).unwrap(); + assert_relative_eq!(a.asia, 6.0); + assert_relative_eq!(a.eu, 0.0); + + let mut shifted = SessionRange::new(120); + let e = shifted.update(c(104.0, 98.0, 7 * HOUR)).unwrap(); + assert_relative_eq!(e.asia, 0.0); + assert_relative_eq!(e.eu, 6.0); + } + + #[test] + fn reset_clears_state() { + let mut sr = SessionRange::new(0); + sr.update(c(104.0, 98.0, 2 * HOUR)); + sr.reset(); + assert!(!sr.is_ready()); + assert!(sr.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| { + c( + 100.0 + f64::from(i % 5), + 95.0 - f64::from(i % 3), + i64::from(i) * HOUR, + ) + }) + .collect(); + let mut a = SessionRange::new(0); + let mut b = SessionRange::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/session_vwap.rs b/crates/wickra-core/src/indicators/session_vwap.rs new file mode 100644 index 00000000..c14f74f9 --- /dev/null +++ b/crates/wickra-core/src/indicators/session_vwap.rs @@ -0,0 +1,199 @@ +//! Session VWAP — the volume-weighted average price accumulated since the start +//! of the current calendar-day session, re-anchored automatically each day. + +use crate::calendar::civil_from_timestamp; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-weighted average price reset at each local day boundary. +/// +/// Each bar contributes its typical price `(high + low + close) / 3` weighted by +/// volume. The running VWAP is `Σ(typical · volume) / Σ volume` over the current +/// session; if the session's volume is still zero the indicator falls back to the +/// latest typical price so the output is always finite. The session boundary is +/// the wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by +/// `utc_offset_minutes`. +/// +/// Where [`crate::RollingVwap`] averages over a fixed bar window and +/// [`crate::AnchoredVwap`] anchors at a caller-chosen bar, Session VWAP anchors +/// at the automatically detected day open. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, SessionVwap}; +/// +/// let hour = 3_600_000; +/// let mut vwap = SessionVwap::new(0); +/// // typical = 100, volume 10. +/// vwap.update(Candle::new(100.0, 100.0, 100.0, 100.0, 10.0, 0).unwrap()); +/// // typical = 110, volume 30 -> VWAP = (100*10 + 110*30) / 40 = 107.5. +/// let v = vwap.update(Candle::new(110.0, 110.0, 110.0, 110.0, 30.0, hour).unwrap()).unwrap(); +/// assert!((v - 107.5).abs() < 1e-9); +/// ``` +#[derive(Debug, Clone)] +pub struct SessionVwap { + utc_offset_minutes: i32, + day_key: Option<(i64, u32, u32)>, + cum_pv: f64, + cum_volume: f64, + last: Option, +} + +impl SessionVwap { + /// Construct a Session VWAP indicator with the given UTC offset (minutes). + pub const fn new(utc_offset_minutes: i32) -> Self { + Self { + utc_offset_minutes, + day_key: None, + cum_pv: 0.0, + cum_volume: 0.0, + last: None, + } + } + + /// Configured UTC offset in minutes. + pub const fn utc_offset_minutes(&self) -> i32 { + self.utc_offset_minutes + } + + /// Most recent VWAP if at least one bar has been seen. + pub const fn value(&self) -> Option { + self.last + } +} + +impl Indicator for SessionVwap { + 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) { + self.day_key = Some(key); + self.cum_pv = 0.0; + self.cum_volume = 0.0; + } + let typical = (candle.high + candle.low + candle.close) / 3.0; + self.cum_pv += typical * candle.volume; + self.cum_volume += candle.volume; + let vwap = if self.cum_volume > 0.0 { + self.cum_pv / self.cum_volume + } else { + typical + }; + self.last = Some(vwap); + Some(vwap) + } + + fn reset(&mut self) { + self.day_key = None; + self.cum_pv = 0.0; + self.cum_volume = 0.0; + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "SessionVwap" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(price: f64, volume: f64, ts: i64) -> Candle { + Candle::new(price, price, price, price, volume, ts).unwrap() + } + + #[test] + fn metadata_and_accessors() { + let vwap = SessionVwap::new(-480); + assert_eq!(vwap.utc_offset_minutes(), -480); + assert_eq!(vwap.name(), "SessionVwap"); + assert_eq!(vwap.warmup_period(), 1); + assert!(!vwap.is_ready()); + assert!(vwap.value().is_none()); + } + + #[test] + fn volume_weights_the_average() { + let mut vwap = SessionVwap::new(0); + let first = vwap.update(c(100.0, 10.0, 0)).unwrap(); + assert_relative_eq!(first, 100.0); + assert!(vwap.is_ready()); + let second = vwap.update(c(110.0, 30.0, HOUR)).unwrap(); + assert_relative_eq!(second, 107.5); + } + + #[test] + fn zero_volume_session_falls_back_to_typical() { + let mut vwap = SessionVwap::new(0); + let v = vwap.update(c(100.0, 0.0, 0)).unwrap(); + assert_relative_eq!(v, 100.0); + let v2 = vwap.update(c(120.0, 0.0, HOUR)).unwrap(); + assert_relative_eq!(v2, 120.0); + } + + #[test] + fn re_anchors_on_new_day() { + let mut vwap = SessionVwap::new(0); + vwap.update(c(100.0, 10.0, 0)); + vwap.update(c(110.0, 30.0, HOUR)); + // New day: VWAP restarts from the first bar of day 2. + let next = vwap.update(c(200.0, 5.0, 24 * HOUR)).unwrap(); + assert_relative_eq!(next, 200.0); + } + + #[test] + fn typical_price_uses_high_low_close() { + let mut vwap = SessionVwap::new(0); + // typical = (120 + 90 + 102) / 3 = 104. + let candle = Candle::new(100.0, 120.0, 90.0, 102.0, 10.0, 0).unwrap(); + let v = vwap.update(candle).unwrap(); + assert_relative_eq!(v, 104.0); + } + + #[test] + fn reset_clears_state() { + let mut vwap = SessionVwap::new(0); + vwap.update(c(100.0, 10.0, 0)); + vwap.reset(); + assert!(!vwap.is_ready()); + assert!(vwap.value().is_none()); + let after = vwap.update(c(50.0, 1.0, HOUR)).unwrap(); + assert_relative_eq!(after, 50.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..30) + .map(|i| { + c( + 100.0 + f64::from(i), + 1.0 + f64::from(i % 4), + i64::from(i) * HOUR, + ) + }) + .collect(); + let mut a = SessionVwap::new(0); + let mut b = SessionVwap::new(0); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/time_of_day_return_profile.rs b/crates/wickra-core/src/indicators/time_of_day_return_profile.rs new file mode 100644 index 00000000..20bd1340 --- /dev/null +++ b/crates/wickra-core/src/indicators/time_of_day_return_profile.rs @@ -0,0 +1,226 @@ +//! Time-of-Day Return Profile — the mean bar return in each intraday time bucket. + +use crate::calendar::civil_from_timestamp; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Time-of-Day Return Profile output: the per-bucket mean return. +/// +/// `bins[i]` is the mean simple return of all bars whose local time-of-day fell +/// in bucket `i`, where bucket `i` spans the minutes +/// `[i * 1440 / bins.len(), (i + 1) * 1440 / bins.len())`. Empty buckets read +/// `0.0`. +#[derive(Debug, Clone, PartialEq)] +pub struct TimeOfDayReturnProfileOutput { + /// Per-bucket mean return, earliest bucket first. Length equals `buckets`. + pub bins: Vec, +} + +/// Mean bar return bucketed by local time of day. +/// +/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle) +/// shifted by `utc_offset_minutes`) is divided into `buckets` equal slices. Each +/// bar's simple return `close / previous_close - 1` is accumulated into the bucket +/// of its time-of-day, and the profile reports the running mean per bucket. The +/// first bar produces no output (no return yet). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TimeOfDayReturnProfile}; +/// +/// let hour = 3_600_000; +/// let mut prof = TimeOfDayReturnProfile::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 TimeOfDayReturnProfile { + buckets: usize, + utc_offset_minutes: i32, + prev_close: Option, + sum: Vec, + count: Vec, + last: Option, +} + +impl TimeOfDayReturnProfile { + /// Construct a Time-of-Day Return 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, + sum: vec![0.0; buckets], + count: vec![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<&TimeOfDayReturnProfileOutput> { + 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) -> TimeOfDayReturnProfileOutput { + let bins = self + .sum + .iter() + .zip(&self.count) + .map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 }) + .collect(); + TimeOfDayReturnProfileOutput { bins } + } +} + +impl Indicator for TimeOfDayReturnProfile { + type Input = Candle; + type Output = TimeOfDayReturnProfileOutput; + + 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.sum[bucket] += ret; + self.count[bucket] += 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.iter_mut().for_each(|x| *x = 0.0); + self.count.iter_mut().for_each(|x| *x = 0); + self.last = None; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "TimeOfDayReturnProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, 1.0, ts).unwrap() + } + + #[test] + fn rejects_zero_buckets() { + assert!(matches!( + TimeOfDayReturnProfile::new(0, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn metadata_and_accessors() { + let prof = TimeOfDayReturnProfile::new(24, -300).unwrap(); + assert_eq!(prof.params(), (24, -300)); + assert_eq!(prof.name(), "TimeOfDayReturnProfile"); + assert_eq!(prof.warmup_period(), 2); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + } + + #[test] + fn buckets_by_hour_and_means_returns() { + let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap(); + assert!(prof.update(c(100.0, 0)).is_none()); // 00:00, no return + // 01:00 return +0.01 -> bucket 1. + let out = prof.update(c(101.0, HOUR)).unwrap(); + assert_eq!(out.bins.len(), 24); + assert_relative_eq!(out.bins[1], 0.01); + assert_relative_eq!(out.bins[0], 0.0); + assert!(prof.is_ready()); + // 01:00 next day, return -> averages into bucket 1. + let out = prof.update(c(102.01, 25 * HOUR)).unwrap(); + // two returns in bucket 1: 0.01 and 0.01 -> mean 0.01. + assert_relative_eq!(out.bins[1], 0.01); + } + + #[test] + fn last_bucket_clamped_for_end_of_day() { + let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap(); + prof.update(c(100.0, 23 * HOUR)); + // 23:59 -> minute 1439 -> bucket min(23, 23) = 23. + let out = prof.update(c(110.0, 23 * HOUR + 59 * 60_000)).unwrap(); + assert_relative_eq!(out.bins[23], 0.10); + } + + #[test] + fn zero_prev_close_uses_zero_return() { + let mut prof = TimeOfDayReturnProfile::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 = TimeOfDayReturnProfile::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, 2 * HOUR)).is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(100.0 + f64::from(i % 7), i64::from(i) * HOUR)) + .collect(); + let mut a = TimeOfDayReturnProfile::new(12, 0).unwrap(); + let mut b = TimeOfDayReturnProfile::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/turn_of_month.rs b/crates/wickra-core/src/indicators/turn_of_month.rs new file mode 100644 index 00000000..f28eb79c --- /dev/null +++ b/crates/wickra-core/src/indicators/turn_of_month.rs @@ -0,0 +1,275 @@ +//! Turn-of-Month Effect — the mean daily return of sessions that fall inside the +//! turn-of-month window (the last `n_last` and first `n_first` days of a month). + +use crate::calendar::{civil_from_timestamp, days_in_month}; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Whether a day-of-month lies in the turn-of-month window. +/// +/// The window is the first `n_first` calendar days plus the last `n_last` days of +/// the month (`days_in_month - n_last < dom`). +fn in_turn_window(dom: u32, dim: u32, n_first: u32, n_last: u32) -> bool { + dom <= n_first || dom > dim.saturating_sub(n_last) +} + +/// Turn-of-Month effect: the running mean of daily close-to-close returns for the +/// sessions that fall in the turn-of-month window. +/// +/// Each completed session (the wall-clock day of +/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`) +/// contributes its return `close / previous_close - 1`. Only sessions whose +/// day-of-month is within the first `n_first` or last `n_last` days of their month +/// are averaged; the rest are ignored. The classic effect uses `n_first = 3`, +/// `n_last = 1`. +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, TurnOfMonth}; +/// +/// let day = 24 * 3_600_000; +/// // 2021-01-29 .. 02-02 — all turn-of-month days with n_first=3, n_last=1. +/// let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); +/// let start = 1_611_878_400_000; // 2021-01-29 00:00 UTC +/// let mut last = None; +/// for (i, close) in [100.0, 101.0, 102.0, 103.0].iter().enumerate() { +/// let ts = start + i as i64 * day; +/// last = tom.update(Candle::new(*close, *close, *close, *close, 1.0, ts).unwrap()); +/// } +/// assert!(last.is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct TurnOfMonth { + n_first: u32, + n_last: u32, + utc_offset_minutes: i32, + day: Option<(i64, u32, u32)>, + cur_close: f64, + prev_day_close: Option, + sum: f64, + count: u64, +} + +impl TurnOfMonth { + /// Construct a Turn-of-Month indicator. + /// + /// # Errors + /// + /// Returns [`Error::PeriodZero`] if both `n_first` and `n_last` are zero (the + /// window would never include a day). + pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> Result { + if n_first == 0 && n_last == 0 { + return Err(Error::PeriodZero); + } + Ok(Self { + n_first, + n_last, + utc_offset_minutes, + day: None, + cur_close: 0.0, + prev_day_close: None, + sum: 0.0, + count: 0, + }) + } + + /// Classic turn-of-month window: first 3 and last 1 day of the month. + pub fn classic() -> Self { + Self::new(3, 1, 0).expect("classic turn-of-month window is valid") + } + + /// Configured `(n_first, n_last, utc_offset_minutes)`. + pub const fn params(&self) -> (u32, u32, i32) { + (self.n_first, self.n_last, self.utc_offset_minutes) + } + + /// Most recent mean turn-of-month return if any in-window day has completed. + pub fn value(&self) -> Option { + if self.count == 0 { + None + } else { + Some(self.sum / self.count as f64) + } + } + + /// Settle the just-finished day `(year, month, dom)` whose last close is + /// `self.cur_close`, then start `next_key`. + fn roll_into( + &mut self, + year: i64, + month: u32, + dom: u32, + next_key: (i64, u32, u32), + close: f64, + ) { + if let Some(prev) = self.prev_day_close { + let ret = if prev == 0.0 { + 0.0 + } else { + self.cur_close / prev - 1.0 + }; + if in_turn_window(dom, days_in_month(year, month), self.n_first, self.n_last) { + self.sum += ret; + self.count += 1; + } + } + self.prev_day_close = Some(self.cur_close); + self.day = Some(next_key); + self.cur_close = close; + } +} + +impl Indicator for TurnOfMonth { + 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 { + Some(prev) if prev == key => { + self.cur_close = candle.close; + } + Some((year, month, dom)) => { + self.roll_into(year, month, dom, key, candle.close); + } + None => { + self.day = Some(key); + self.cur_close = candle.close; + } + } + self.value() + } + + fn reset(&mut self) { + self.day = None; + self.cur_close = 0.0; + self.prev_day_close = None; + self.sum = 0.0; + self.count = 0; + } + + fn warmup_period(&self) -> usize { + 2 + } + + fn is_ready(&self) -> bool { + self.count > 0 + } + + fn name(&self) -> &'static str { + "TurnOfMonth" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const DAY: i64 = 24 * 3_600_000; + // 2021-01-28 00:00 UTC. + const JAN28_2021: i64 = 1_611_792_000_000; + + fn c(close: f64, ts: i64) -> Candle { + Candle::new(close, close, close, close, 1.0, ts).unwrap() + } + + #[test] + fn window_predicate_branches() { + // First-days branch. + assert!(in_turn_window(1, 31, 3, 1)); + assert!(in_turn_window(3, 31, 3, 1)); + assert!(!in_turn_window(4, 31, 3, 1)); + // Last-days branch. + assert!(in_turn_window(31, 31, 3, 1)); + assert!(!in_turn_window(30, 31, 3, 1)); + // Saturating subtraction when n_last exceeds the month length. + assert!(in_turn_window(1, 28, 0, 40)); + } + + #[test] + fn rejects_empty_window() { + assert!(matches!(TurnOfMonth::new(0, 0, 0), Err(Error::PeriodZero))); + } + + #[test] + fn metadata_and_accessors() { + let tom = TurnOfMonth::classic(); + assert_eq!(tom.params(), (3, 1, 0)); + assert_eq!(tom.name(), "TurnOfMonth"); + assert_eq!(tom.warmup_period(), 2); + assert!(!tom.is_ready()); + assert!(tom.value().is_none()); + } + + #[test] + fn averages_in_window_returns_only() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + // 2021-01-28 (out of window, no prior close): close 100. + assert!(tom.update(c(100.0, JAN28_2021)).is_none()); + // 2021-01-29 (out of window: dom 29, dim 31 -> 29 <= 30): return ignored. + assert!(tom.update(c(110.0, JAN28_2021 + DAY)).is_none()); + // 2021-01-30 (out of window): completes 01-29; still none. + assert!(tom.update(c(120.0, JAN28_2021 + 2 * DAY)).is_none()); + // 2021-01-31 (last day, in window): completes 01-30 (out). Still none. + assert!(tom.update(c(121.0, JAN28_2021 + 3 * DAY)).is_none()); + // 2021-02-01 (first day, in window): completes 01-31 (in window). + // return = 121 / 120 - 1. + let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap(); + assert_relative_eq!(v, 121.0 / 120.0 - 1.0); + assert!(tom.is_ready()); + } + + #[test] + fn zero_prev_close_contributes_zero() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + // 2021-01-30 closes at 0 — becomes the prior close for 01-31. + tom.update(c(0.0, JAN28_2021 + 2 * DAY)); + // 2021-01-31 (last day, in window): finalizes 01-30 with no prior -> no + // contribution, but records prev_day_close = 0. + tom.update(c(5.0, JAN28_2021 + 3 * DAY)); + // 2021-02-01 (in window): finalizes 01-31 with prev_close 0 -> ret 0. + let v = tom.update(c(50.0, JAN28_2021 + 4 * DAY)).unwrap(); + assert_relative_eq!(v, 0.0); + } + + #[test] + fn same_day_bars_use_latest_close() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + // 2021-01-30 closes at 100 (prior day, sets prev_day_close). + tom.update(c(100.0, JAN28_2021 + 2 * DAY)); + // 2021-01-31 two bars on the same day; the later close (120) wins. + tom.update(c(110.0, JAN28_2021 + 3 * DAY)); + tom.update(c(120.0, JAN28_2021 + 3 * DAY + 3_600_000)); + // 2021-02-01 (in window) finalizes 01-31: return = 120 / 100 - 1 = 0.20. + let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap(); + assert_relative_eq!(v, 0.20); + } + + #[test] + fn reset_clears_state() { + let mut tom = TurnOfMonth::new(3, 1, 0).unwrap(); + tom.update(c(121.0, JAN28_2021 + 3 * DAY)); + tom.update(c(130.0, JAN28_2021 + 4 * DAY)); + tom.reset(); + assert!(!tom.is_ready()); + assert!(tom.value().is_none()); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..40) + .map(|i| c(100.0 + f64::from(i), JAN28_2021 + i64::from(i) * DAY)) + .collect(); + let mut a = TurnOfMonth::new(3, 2, 0).unwrap(); + let mut b = TurnOfMonth::new(3, 2, 0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/indicators/volume_by_time_profile.rs b/crates/wickra-core/src/indicators/volume_by_time_profile.rs new file mode 100644 index 00000000..4bbdce85 --- /dev/null +++ b/crates/wickra-core/src/indicators/volume_by_time_profile.rs @@ -0,0 +1,198 @@ +//! Volume-by-Time Profile — the mean traded volume in each intraday bucket. + +use crate::calendar::civil_from_timestamp; +use crate::error::{Error, Result}; +use crate::ohlcv::Candle; +use crate::traits::Indicator; + +/// Volume-by-Time Profile output: the per-bucket mean volume. +/// +/// `bins[i]` is the mean volume of all bars whose local time-of-day fell in +/// bucket `i`. Empty buckets read `0.0`. +#[derive(Debug, Clone, PartialEq)] +pub struct VolumeByTimeProfileOutput { + /// Per-bucket mean volume, earliest bucket first. Length equals `buckets`. + pub bins: Vec, +} + +/// Mean traded volume 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 volume is accumulated into the bucket of its time-of-day, and the +/// profile reports the running mean volume per bucket. Unlike the return +/// profiles, the first bar already produces output (volume needs no prior bar). +/// +/// # Example +/// +/// ``` +/// use wickra_core::{Candle, Indicator, VolumeByTimeProfile}; +/// +/// let hour = 3_600_000; +/// let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); +/// let out = prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 500.0, hour).unwrap()).unwrap(); +/// assert_eq!(out.bins.len(), 24); +/// assert_eq!(out.bins[1], 500.0); +/// ``` +#[derive(Debug, Clone)] +pub struct VolumeByTimeProfile { + buckets: usize, + utc_offset_minutes: i32, + sum: Vec, + count: Vec, + last: Option, +} + +impl VolumeByTimeProfile { + /// Construct a Volume-by-Time 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, + sum: vec![0.0; buckets], + count: vec![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 bar has been seen. + pub fn value(&self) -> Option<&VolumeByTimeProfileOutput> { + 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) -> VolumeByTimeProfileOutput { + let bins = self + .sum + .iter() + .zip(&self.count) + .map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 }) + .collect(); + VolumeByTimeProfileOutput { bins } + } +} + +impl Indicator for VolumeByTimeProfile { + type Input = Candle; + type Output = VolumeByTimeProfileOutput; + + fn update(&mut self, candle: Candle) -> Option { + let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes); + let bucket = self.bucket_of(civil.minute_of_day()); + self.sum[bucket] += candle.volume; + self.count[bucket] += 1; + let out = self.snapshot(); + self.last = Some(out.clone()); + Some(out) + } + + fn reset(&mut self) { + self.sum.iter_mut().for_each(|x| *x = 0.0); + self.count.iter_mut().for_each(|x| *x = 0); + self.last = None; + } + + fn warmup_period(&self) -> usize { + 1 + } + + fn is_ready(&self) -> bool { + self.last.is_some() + } + + fn name(&self) -> &'static str { + "VolumeByTimeProfile" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::BatchExt; + use approx::assert_relative_eq; + + const HOUR: i64 = 3_600_000; + + fn c(volume: f64, ts: i64) -> Candle { + Candle::new(100.0, 100.0, 100.0, 100.0, volume, ts).unwrap() + } + + #[test] + fn rejects_zero_buckets() { + assert!(matches!( + VolumeByTimeProfile::new(0, 0), + Err(Error::PeriodZero) + )); + } + + #[test] + fn metadata_and_accessors() { + let prof = VolumeByTimeProfile::new(24, -60).unwrap(); + assert_eq!(prof.params(), (24, -60)); + assert_eq!(prof.name(), "VolumeByTimeProfile"); + assert_eq!(prof.warmup_period(), 1); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + } + + #[test] + fn emits_from_first_bar_and_means_volume() { + let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); + let out = prof.update(c(500.0, HOUR)).unwrap(); // 01:00 -> bucket 1 + assert_eq!(out.bins.len(), 24); + assert_relative_eq!(out.bins[1], 500.0); + assert_relative_eq!(out.bins[0], 0.0); + assert!(prof.is_ready()); + // Next day 01:00, volume 700 -> mean (500 + 700) / 2 = 600. + let out = prof.update(c(700.0, 25 * HOUR)).unwrap(); + assert_relative_eq!(out.bins[1], 600.0); + } + + #[test] + fn last_bucket_clamped() { + let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); + // 23:59 -> minute 1439 -> bucket 23. + let out = prof.update(c(300.0, 23 * HOUR + 59 * 60_000)).unwrap(); + assert_relative_eq!(out.bins[23], 300.0); + } + + #[test] + fn reset_clears_state() { + let mut prof = VolumeByTimeProfile::new(24, 0).unwrap(); + prof.update(c(500.0, HOUR)); + prof.reset(); + assert!(!prof.is_ready()); + assert!(prof.value().is_none()); + let out = prof.update(c(100.0, 2 * HOUR)).unwrap(); + assert_relative_eq!(out.bins[2], 100.0); + } + + #[test] + fn batch_equals_streaming() { + let candles: Vec = (0..50) + .map(|i| c(100.0 + f64::from(i % 8), i64::from(i) * HOUR)) + .collect(); + let mut a = VolumeByTimeProfile::new(12, 0).unwrap(); + let mut b = VolumeByTimeProfile::new(12, 0).unwrap(); + assert_eq!( + a.batch(&candles), + candles.iter().map(|x| b.update(*x)).collect::>() + ); + } +} diff --git a/crates/wickra-core/src/lib.rs b/crates/wickra-core/src/lib.rs index 4563e799..b3e9339f 100644 --- a/crates/wickra-core/src/lib.rs +++ b/crates/wickra-core/src/lib.rs @@ -42,6 +42,7 @@ // builds — library code is still linted for genuinely large stack arrays. #![cfg_attr(test, allow(clippy::large_stack_arrays))] +mod calendar; mod cross_section; mod derivatives; mod error; @@ -59,17 +60,18 @@ pub use indicators::{ AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock, AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma, Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands, - AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AvgPrice, AwesomeOscillator, - AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BetaNeutralSpread, BollingerBands, - BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex, - CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo, - ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, - ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, - ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput, - ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack, - CumulativeVolumeDelta, CumulativeVolumeIndex, CyberneticCycle, Decycler, DecyclerOscillator, - Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, - Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, + AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice, + AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, + BetaNeutralSpread, BollingerBands, BollingerBandwidth, BollingerOutput, BreadthThrust, + Breakaway, BullishPercentIndex, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, + Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, + ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex, + ClassicPivots, ClassicPivotsOutput, ClosingMarubozu, Cmo, CoefficientOfVariation, + Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, + Coppock, Counterattack, CumulativeVolumeDelta, CumulativeVolumeIndex, CyberneticCycle, + DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex, + DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar, + Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema, EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama, @@ -81,44 +83,48 @@ pub use indicators::{ HistoricalVolatility, Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, - InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, KagiBars, - KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, - Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, - LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, - LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression, - LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio, - MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput, - MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, - McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, - MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, - MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, - OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput, - OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex, - PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa, - PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo, - PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread, - RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, - RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, - RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility, - Rwi, RwiOutput, SarExt, SeparatingLines, SharpeRatio, ShootingStar, ShortLine, SignedVolume, - SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, - SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, - StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, - StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, - SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, - TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, + InstantaneousTrendline, IntradayVolatilityProfile, IntradayVolatilityProfileOutput, + InverseFisherTransform, InvertedHammer, Jma, KagiBars, KalmanHedgeRatio, + KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength, + Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, + LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput, + LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, + LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, + MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex, + MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex, + McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, + MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi, + OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, + OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, + OrderBookImbalanceTopN, OuHalfLife, OvernightGap, OvernightIntradayReturn, + OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, + PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, + PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, + RealizedSpread, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, + RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, + RogersSatchellVolatility, RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter, + Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines, + SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap, + SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma, + SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadBollingerBands, + SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands, + StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop, + StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend, + SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, + TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike, - ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TpoProfile, - TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv, - TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, + ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, + TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput, + TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv, TtmSqueeze, + TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya, - VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex, - VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, - WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma, - WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd, - ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3, + VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, + VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vwap, VwapStdDevBands, + VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals, + WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, + YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3, }; // `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own // line so the indicator-count tooling (which scans the braced block above and diff --git a/docs/README.md b/docs/README.md index f4125871..66f3717b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ That includes: [Python](https://docs.wickra.org/Quickstart-Python), [Node](https://docs.wickra.org/Quickstart-Node), and [WASM](https://docs.wickra.org/Quickstart-WASM). -- A per-indicator deep dive for every one of the **339 indicators** across +- A per-indicator deep dive for every one of the **351 indicators** across the sixteen families (Moving Averages, Momentum Oscillators, Trend & Directional, Price Oscillators, Volatility & Bands, Bands & Channels, Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots & diff --git a/fuzz/fuzz_targets/indicator_update_candle.rs b/fuzz/fuzz_targets/indicator_update_candle.rs index bf5c6a7f..b989743a 100644 --- a/fuzz/fuzz_targets/indicator_update_candle.rs +++ b/fuzz/fuzz_targets/indicator_update_candle.rs @@ -22,9 +22,7 @@ //! WeightedClose. use libfuzzer_sys::fuzz_target; -use wickra_core::{ -AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag -}; +use wickra_core::{AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag}; /// Convert a flat `f64` stream into a `Vec` by chunking it into /// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV @@ -349,4 +347,29 @@ fuzz_target!(|data: Vec| { drive(TwoCrows::new, &candles); drive(UpsideGapTwoCrows::new, &candles); drive(IdenticalThreeCrows::new, &candles); + // --- Seasonality & Session --- + drive(|| VolumeByTimeProfile::new(24, 0).unwrap(), &candles); + + drive(|| IntradayVolatilityProfile::new(24, 0).unwrap(), &candles); + + drive(|| DayOfWeekProfile::new(0), &candles); + + drive(|| TimeOfDayReturnProfile::new(24, 0).unwrap(), &candles); + + drive(|| SeasonalZScore::new(0), &candles); + + drive(|| TurnOfMonth::new(3, 1, 0).unwrap(), &candles); + + drive(|| OvernightIntradayReturn::new(0), &candles); + + drive(|| OvernightGap::new(0), &candles); + + drive(|| AverageDailyRange::new(14, 0).unwrap(), &candles); + + drive(|| SessionRange::new(0), &candles); + + drive(|| SessionHighLow::new(0), &candles); + + drive(|| SessionVwap::new(0), &candles); + });