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:
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user