Files
wickra/bindings/node/__tests__/seasonality.test.js
T
kingchenc 3ab2d6ec2d 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.
2026-06-03 20:31:32 +02:00

97 lines
3.8 KiB
JavaScript

// Streaming-vs-batch equivalence and reference values for the Seasonality &
// Session family. These indicators consume the full candle (open, high, low,
// close, volume, timestamp), so they have a dedicated suite.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
const HOUR = 3_600_000;
const N = 240;
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
const high = close.map((c, i) => Math.max(open[i], c) + 1);
const low = close.map((c, i) => Math.min(open[i], c) - 1);
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
const ts = Array.from({ length: N }, (_, i) => i * HOUR);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
return Math.abs(a - b) < 1e-9;
}
function streamScalar(ind, i) {
const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
return v === null || v === undefined ? NaN : v;
}
function checkScalar(name, make) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
}
});
}
function checkMatrix(name, make, k, pick) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
for (let j = 0; j < k; j += 1) {
const s = out === null || out === undefined ? NaN : pick(out, j);
assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
}
}
});
}
checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
checkMatrix(
'OvernightIntradayReturn',
() => new wickra.OvernightIntradayReturn(0),
2,
(o, j) => (j === 0 ? o.overnight : o.intraday),
);
checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
test('SessionVwap reference value', () => {
const vwap = new wickra.SessionVwap(0);
assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
});
test('OvernightGap reference value', () => {
const gap = new wickra.OvernightGap(0);
assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
});
test('SessionHighLow reference object', () => {
const shl = new wickra.SessionHighLow(0);
shl.update(100, 105, 99, 101, 1, 0);
const out = shl.update(101, 108, 100, 107, 1, HOUR);
assert.ok(eq(out.high, 108));
assert.ok(eq(out.low, 99));
});
test('AverageDailyRange rejects zero period', () => {
assert.throws(() => new wickra.AverageDailyRange(0, 0));
});