feat(seasonality): add the Seasonality & Session family (12 indicators) (#161)

## 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<f64>`) | `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<f64>`; 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.
This commit is contained in:
kingchenc
2026-06-03 20:31:32 +02:00
committed by GitHub
parent 5e96d41916
commit 3ab2d6ec2d
27 changed files with 5026 additions and 55 deletions
+12
View File
@@ -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
+5 -4
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=339" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=351" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![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/
@@ -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));
});
+131
View File
@@ -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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
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<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
+13 -1
View File
@@ -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
+612
View File
@@ -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<Vec<wc::Candle>> {
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<Option<f64>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Self> {
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<Option<Vec<f64>>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Option<Vec<f64>>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Self> {
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<Option<f64>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Self> {
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<Option<f64>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Option<SessionHighLowValue>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Option<SessionRangeValue>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Option<OvernightIntradayReturnValue>> {
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<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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
}
}
+26
View File
@@ -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",
]
+600
View File
@@ -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<Vec<wc::Candle>> {
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<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
let candles =
build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let out: Vec<f64> = 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<Self> {
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<Option<Bound<'py, PyArray1<f64>>>> {
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<Bound<'py, PyArray2<f64>>> {
let candles =
build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Option<Bound<'py, PyArray1<f64>>>> {
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<Bound<'py, PyArray2<f64>>> {
let candles =
build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Self> {
Ok(Self {
inner: wc::AverageDailyRange::new(period, utc_offset_minutes).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let out: Vec<f64> = 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<Self> {
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<Option<f64>> {
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<Bound<'py, PyArray1<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let out: Vec<f64> = 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<Option<(f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Option<(f64, f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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<Option<(f64, f64)>> {
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<Bound<'py, PyArray2<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
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::<PyRocr100>()?;
m.add_class::<PyLinRegIntercept>()?;
m.add_class::<PyTsf>()?;
// Family 16: Seasonality & Session.
m.add_class::<PySessionVwap>()?;
m.add_class::<PySessionHighLow>()?;
m.add_class::<PySessionRange>()?;
m.add_class::<PyAverageDailyRange>()?;
m.add_class::<PyOvernightGap>()?;
m.add_class::<PyOvernightIntradayReturn>()?;
m.add_class::<PyTurnOfMonth>()?;
m.add_class::<PySeasonalZScore>()?;
m.add_class::<PyTimeOfDayReturnProfile>()?;
m.add_class::<PyDayOfWeekProfile>()?;
m.add_class::<PyIntradayVolatilityProfile>()?;
m.add_class::<PyVolumeByTimeProfile>()?;
Ok(())
}
+132
View File
@@ -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)
+387
View File
@@ -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<Option<f64>, 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<JsValue, JsError> {
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<JsValue, JsError> {
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<WasmAverageDailyRange, JsError> {
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<Option<f64>, 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<WasmTurnOfMonth, JsError> {
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<Option<f64>, 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<JsValue, JsError> {
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<JsValue, JsError> {
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<JsValue, JsError> {
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()
}
}
+203
View File
@@ -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);
}
}
@@ -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<f64>,
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<Self> {
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<f64> {
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<f64> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
}
/// 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<f64>,
sum: [f64; DAYS],
count: [u64; DAYS],
last: Option<DayOfWeekProfileOutput>,
}
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<DayOfWeekProfileOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
}
/// 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<f64>,
count: Vec<u64>,
mean: Vec<f64>,
m2: Vec<f64>,
last: Option<IntradayVolatilityProfileOutput>,
}
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<Self> {
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<IntradayVolatilityProfileOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
}
+42 -1
View File
@@ -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");
}
}
@@ -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<f64>,
gap: Option<f64>,
}
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<f64> {
self.gap
}
}
impl Indicator for OvernightGap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
today_open: f64,
overnight: Option<f64>,
last: Option<OvernightIntradayReturnOutput>,
}
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<OvernightIntradayReturnOutput> {
self.last
}
}
impl Indicator for OvernightIntradayReturn {
type Input = Candle;
type Output = OvernightIntradayReturnOutput;
fn update(&mut self, candle: Candle) -> Option<OvernightIntradayReturnOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
count: [u64; HOURS],
mean: [f64; HOURS],
m2: [f64; HOURS],
last: Option<f64>,
}
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<f64> {
self.last
}
fn z_for(&self, hour: usize, ret: f64) -> Option<f64> {
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<f64> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<SessionHighLowOutput>,
}
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<SessionHighLowOutput> {
self.last
}
}
impl Indicator for SessionHighLow {
type Input = Candle;
type Output = SessionHighLowOutput;
fn update(&mut self, candle: Candle) -> Option<SessionHighLowOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<SessionRangeOutput>,
}
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<SessionRangeOutput> {
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<SessionRangeOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
}
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<f64> {
self.last
}
}
impl Indicator for SessionVwap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
}
/// 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<f64>,
sum: Vec<f64>,
count: Vec<u64>,
last: Option<TimeOfDayReturnProfileOutput>,
}
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<Self> {
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<TimeOfDayReturnProfileOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
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<Self> {
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<f64> {
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<f64> {
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<Candle> = (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::<Vec<_>>()
);
}
}
@@ -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<f64>,
}
/// 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<f64>,
count: Vec<u64>,
last: Option<VolumeByTimeProfileOutput>,
}
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<Self> {
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<VolumeByTimeProfileOutput> {
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<Candle> = (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::<Vec<_>>()
);
}
}
+51 -45
View File
@@ -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
+1 -1
View File
@@ -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 &
+26 -3
View File
@@ -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<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -349,4 +347,29 @@ fuzz_target!(|data: Vec<f64>| {
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);
});