Compare commits

..

3 Commits

Author SHA1 Message Date
kingchenc 4d602df8a3 release: bump 0.5.0 -> 0.5.1 (#162)
Version bump **0.5.0 → 0.5.1** for the Seasonality & Session family release (12 indicators, PR #161).

Bumped: `Cargo.toml` (workspace version + `wickra-core` dep), `Cargo.lock` (via `cargo build`), `bindings/python/pyproject.toml`, `bindings/node/package.json` (+ 6 `optionalDependencies`), the 6 `bindings/node/npm/<platform>/package.json`, both `package-lock.json` files, and `CHANGELOG.md` (`[Unreleased]` → `[0.5.1]` + compare URLs).

No code changes — version strings only.
2026-06-03 20:55:13 +02:00
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
kingchenc 5e96d41916 chore: add REUSE-style LICENSES directory for license auto-detection (#160)
Adds a `LICENSES/` directory with SPDX-named copies of the existing license texts (`MIT.txt`, `Apache-2.0.txt`) per the [REUSE Specification](https://reuse.software/spec/).

## Why
Automated license scanners (the OpenSSF Best Practices BadgeApp, GitHub's license API, REUSE tooling) look for a top-level `LICENSE`/`COPYING` file or a `LICENSES/` directory with SPDX-named files. Our files are named `LICENSE-MIT` / `LICENSE-APACHE` (Rust convention), which these scanners do not recognize — so the BadgeApp's `license_location` check keeps auto-flipping to "Unmet".

## What
- New `LICENSES/MIT.txt` — byte-identical copy of `LICENSE-MIT`
- New `LICENSES/Apache-2.0.txt` — byte-identical copy of `LICENSE-APACHE`
- Existing `LICENSE-MIT` and `LICENSE-APACHE` are **unchanged**

The project remains dual-licensed under **MIT OR Apache-2.0**. This change is additive only.
2026-06-03 20:00:40 +02:00
41 changed files with 5304 additions and 105 deletions
+19 -1
View File
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.5.1] - 2026-06-03
### Added — Seasonality & Session family (12 indicators)
- **Volume-by-Time Profile** — mean traded volume bucketed by intraday time (`VOLUME_BY_TIME_PROFILE`).
- **Intraday Volatility Profile** — return standard deviation bucketed by intraday time (`INTRADAY_VOLATILITY_PROFILE`).
- **Day-of-Week Profile** — mean bar return bucketed by weekday (`DAY_OF_WEEK_PROFILE`).
- **Time-of-Day Return Profile** — mean bar return bucketed by intraday time (`TIME_OF_DAY_RETURN_PROFILE`).
- **Seasonal Z-Score** — z-score of the current return versus the same hour-of-day history (`SEASONAL_Z_SCORE`).
- **Turn-of-Month** — mean daily return inside the turn-of-month window (`TURN_OF_MONTH`).
- **Overnight/Intraday Return** — decomposition of session return into overnight and intraday legs (`OVERNIGHT_INTRADAY_RETURN`).
- **Overnight Gap** — close-to-open return across the session boundary (`OVERNIGHT_GAP`).
- **Average Daily Range** — mean high-low range of the last N completed sessions (`AVERAGE_DAILY_RANGE`).
- **Session Range** — per-session (Asia/EU/US) high-low range (`SESSION_RANGE`).
- **Session High/Low** — running high and low of the current session (`SESSION_HIGH_LOW`).
- **Session VWAP** — session-anchored volume-weighted average price (`SESSION_VWAP`).
## [0.5.0] - 2026-06-03
### Added
@@ -1168,7 +1185,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
optional Binance live feed.
- Bindings for Python, Node.js, and WebAssembly.
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.0...HEAD
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.5.1...HEAD
[0.5.1]: https://github.com/wickra-lib/wickra/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/wickra-lib/wickra/compare/v0.4.7...v0.5.0
[0.4.7]: https://github.com/wickra-lib/wickra/compare/v0.4.6...v0.4.7
[0.4.6]: https://github.com/wickra-lib/wickra/compare/v0.4.5...v0.4.6
Generated
+6 -6
View File
@@ -1867,7 +1867,7 @@ dependencies = [
[[package]]
name = "wickra"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"approx",
"criterion",
@@ -1878,7 +1878,7 @@ dependencies = [
[[package]]
name = "wickra-core"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"approx",
"proptest",
@@ -1888,7 +1888,7 @@ dependencies = [
[[package]]
name = "wickra-data"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"approx",
"csv",
@@ -1915,7 +1915,7 @@ dependencies = [
[[package]]
name = "wickra-node"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"napi",
"napi-build",
@@ -1925,7 +1925,7 @@ dependencies = [
[[package]]
name = "wickra-python"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"numpy",
"pyo3",
@@ -1934,7 +1934,7 @@ dependencies = [
[[package]]
name = "wickra-wasm"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"console_error_panic_hook",
"js-sys",
+2 -2
View File
@@ -12,7 +12,7 @@ members = [
exclude = ["fuzz"]
[workspace.package]
version = "0.5.0"
version = "0.5.1"
authors = ["kingchenc <support@wickra.org>"]
edition = "2021"
rust-version = "1.86"
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
categories = ["finance", "mathematics", "science"]
[workspace.dependencies]
wickra-core = { path = "crates/wickra-core", version = "0.5.0" }
wickra-core = { path = "crates/wickra-core", version = "0.5.1" }
thiserror = "2"
rayon = "1.10"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or Derivative
Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 kingchenc and the Wickra contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 kingchenc and the Wickra contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -4
View File
@@ -1,5 +1,5 @@
<p align="center">
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=339" alt="Wickra — streaming-first technical indicators" width="100%"></a>
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=351" alt="Wickra — streaming-first technical indicators" width="100%"></a>
</p>
[![CI](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml/badge.svg)](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
[Node](https://docs.wickra.org/Quickstart-Node),
[WASM](https://docs.wickra.org/Quickstart-WASM).
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
every one of the 339 indicators; start at the
every one of the 351 indicators; start at the
[indicators overview](https://docs.wickra.org/Indicators-Overview).
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
## Indicators
339 streaming-first indicators across twenty families. Every one passes the
351 streaming-first indicators across twenty-one families. Every one passes the
`batch == streaming` equivalence test, reference-value tests, and reset
semantics tests. Each has a per-indicator deep dive (formula, parameters,
warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
@@ -162,6 +162,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
| Market Breadth | Advance/Decline Line, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
`1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
@@ -240,7 +241,7 @@ A Python live-trading example using the public `websockets` package lives at
```
wickra/
├── crates/
│ ├── wickra-core/ core engine + all 339 indicators
│ ├── wickra-core/ core engine + all 351 indicators
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
├── bindings/
@@ -0,0 +1,96 @@
// Streaming-vs-batch equivalence and reference values for the Seasonality &
// Session family. These indicators consume the full candle (open, high, low,
// close, volume, timestamp), so they have a dedicated suite.
const test = require('node:test');
const assert = require('node:assert/strict');
const wickra = require('..');
const HOUR = 3_600_000;
const N = 240;
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
const high = close.map((c, i) => Math.max(open[i], c) + 1);
const low = close.map((c, i) => Math.min(open[i], c) - 1);
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
const ts = Array.from({ length: N }, (_, i) => i * HOUR);
function eq(a, b) {
if (Number.isNaN(a)) return Number.isNaN(b);
return Math.abs(a - b) < 1e-9;
}
function streamScalar(ind, i) {
const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
return v === null || v === undefined ? NaN : v;
}
function checkScalar(name, make) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
}
});
}
function checkMatrix(name, make, k, pick) {
test(`${name} streaming equals batch`, () => {
const a = make();
const b = make();
const batch = b.batch(open, high, low, close, volume, ts);
for (let i = 0; i < N; i += 1) {
const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
for (let j = 0; j < k; j += 1) {
const s = out === null || out === undefined ? NaN : pick(out, j);
assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
}
}
});
}
checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
checkMatrix(
'OvernightIntradayReturn',
() => new wickra.OvernightIntradayReturn(0),
2,
(o, j) => (j === 0 ? o.overnight : o.intraday),
);
checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
test('SessionVwap reference value', () => {
const vwap = new wickra.SessionVwap(0);
assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
});
test('OvernightGap reference value', () => {
const gap = new wickra.OvernightGap(0);
assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
});
test('SessionHighLow reference object', () => {
const shl = new wickra.SessionHighLow(0);
shl.update(100, 105, 99, 101, 1, 0);
const out = shl.update(101, 108, 100, 107, 1, HOUR);
assert.ok(eq(out.high, 108));
assert.ok(eq(out.low, 99));
});
test('AverageDailyRange rejects zero period', () => {
assert.throws(() => new wickra.AverageDailyRange(0, 0));
});
+131
View File
@@ -349,6 +349,19 @@ export interface PnfColumnValue {
high: number
low: number
}
export interface SessionHighLowValue {
high: number
low: number
}
export interface SessionRangeValue {
asia: number
eu: number
us: number
}
export interface OvernightIntradayReturnValue {
overnight: number
intraday: number
}
export type SmaNode = SMA
export declare class SMA {
constructor(period: number)
@@ -3557,3 +3570,121 @@ export declare class Alpha {
isReady(): boolean
warmupPeriod(): number
}
export type SessionVwapNode = SessionVwap
export declare class SessionVwap {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type OvernightGapNode = OvernightGap
export declare class OvernightGap {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type SeasonalZScoreNode = SeasonalZScore
export declare class SeasonalZScore {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type TimeOfDayReturnProfileNode = TimeOfDayReturnProfile
export declare class TimeOfDayReturnProfile {
constructor(buckets: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
buckets(): number
utcOffsetMinutes(): number
}
export type IntradayVolatilityProfileNode = IntradayVolatilityProfile
export declare class IntradayVolatilityProfile {
constructor(buckets: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
buckets(): number
utcOffsetMinutes(): number
}
export type VolumeByTimeProfileNode = VolumeByTimeProfile
export declare class VolumeByTimeProfile {
constructor(buckets: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
buckets(): number
utcOffsetMinutes(): number
}
export type DayOfWeekProfileNode = DayOfWeekProfile
export declare class DayOfWeekProfile {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
utcOffsetMinutes(): number
}
export type AverageDailyRangeNode = AverageDailyRange
export declare class AverageDailyRange {
constructor(period: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type TurnOfMonthNode = TurnOfMonth
export declare class TurnOfMonth {
constructor(nFirst: number, nLast: number, utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SessionHighLowNode = SessionHighLow
export declare class SessionHighLow {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionHighLowValue | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type SessionRangeNode = SessionRange
export declare class SessionRange {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionRangeValue | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
export type OvernightIntradayReturnNode = OvernightIntradayReturn
export declare class OvernightIntradayReturn {
constructor(utcOffsetMinutes: number)
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): OvernightIntradayReturnValue | null
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
reset(): void
isReady(): boolean
warmupPeriod(): number
}
+13 -1
View File
@@ -310,7 +310,7 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, MIDPOINT, ROCP, ROCR, ROCR100, LINEARREG_INTERCEPT, TSF, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, RollingCorrelation, RollingCovariance, OuHalfLife, SpreadHurst, DistanceSsd, BetaNeutralSpread, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, VarianceRatio, GrangerCausality, KalmanHedgeRatio, SpreadBollingerBands, MACD, MACDFIX, MACDEXT, BollingerBands, ATR, PLUS_DM, MINUS_DM, PLUS_DI, MINUS_DI, DX, MIDPRICE, AVGPRICE, SAREXT, HT_PHASOR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredRSI, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HT_DCPHASE, HT_TRENDMODE, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, VolumeProfile, TpoProfile, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, TwoCrows, UpsideGapTwoCrows, IdenticalThreeCrows, ThreeLineStrike, ThreeStarsInSouth, AbandonedBaby, AdvanceBlock, BeltHold, Breakaway, Counterattack, DojiStar, DragonflyDoji, GravestoneDoji, LongLeggedDoji, RickshawMan, EveningDojiStar, MorningDojiStar, GapSideBySideWhite, HighWave, Hikkake, HikkakeModified, HomingPigeon, OnNeck, InNeck, Thrusting, SeparatingLines, Kicking, KickingByLength, LadderBottom, MatHold, MatchingLow, LongLine, ShortLine, RisingThreeMethods, FallingThreeMethods, UpsideGapThreeMethods, DownsideGapThreeMethods, StalledPattern, StickSandwich, Takuri, ClosingMarubozu, OpeningMarubozu, TasukiGap, UniqueThreeRiver, ConcealingBabySwallow, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, FundingRate, FundingRateMean, FundingRateZScore, FundingBasis, OpenInterestDelta, OIPriceDivergence, OIWeighted, LongShortRatio, TakerBuySellRatio, LiquidationFeatures, TermStructureBasis, CalendarSpread, AdvanceDecline, AdvanceDeclineRatio, AdVolumeLine, McClellanOscillator, McClellanSummationIndex, Trin, BreadthThrust, NewHighsNewLows, HighLowIndex, PercentAboveMa, UpDownVolumeRatio, BullishPercentIndex, CumulativeVolumeIndex, AbsoluteBreadthIndex, TickIndex, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha, SessionVwap, OvernightGap, SeasonalZScore, TimeOfDayReturnProfile, IntradayVolatilityProfile, VolumeByTimeProfile, DayOfWeekProfile, AverageDailyRange, TurnOfMonth, SessionHighLow, SessionRange, OvernightIntradayReturn } = nativeBinding
module.exports.version = version
module.exports.SMA = SMA
@@ -652,3 +652,15 @@ module.exports.RenkoBars = RenkoBars
module.exports.KagiBars = KagiBars
module.exports.PointAndFigureBars = PointAndFigureBars
module.exports.Alpha = Alpha
module.exports.SessionVwap = SessionVwap
module.exports.OvernightGap = OvernightGap
module.exports.SeasonalZScore = SeasonalZScore
module.exports.TimeOfDayReturnProfile = TimeOfDayReturnProfile
module.exports.IntradayVolatilityProfile = IntradayVolatilityProfile
module.exports.VolumeByTimeProfile = VolumeByTimeProfile
module.exports.DayOfWeekProfile = DayOfWeekProfile
module.exports.AverageDailyRange = AverageDailyRange
module.exports.TurnOfMonth = TurnOfMonth
module.exports.SessionHighLow = SessionHighLow
module.exports.SessionRange = SessionRange
module.exports.OvernightIntradayReturn = OvernightIntradayReturn
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-arm64",
"version": "0.5.0",
"version": "0.5.1",
"description": "Native binding for wickra (macOS Apple Silicon). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-arm64.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-darwin-x64",
"version": "0.5.0",
"version": "0.5.1",
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.darwin-x64.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-arm64-gnu",
"version": "0.5.0",
"version": "0.5.1",
"description": "Native binding for wickra (linux arm64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-arm64-gnu.node",
"files": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wickra-linux-x64-gnu",
"version": "0.5.0",
"version": "0.5.1",
"description": "Native binding for wickra (linux x64 GNU). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.linux-x64-gnu.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-arm64-msvc",
"version": "0.5.0",
"version": "0.5.1",
"description": "Native binding for wickra (Windows arm64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-arm64-msvc.node",
"files": [
@@ -1,6 +1,6 @@
{
"name": "wickra-win32-x64-msvc",
"version": "0.5.0",
"version": "0.5.1",
"description": "Native binding for wickra (Windows x64 MSVC). Installed automatically as an optional dependency of wickra on matching platforms.",
"main": "wickra.win32-x64-msvc.node",
"files": [
+20 -20
View File
@@ -1,12 +1,12 @@
{
"name": "wickra",
"version": "0.5.0",
"version": "0.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wickra",
"version": "0.5.0",
"version": "0.5.1",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -15,12 +15,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
"wickra-darwin-arm64": "0.5.1",
"wickra-darwin-x64": "0.5.1",
"wickra-linux-arm64-gnu": "0.5.1",
"wickra-linux-x64-gnu": "0.5.1",
"wickra-win32-arm64-msvc": "0.5.1",
"wickra-win32-x64-msvc": "0.5.1"
}
},
"node_modules/@napi-rs/cli": {
@@ -41,8 +41,8 @@
}
},
"node_modules/wickra-darwin-arm64": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.0.tgz",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.1.tgz",
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
"cpu": [
"arm64"
@@ -57,8 +57,8 @@
}
},
"node_modules/wickra-darwin-x64": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.0.tgz",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.1.tgz",
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
"cpu": [
"x64"
@@ -73,8 +73,8 @@
}
},
"node_modules/wickra-linux-arm64-gnu": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.0.tgz",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.1.tgz",
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
"cpu": [
"arm64"
@@ -89,8 +89,8 @@
}
},
"node_modules/wickra-linux-x64-gnu": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.0.tgz",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.1.tgz",
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
"cpu": [
"x64"
@@ -105,8 +105,8 @@
}
},
"node_modules/wickra-win32-arm64-msvc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.0.tgz",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.1.tgz",
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
"cpu": [
"arm64"
@@ -121,8 +121,8 @@
}
},
"node_modules/wickra-win32-x64-msvc": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.0.tgz",
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.1.tgz",
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
"cpu": [
"x64"
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "wickra",
"version": "0.5.0",
"version": "0.5.1",
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
"author": "kingchenc <support@wickra.org>",
"main": "index.js",
@@ -47,12 +47,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-linux-x64-gnu": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-darwin-arm64": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0"
"wickra-linux-x64-gnu": "0.5.1",
"wickra-linux-arm64-gnu": "0.5.1",
"wickra-darwin-x64": "0.5.1",
"wickra-darwin-arm64": "0.5.1",
"wickra-win32-x64-msvc": "0.5.1",
"wickra-win32-arm64-msvc": "0.5.1"
},
"scripts": {
"build": "napi build --platform --release",
+612
View File
@@ -13398,3 +13398,615 @@ impl AlphaNode {
self.inner.warmup_period() as u32
}
}
// ====================== Seasonality & Session (full-candle) ======================
//
// These read the wall-clock fields of `Candle::timestamp`, so the bindings take
// the full candle (open, high, low, close, volume, timestamp) rather than the
// high/low/close slice used by the candle indicators above.
fn season_candles(
open: &[f64],
high: &[f64],
low: &[f64],
close: &[f64],
volume: &[f64],
timestamp: &[i64],
) -> napi::Result<Vec<wc::Candle>> {
let n = open.len();
if [
high.len(),
low.len(),
close.len(),
volume.len(),
timestamp.len(),
]
.iter()
.any(|&x| x != n)
{
return Err(NapiError::from_reason(
"open, high, low, close, volume, timestamp must be equal length".to_string(),
));
}
let mut out = Vec::with_capacity(n);
for i in 0..n {
out.push(
wc::Candle::new(open[i], high[i], low[i], close[i], volume[i], timestamp[i])
.map_err(map_err)?,
);
}
Ok(out)
}
macro_rules! node_seasonality_offset_scalar {
($wrapper:ident, $node_name:literal, $rust_ty:ty) => {
#[napi(js_name = $node_name)]
pub struct $wrapper {
inner: $rust_ty,
}
#[napi]
impl $wrapper {
#[napi(constructor)]
pub fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: <$rust_ty>::new(utc_offset_minutes),
}
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<f64>> {
Ok(self.inner.update(
wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?,
))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
Ok(candles
.into_iter()
.map(|c| self.inner.update(c).unwrap_or(f64::NAN))
.collect())
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi(js_name = "utcOffsetMinutes")]
pub fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
}
};
}
macro_rules! node_seasonality_bucket_profile {
($wrapper:ident, $node_name:literal, $rust_ty:ty) => {
#[napi(js_name = $node_name)]
pub struct $wrapper {
inner: $rust_ty,
}
#[napi]
impl $wrapper {
#[napi(constructor)]
pub fn new(buckets: u32, utc_offset_minutes: i32) -> napi::Result<Self> {
Ok(Self {
inner: <$rust_ty>::new(buckets as usize, utc_offset_minutes)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<Vec<f64>>> {
Ok(self
.inner
.update(
wc::Candle::new(open, high, low, close, volume, timestamp)
.map_err(map_err)?,
)
.map(|o| o.bins))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let k = self.inner.params().0;
let n = candles.len();
let mut out = vec![f64::NAN; n * k];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
for (j, b) in o.bins.iter().enumerate() {
out[i * k + j] = *b;
}
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi(js_name = "buckets")]
pub fn buckets(&self) -> u32 {
self.inner.params().0 as u32
}
#[napi(js_name = "utcOffsetMinutes")]
pub fn utc_offset_minutes(&self) -> i32 {
self.inner.params().1
}
}
};
}
macro_rules! node_seasonality_offset_profile {
($wrapper:ident, $node_name:literal, $rust_ty:ty, $k:expr) => {
#[napi(js_name = $node_name)]
pub struct $wrapper {
inner: $rust_ty,
}
#[napi]
impl $wrapper {
#[napi(constructor)]
pub fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: <$rust_ty>::new(utc_offset_minutes),
}
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<Vec<f64>>> {
Ok(self
.inner
.update(
wc::Candle::new(open, high, low, close, volume, timestamp)
.map_err(map_err)?,
)
.map(|o| o.bins))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let k = $k;
let n = candles.len();
let mut out = vec![f64::NAN; n * k];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
for (j, b) in o.bins.iter().enumerate() {
out[i * k + j] = *b;
}
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
#[napi(js_name = "utcOffsetMinutes")]
pub fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
}
};
}
node_seasonality_offset_scalar!(SessionVwapNode, "SessionVwap", wc::SessionVwap);
node_seasonality_offset_scalar!(OvernightGapNode, "OvernightGap", wc::OvernightGap);
node_seasonality_offset_scalar!(SeasonalZScoreNode, "SeasonalZScore", wc::SeasonalZScore);
node_seasonality_bucket_profile!(
TimeOfDayReturnProfileNode,
"TimeOfDayReturnProfile",
wc::TimeOfDayReturnProfile
);
node_seasonality_bucket_profile!(
IntradayVolatilityProfileNode,
"IntradayVolatilityProfile",
wc::IntradayVolatilityProfile
);
node_seasonality_bucket_profile!(
VolumeByTimeProfileNode,
"VolumeByTimeProfile",
wc::VolumeByTimeProfile
);
node_seasonality_offset_profile!(
DayOfWeekProfileNode,
"DayOfWeekProfile",
wc::DayOfWeekProfile,
7
);
#[napi(js_name = "AverageDailyRange")]
pub struct AverageDailyRangeNode {
inner: wc::AverageDailyRange,
}
#[napi]
impl AverageDailyRangeNode {
#[napi(constructor)]
pub fn new(period: u32, utc_offset_minutes: i32) -> napi::Result<Self> {
Ok(Self {
inner: wc::AverageDailyRange::new(period as usize, utc_offset_minutes)
.map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<f64>> {
Ok(self
.inner
.update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
Ok(candles
.into_iter()
.map(|c| self.inner.update(c).unwrap_or(f64::NAN))
.collect())
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(js_name = "TurnOfMonth")]
pub struct TurnOfMonthNode {
inner: wc::TurnOfMonth,
}
#[napi]
impl TurnOfMonthNode {
#[napi(constructor)]
pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> napi::Result<Self> {
Ok(Self {
inner: wc::TurnOfMonth::new(n_first, n_last, utc_offset_minutes).map_err(map_err)?,
})
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<f64>> {
Ok(self
.inner
.update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
Ok(candles
.into_iter()
.map(|c| self.inner.update(c).unwrap_or(f64::NAN))
.collect())
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct SessionHighLowValue {
pub high: f64,
pub low: f64,
}
#[napi(js_name = "SessionHighLow")]
pub struct SessionHighLowNode {
inner: wc::SessionHighLow,
}
#[napi]
impl SessionHighLowNode {
#[napi(constructor)]
pub fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::SessionHighLow::new(utc_offset_minutes),
}
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<SessionHighLowValue>> {
Ok(self
.inner
.update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?)
.map(|o| SessionHighLowValue {
high: o.high,
low: o.low,
}))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let n = candles.len();
let mut out = vec![f64::NAN; n * 2];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.high;
out[i * 2 + 1] = o.low;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct SessionRangeValue {
pub asia: f64,
pub eu: f64,
pub us: f64,
}
#[napi(js_name = "SessionRange")]
pub struct SessionRangeNode {
inner: wc::SessionRange,
}
#[napi]
impl SessionRangeNode {
#[napi(constructor)]
pub fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::SessionRange::new(utc_offset_minutes),
}
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<SessionRangeValue>> {
Ok(self
.inner
.update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?)
.map(|o| SessionRangeValue {
asia: o.asia,
eu: o.eu,
us: o.us,
}))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let n = candles.len();
let mut out = vec![f64::NAN; n * 3];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
out[i * 3] = o.asia;
out[i * 3 + 1] = o.eu;
out[i * 3 + 2] = o.us;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
#[napi(object)]
pub struct OvernightIntradayReturnValue {
pub overnight: f64,
pub intraday: f64,
}
#[napi(js_name = "OvernightIntradayReturn")]
pub struct OvernightIntradayReturnNode {
inner: wc::OvernightIntradayReturn,
}
#[napi]
impl OvernightIntradayReturnNode {
#[napi(constructor)]
pub fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::OvernightIntradayReturn::new(utc_offset_minutes),
}
}
#[napi]
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> napi::Result<Option<OvernightIntradayReturnValue>> {
Ok(self
.inner
.update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?)
.map(|o| OvernightIntradayReturnValue {
overnight: o.overnight,
intraday: o.intraday,
}))
}
#[napi]
pub fn batch(
&mut self,
open: Vec<f64>,
high: Vec<f64>,
low: Vec<f64>,
close: Vec<f64>,
volume: Vec<f64>,
timestamp: Vec<i64>,
) -> napi::Result<Vec<f64>> {
let candles = season_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let n = candles.len();
let mut out = vec![f64::NAN; n * 2];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.overnight;
out[i * 2 + 1] = o.intraday;
}
}
Ok(out)
}
#[napi]
pub fn reset(&mut self) {
self.inner.reset();
}
#[napi(js_name = "isReady")]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[napi(js_name = "warmupPeriod")]
pub fn warmup_period(&self) -> u32 {
self.inner.warmup_period() as u32
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project]
name = "wickra"
version = "0.5.0"
version = "0.5.1"
description = "Streaming-first technical indicators: incremental, fast, install-free."
readme = "README.md"
license = "MIT OR Apache-2.0"
+26
View File
@@ -385,6 +385,19 @@ from ._wickra import (
TreynorRatio,
InformationRatio,
Alpha,
# Seasonality & Session
SessionVwap,
SessionHighLow,
SessionRange,
AverageDailyRange,
OvernightGap,
OvernightIntradayReturn,
TurnOfMonth,
SeasonalZScore,
TimeOfDayReturnProfile,
DayOfWeekProfile,
IntradayVolatilityProfile,
VolumeByTimeProfile,
)
__all__ = [
@@ -749,4 +762,17 @@ __all__ = [
"TreynorRatio",
"InformationRatio",
"Alpha",
# Seasonality & Session
"SessionVwap",
"SessionHighLow",
"SessionRange",
"AverageDailyRange",
"OvernightGap",
"OvernightIntradayReturn",
"TurnOfMonth",
"SeasonalZScore",
"TimeOfDayReturnProfile",
"DayOfWeekProfile",
"IntradayVolatilityProfile",
"VolumeByTimeProfile",
]
+600
View File
@@ -17243,6 +17243,593 @@ impl PyPointAndFigureBars {
// ============================== Module ==============================
// ====================== Seasonality & Session (full-candle) ======================
//
// These indicators read the wall-clock fields of `Candle::timestamp`, so the
// bindings consume the FULL candle (open, high, low, close, volume, timestamp)
// — unlike the high/low/close candle indicators above.
fn build_seasonality_candles<'py>(
open: &PyReadonlyArray1<'py, f64>,
high: &PyReadonlyArray1<'py, f64>,
low: &PyReadonlyArray1<'py, f64>,
close: &PyReadonlyArray1<'py, f64>,
volume: &PyReadonlyArray1<'py, f64>,
timestamp: &PyReadonlyArray1<'py, i64>,
) -> PyResult<Vec<wc::Candle>> {
let o = open
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let h = high
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let l = low
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let c = close
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let v = volume
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let t = timestamp
.as_slice()
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
let n = o.len();
if [h.len(), l.len(), c.len(), v.len(), t.len()]
.iter()
.any(|&x| x != n)
{
return Err(PyValueError::new_err(
"open, high, low, close, volume, timestamp must be equal length",
));
}
let mut candles = Vec::with_capacity(n);
for i in 0..n {
candles.push(wc::Candle::new(o[i], h[i], l[i], c[i], v[i], t[i]).map_err(map_err)?);
}
Ok(candles)
}
macro_rules! py_seasonality_offset_scalar {
($pytype:ident, $name:literal, $rust:ident) => {
#[pyclass(name = $name, module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct $pytype {
inner: wc::$rust,
}
#[pymethods]
impl $pytype {
#[new]
#[pyo3(signature = (utc_offset_minutes = 0))]
fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::$rust::new(utc_offset_minutes),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
Ok(self.inner.update(extract_candle(candle)?))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let candles =
build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let out: Vec<f64> = candles
.into_iter()
.map(|c| self.inner.update(c).unwrap_or(f64::NAN))
.collect();
Ok(out.into_pyarray(py))
}
#[getter]
fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"{}(utc_offset_minutes={})",
$name,
self.inner.utc_offset_minutes()
)
}
}
};
}
macro_rules! py_seasonality_bucket_profile {
($pytype:ident, $name:literal, $rust:ident) => {
#[pyclass(name = $name, module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct $pytype {
inner: wc::$rust,
}
#[pymethods]
impl $pytype {
#[new]
#[pyo3(signature = (buckets = 24, utc_offset_minutes = 0))]
fn new(buckets: usize, utc_offset_minutes: i32) -> PyResult<Self> {
Ok(Self {
inner: wc::$rust::new(buckets, utc_offset_minutes).map_err(map_err)?,
})
}
fn update<'py>(
&mut self,
py: Python<'py>,
candle: &Bound<'_, PyAny>,
) -> PyResult<Option<Bound<'py, PyArray1<f64>>>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| o.bins.into_pyarray(py)))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let candles =
build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let k = self.inner.params().0;
let n = candles.len();
let mut out = vec![f64::NAN; n * k];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
for (j, b) in o.bins.iter().enumerate() {
out[i * k + j] = *b;
}
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, i32) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (buckets, offset) = self.inner.params();
format!("{}(buckets={buckets}, utc_offset_minutes={offset})", $name)
}
}
};
}
macro_rules! py_seasonality_offset_profile {
($pytype:ident, $name:literal, $rust:ident, $k:expr) => {
#[pyclass(name = $name, module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct $pytype {
inner: wc::$rust,
}
#[pymethods]
impl $pytype {
#[new]
#[pyo3(signature = (utc_offset_minutes = 0))]
fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::$rust::new(utc_offset_minutes),
}
}
fn update<'py>(
&mut self,
py: Python<'py>,
candle: &Bound<'_, PyAny>,
) -> PyResult<Option<Bound<'py, PyArray1<f64>>>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| o.bins.into_pyarray(py)))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let candles =
build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let k = $k;
let n = candles.len();
let mut out = vec![f64::NAN; n * k];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
for (j, b) in o.bins.iter().enumerate() {
out[i * k + j] = *b;
}
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, k), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"{}(utc_offset_minutes={})",
$name,
self.inner.utc_offset_minutes()
)
}
}
};
}
py_seasonality_offset_scalar!(PySessionVwap, "SessionVwap", SessionVwap);
py_seasonality_offset_scalar!(PyOvernightGap, "OvernightGap", OvernightGap);
py_seasonality_offset_scalar!(PySeasonalZScore, "SeasonalZScore", SeasonalZScore);
py_seasonality_bucket_profile!(
PyTimeOfDayReturnProfile,
"TimeOfDayReturnProfile",
TimeOfDayReturnProfile
);
py_seasonality_bucket_profile!(
PyIntradayVolatilityProfile,
"IntradayVolatilityProfile",
IntradayVolatilityProfile
);
py_seasonality_bucket_profile!(
PyVolumeByTimeProfile,
"VolumeByTimeProfile",
VolumeByTimeProfile
);
py_seasonality_offset_profile!(PyDayOfWeekProfile, "DayOfWeekProfile", DayOfWeekProfile, 7);
#[pyclass(
name = "AverageDailyRange",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyAverageDailyRange {
inner: wc::AverageDailyRange,
}
#[pymethods]
impl PyAverageDailyRange {
#[new]
#[pyo3(signature = (period = 14, utc_offset_minutes = 0))]
fn new(period: usize, utc_offset_minutes: i32) -> PyResult<Self> {
Ok(Self {
inner: wc::AverageDailyRange::new(period, utc_offset_minutes).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
Ok(self.inner.update(extract_candle(candle)?))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let out: Vec<f64> = candles
.into_iter()
.map(|c| self.inner.update(c).unwrap_or(f64::NAN))
.collect();
Ok(out.into_pyarray(py))
}
#[getter]
fn params(&self) -> (usize, i32) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (period, offset) = self.inner.params();
format!("AverageDailyRange(period={period}, utc_offset_minutes={offset})")
}
}
#[pyclass(name = "TurnOfMonth", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PyTurnOfMonth {
inner: wc::TurnOfMonth,
}
#[pymethods]
impl PyTurnOfMonth {
#[new]
#[pyo3(signature = (n_first = 3, n_last = 1, utc_offset_minutes = 0))]
fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> PyResult<Self> {
Ok(Self {
inner: wc::TurnOfMonth::new(n_first, n_last, utc_offset_minutes).map_err(map_err)?,
})
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<f64>> {
Ok(self.inner.update(extract_candle(candle)?))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray1<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let out: Vec<f64> = candles
.into_iter()
.map(|c| self.inner.update(c).unwrap_or(f64::NAN))
.collect();
Ok(out.into_pyarray(py))
}
#[getter]
fn params(&self) -> (u32, u32, i32) {
self.inner.params()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
let (n_first, n_last, offset) = self.inner.params();
format!("TurnOfMonth(n_first={n_first}, n_last={n_last}, utc_offset_minutes={offset})")
}
}
#[pyclass(
name = "SessionHighLow",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PySessionHighLow {
inner: wc::SessionHighLow,
}
#[pymethods]
impl PySessionHighLow {
#[new]
#[pyo3(signature = (utc_offset_minutes = 0))]
fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::SessionHighLow::new(utc_offset_minutes),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.high, o.low)))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let n = candles.len();
let mut out = vec![f64::NAN; n * 2];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.high;
out[i * 2 + 1] = o.low;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"SessionHighLow(utc_offset_minutes={})",
self.inner.utc_offset_minutes()
)
}
}
#[pyclass(name = "SessionRange", module = "wickra._wickra", skip_from_py_object)]
#[derive(Clone)]
struct PySessionRange {
inner: wc::SessionRange,
}
#[pymethods]
impl PySessionRange {
#[new]
#[pyo3(signature = (utc_offset_minutes = 0))]
fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::SessionRange::new(utc_offset_minutes),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.asia, o.eu, o.us)))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let n = candles.len();
let mut out = vec![f64::NAN; n * 3];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
out[i * 3] = o.asia;
out[i * 3 + 1] = o.eu;
out[i * 3 + 2] = o.us;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"SessionRange(utc_offset_minutes={})",
self.inner.utc_offset_minutes()
)
}
}
#[pyclass(
name = "OvernightIntradayReturn",
module = "wickra._wickra",
skip_from_py_object
)]
#[derive(Clone)]
struct PyOvernightIntradayReturn {
inner: wc::OvernightIntradayReturn,
}
#[pymethods]
impl PyOvernightIntradayReturn {
#[new]
#[pyo3(signature = (utc_offset_minutes = 0))]
fn new(utc_offset_minutes: i32) -> Self {
Self {
inner: wc::OvernightIntradayReturn::new(utc_offset_minutes),
}
}
fn update(&mut self, candle: &Bound<'_, PyAny>) -> PyResult<Option<(f64, f64)>> {
let c = extract_candle(candle)?;
Ok(self.inner.update(c).map(|o| (o.overnight, o.intraday)))
}
#[allow(clippy::too_many_arguments)]
fn batch<'py>(
&mut self,
py: Python<'py>,
open: PyReadonlyArray1<'py, f64>,
high: PyReadonlyArray1<'py, f64>,
low: PyReadonlyArray1<'py, f64>,
close: PyReadonlyArray1<'py, f64>,
volume: PyReadonlyArray1<'py, f64>,
timestamp: PyReadonlyArray1<'py, i64>,
) -> PyResult<Bound<'py, PyArray2<f64>>> {
let candles = build_seasonality_candles(&open, &high, &low, &close, &volume, &timestamp)?;
let n = candles.len();
let mut out = vec![f64::NAN; n * 2];
for (i, c) in candles.into_iter().enumerate() {
if let Some(o) = self.inner.update(c) {
out[i * 2] = o.overnight;
out[i * 2 + 1] = o.intraday;
}
}
Ok(numpy::ndarray::Array2::from_shape_vec((n, 2), out)
.expect("shape consistent")
.into_pyarray(py))
}
#[getter]
fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
fn reset(&mut self) {
self.inner.reset();
}
fn is_ready(&self) -> bool {
self.inner.is_ready()
}
fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
fn __repr__(&self) -> String {
format!(
"OvernightIntradayReturn(utc_offset_minutes={})",
self.inner.utc_offset_minutes()
)
}
}
#[pymodule]
#[allow(clippy::too_many_lines)]
fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
@@ -17596,5 +18183,18 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyRocr100>()?;
m.add_class::<PyLinRegIntercept>()?;
m.add_class::<PyTsf>()?;
// Family 16: Seasonality & Session.
m.add_class::<PySessionVwap>()?;
m.add_class::<PySessionHighLow>()?;
m.add_class::<PySessionRange>()?;
m.add_class::<PyAverageDailyRange>()?;
m.add_class::<PyOvernightGap>()?;
m.add_class::<PyOvernightIntradayReturn>()?;
m.add_class::<PyTurnOfMonth>()?;
m.add_class::<PySeasonalZScore>()?;
m.add_class::<PyTimeOfDayReturnProfile>()?;
m.add_class::<PyDayOfWeekProfile>()?;
m.add_class::<PyIntradayVolatilityProfile>()?;
m.add_class::<PyVolumeByTimeProfile>()?;
Ok(())
}
+132
View File
@@ -0,0 +1,132 @@
"""Streaming-vs-batch equivalence and reference values for the Seasonality &
Session family.
These indicators read the full candle (including ``timestamp``), so they have a
dedicated test rather than joining the timestamp-less parametrize harness in
``test_new_indicators.py``.
"""
import numpy as np
import pytest
import wickra as ta
HOUR_MS = 3_600_000
@pytest.fixture(scope="module")
def candle_columns():
"""240 hourly candles (10 days) with valid OHLCV and epoch-ms timestamps."""
n = 240
t = np.arange(n, dtype=np.float64)
close = 100.0 + np.sin(t * 0.3) * 5.0 + np.cos(t * 0.1) * 3.0
open_ = close + np.sin(t * 0.5) * 0.5
high = np.maximum(open_, close) + 1.0
low = np.minimum(open_, close) - 1.0
volume = 1000.0 + (t % 24) * 50.0
timestamp = (np.arange(n, dtype=np.int64)) * HOUR_MS
return open_, high, low, close, volume, timestamp
def _candles(cols):
open_, high, low, close, volume, timestamp = cols
return [
(open_[i], high[i], low[i], close[i], volume[i], int(timestamp[i]))
for i in range(len(close))
]
def _check_scalar(make, cols):
candles = _candles(cols)
a, b = make(), make()
stream = np.array(
[np.nan if (v := a.update(c)) is None else v for c in candles],
dtype=np.float64,
)
batch = np.asarray(b.batch(*cols))
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
def _check_matrix(make, k, cols):
candles = _candles(cols)
a, b = make(), make()
rows = []
for c in candles:
out = a.update(c)
rows.append(np.full(k, np.nan) if out is None else np.asarray(out, dtype=float))
stream = np.vstack(rows)
batch = np.asarray(b.batch(*cols))
assert batch.shape == (len(candles), k)
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
SCALAR = [
lambda: ta.SessionVwap(0),
lambda: ta.OvernightGap(0),
lambda: ta.SeasonalZScore(0),
lambda: ta.AverageDailyRange(3, 0),
lambda: ta.TurnOfMonth(3, 1, 0),
]
MATRIX = [
(lambda: ta.SessionHighLow(0), 2),
(lambda: ta.SessionRange(0), 3),
(lambda: ta.OvernightIntradayReturn(0), 2),
(lambda: ta.TimeOfDayReturnProfile(24, 0), 24),
(lambda: ta.IntradayVolatilityProfile(12, 0), 12),
(lambda: ta.VolumeByTimeProfile(24, 0), 24),
(lambda: ta.DayOfWeekProfile(0), 7),
]
@pytest.mark.parametrize("make", SCALAR)
def test_scalar_streaming_equals_batch(make, candle_columns):
_check_scalar(make, candle_columns)
@pytest.mark.parametrize("make,k", MATRIX)
def test_matrix_streaming_equals_batch(make, k, candle_columns):
_check_matrix(make, k, candle_columns)
def test_session_vwap_reference():
vwap = ta.SessionVwap(0)
# typical = close for a flat candle; volume-weighted within the day.
v1 = vwap.update((100.0, 100.0, 100.0, 100.0, 10.0, 0))
assert v1 == pytest.approx(100.0)
v2 = vwap.update((110.0, 110.0, 110.0, 110.0, 30.0, HOUR_MS))
assert v2 == pytest.approx(107.5)
# New day re-anchors.
v3 = vwap.update((200.0, 200.0, 200.0, 200.0, 5.0, 24 * HOUR_MS))
assert v3 == pytest.approx(200.0)
def test_overnight_gap_reference():
gap = ta.OvernightGap(0)
assert gap.update((99.0, 101.0, 98.0, 100.0, 1.0, 0)) is None
g = gap.update((105.0, 106.0, 104.0, 105.5, 1.0, 24 * HOUR_MS))
assert g == pytest.approx(0.05)
def test_session_high_low_reference():
shl = ta.SessionHighLow(0)
shl.update((100.0, 105.0, 99.0, 101.0, 1.0, 0))
out = shl.update((101.0, 108.0, 100.0, 107.0, 1.0, HOUR_MS))
assert out == (108.0, 99.0)
def test_volume_by_time_profile_reference():
prof = ta.VolumeByTimeProfile(24, 0)
out = prof.update((100.0, 100.0, 100.0, 100.0, 500.0, HOUR_MS)) # 01:00 -> bucket 1
assert out[1] == pytest.approx(500.0)
assert out[0] == pytest.approx(0.0)
def test_rejects_zero_buckets():
with pytest.raises(ValueError):
ta.TimeOfDayReturnProfile(0, 0)
def test_average_daily_range_rejects_zero_period():
with pytest.raises(ValueError):
ta.AverageDailyRange(0, 0)
+387
View File
@@ -10226,3 +10226,390 @@ impl WasmAlpha {
self.inner.warmup_period()
}
}
// ====================== Seasonality & Session (full-candle) ======================
//
// These read the wall-clock fields of `Candle::timestamp`. JS passes `timestamp`
// as a BigInt (epoch milliseconds). Following the multi-input precedent
// (microstructure / derivatives), WASM exposes streaming `update` only — no
// batch over ragged multi-arrays.
macro_rules! wasm_seasonality_offset_scalar {
($wrapper:ident, $js:ident, $rust:ty) => {
#[wasm_bindgen(js_name = $js)]
pub struct $wrapper {
inner: $rust,
}
#[wasm_bindgen(js_class = $js)]
impl $wrapper {
#[wasm_bindgen(constructor)]
pub fn new(utc_offset_minutes: i32) -> $wrapper {
Self {
inner: <$rust>::new(utc_offset_minutes),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<Option<f64>, JsError> {
Ok(self.inner.update(
wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?,
))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
#[wasm_bindgen(js_name = utcOffsetMinutes)]
pub fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
}
};
}
macro_rules! wasm_seasonality_bucket_profile {
($wrapper:ident, $js:ident, $rust:ty) => {
#[wasm_bindgen(js_name = $js)]
pub struct $wrapper {
inner: $rust,
}
#[wasm_bindgen(js_class = $js)]
impl $wrapper {
#[wasm_bindgen(constructor)]
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<$wrapper, JsError> {
Ok(Self {
inner: <$rust>::new(buckets, utc_offset_minutes).map_err(map_err)?,
})
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<JsValue, JsError> {
let c =
wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
Ok(match self.inner.update(c) {
Some(o) => Float64Array::from(o.bins.as_slice()).into(),
None => JsValue::NULL,
})
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
#[wasm_bindgen(js_name = utcOffsetMinutes)]
pub fn utc_offset_minutes(&self) -> i32 {
self.inner.params().1
}
}
};
}
macro_rules! wasm_seasonality_offset_profile {
($wrapper:ident, $js:ident, $rust:ty) => {
#[wasm_bindgen(js_name = $js)]
pub struct $wrapper {
inner: $rust,
}
#[wasm_bindgen(js_class = $js)]
impl $wrapper {
#[wasm_bindgen(constructor)]
pub fn new(utc_offset_minutes: i32) -> $wrapper {
Self {
inner: <$rust>::new(utc_offset_minutes),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<JsValue, JsError> {
let c =
wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
Ok(match self.inner.update(c) {
Some(o) => Float64Array::from(o.bins.as_slice()).into(),
None => JsValue::NULL,
})
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
#[wasm_bindgen(js_name = utcOffsetMinutes)]
pub fn utc_offset_minutes(&self) -> i32 {
self.inner.utc_offset_minutes()
}
}
};
}
wasm_seasonality_offset_scalar!(WasmSessionVwap, SessionVwap, wc::SessionVwap);
wasm_seasonality_offset_scalar!(WasmOvernightGap, OvernightGap, wc::OvernightGap);
wasm_seasonality_offset_scalar!(WasmSeasonalZScore, SeasonalZScore, wc::SeasonalZScore);
wasm_seasonality_bucket_profile!(
WasmTimeOfDayReturnProfile,
TimeOfDayReturnProfile,
wc::TimeOfDayReturnProfile
);
wasm_seasonality_bucket_profile!(
WasmIntradayVolatilityProfile,
IntradayVolatilityProfile,
wc::IntradayVolatilityProfile
);
wasm_seasonality_bucket_profile!(
WasmVolumeByTimeProfile,
VolumeByTimeProfile,
wc::VolumeByTimeProfile
);
wasm_seasonality_offset_profile!(WasmDayOfWeekProfile, DayOfWeekProfile, wc::DayOfWeekProfile);
#[wasm_bindgen(js_name = AverageDailyRange)]
pub struct WasmAverageDailyRange {
inner: wc::AverageDailyRange,
}
#[wasm_bindgen(js_class = AverageDailyRange)]
impl WasmAverageDailyRange {
#[wasm_bindgen(constructor)]
pub fn new(period: usize, utc_offset_minutes: i32) -> Result<WasmAverageDailyRange, JsError> {
Ok(Self {
inner: wc::AverageDailyRange::new(period, utc_offset_minutes).map_err(map_err)?,
})
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<Option<f64>, JsError> {
Ok(self
.inner
.update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = TurnOfMonth)]
pub struct WasmTurnOfMonth {
inner: wc::TurnOfMonth,
}
#[wasm_bindgen(js_class = TurnOfMonth)]
impl WasmTurnOfMonth {
#[wasm_bindgen(constructor)]
pub fn new(
n_first: u32,
n_last: u32,
utc_offset_minutes: i32,
) -> Result<WasmTurnOfMonth, JsError> {
Ok(Self {
inner: wc::TurnOfMonth::new(n_first, n_last, utc_offset_minutes).map_err(map_err)?,
})
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<Option<f64>, JsError> {
Ok(self
.inner
.update(wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?))
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = SessionHighLow)]
pub struct WasmSessionHighLow {
inner: wc::SessionHighLow,
}
#[wasm_bindgen(js_class = SessionHighLow)]
impl WasmSessionHighLow {
#[wasm_bindgen(constructor)]
pub fn new(utc_offset_minutes: i32) -> WasmSessionHighLow {
Self {
inner: wc::SessionHighLow::new(utc_offset_minutes),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<JsValue, JsError> {
let c = wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"high".into(), &o.high.into()).ok();
Reflect::set(&obj, &"low".into(), &o.low.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = SessionRange)]
pub struct WasmSessionRange {
inner: wc::SessionRange,
}
#[wasm_bindgen(js_class = SessionRange)]
impl WasmSessionRange {
#[wasm_bindgen(constructor)]
pub fn new(utc_offset_minutes: i32) -> WasmSessionRange {
Self {
inner: wc::SessionRange::new(utc_offset_minutes),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<JsValue, JsError> {
let c = wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"asia".into(), &o.asia.into()).ok();
Reflect::set(&obj, &"eu".into(), &o.eu.into()).ok();
Reflect::set(&obj, &"us".into(), &o.us.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
#[wasm_bindgen(js_name = OvernightIntradayReturn)]
pub struct WasmOvernightIntradayReturn {
inner: wc::OvernightIntradayReturn,
}
#[wasm_bindgen(js_class = OvernightIntradayReturn)]
impl WasmOvernightIntradayReturn {
#[wasm_bindgen(constructor)]
pub fn new(utc_offset_minutes: i32) -> WasmOvernightIntradayReturn {
Self {
inner: wc::OvernightIntradayReturn::new(utc_offset_minutes),
}
}
pub fn update(
&mut self,
open: f64,
high: f64,
low: f64,
close: f64,
volume: f64,
timestamp: i64,
) -> Result<JsValue, JsError> {
let c = wc::Candle::new(open, high, low, close, volume, timestamp).map_err(map_err)?;
Ok(match self.inner.update(c) {
Some(o) => {
let obj = Object::new();
Reflect::set(&obj, &"overnight".into(), &o.overnight.into()).ok();
Reflect::set(&obj, &"intraday".into(), &o.intraday.into()).ok();
obj.into()
}
None => JsValue::NULL,
})
}
pub fn reset(&mut self) {
self.inner.reset();
}
#[wasm_bindgen(js_name = isReady)]
pub fn is_ready(&self) -> bool {
self.inner.is_ready()
}
#[wasm_bindgen(js_name = warmupPeriod)]
pub fn warmup_period(&self) -> usize {
self.inner.warmup_period()
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
//!
//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
//! relevant exchange session rather than UTC. This module turns an epoch
//! millisecond instant into its civil fields using Howard Hinnant's
//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
//!
//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
//! the Unix epoch decompose correctly without a dedicated negative-input branch.
/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct CivilTime {
/// Proleptic Gregorian year (can be negative for instants before year 1).
pub(crate) year: i64,
/// Month of year, `1..=12`.
pub(crate) month: u32,
/// Day of month, `1..=31`.
pub(crate) day: u32,
/// Hour of day, `0..=23`.
pub(crate) hour: u32,
/// Minute of hour, `0..=59`.
pub(crate) minute: u32,
/// Day of week with Monday as `0` through Sunday as `6`.
pub(crate) weekday: u32,
}
impl CivilTime {
/// Minute of day, `0..=1439`.
pub(crate) const fn minute_of_day(&self) -> u32 {
self.hour * 60 + self.minute
}
}
/// Decompose an epoch-millisecond instant into local civil fields.
///
/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
let days = local_secs.div_euclid(86_400);
let secs_of_day = local_secs.rem_euclid(86_400);
let hour = (secs_of_day / 3600) as u32;
let minute = ((secs_of_day % 3600) / 60) as u32;
let (year, month, day) = civil_from_days(days);
// 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
let weekday = (days + 3).rem_euclid(7) as u32;
CivilTime {
year,
month,
day,
hour,
minute,
weekday,
}
}
/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
///
/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097; // [0, 146096]
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
let year = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
let mp = (5 * doy + 2) / 153; // [0, 11]
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
(if month <= 2 { year + 1 } else { year }, month, day)
}
/// Whether `year` is a Gregorian leap year.
pub(crate) const fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
/// Number of days in `month` (`1..=12`) of `year`.
pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
_ => {
if is_leap(year) {
29
} else {
28
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn epoch_zero_is_thursday_midnight() {
let t = civil_from_timestamp(0, 0);
assert_eq!(
t,
CivilTime {
year: 1970,
month: 1,
day: 1,
hour: 0,
minute: 0,
weekday: 3, // Thursday
}
);
assert_eq!(t.minute_of_day(), 0);
}
#[test]
fn known_utc_instant_mid_year() {
// 2021-06-15 13:45:00 UTC = 1623764700 s.
let t = civil_from_timestamp(1_623_764_700_000, 0);
assert_eq!(t.year, 2021);
assert_eq!(t.month, 6);
assert_eq!(t.day, 15);
assert_eq!(t.hour, 13);
assert_eq!(t.minute, 45);
assert_eq!(t.weekday, 1); // Tuesday
assert_eq!(t.minute_of_day(), 13 * 60 + 45);
}
#[test]
fn new_year_2021_is_friday() {
// 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
let t = civil_from_timestamp(1_609_459_200_000, 0);
assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
assert_eq!(t.weekday, 4); // Friday
}
#[test]
fn positive_offset_rolls_to_next_day() {
// 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
let t = civil_from_timestamp(base, 60);
assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
assert_eq!((t.hour, t.minute), (0, 30));
assert_eq!(t.weekday, 5); // Saturday
}
#[test]
fn negative_offset_rolls_to_previous_day() {
// 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
let base = 1_609_459_200_000 + 30 * 60 * 1000;
let t = civil_from_timestamp(base, -60);
assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
assert_eq!((t.hour, t.minute), (23, 30));
assert_eq!(t.weekday, 3); // Thursday
}
#[test]
fn sub_epoch_millis_floor_correctly() {
// -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
let t = civil_from_timestamp(-1, 0);
assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
assert_eq!((t.hour, t.minute), (23, 59));
assert_eq!(t.weekday, 2); // Wednesday
}
#[test]
fn far_negative_day_count_hits_pre_era_branch() {
// A day count below -719468 drives `z + 719468` negative, exercising the
// `z - 146096` era branch in civil_from_days (year < 1).
let (year, month, day) = civil_from_days(-1_000_000);
// -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
// Gregorian, astronomical year numbering where year 0 exists).
assert_eq!((year, month, day), (-768, 2, 4));
}
#[test]
fn leap_year_rules() {
assert!(is_leap(2000));
assert!(!is_leap(1900));
assert!(is_leap(2024));
assert!(!is_leap(2023));
}
#[test]
fn days_in_month_all_cases() {
assert_eq!(days_in_month(2023, 1), 31);
assert_eq!(days_in_month(2023, 4), 30);
assert_eq!(days_in_month(2023, 2), 28);
assert_eq!(days_in_month(2024, 2), 29);
assert_eq!(days_in_month(2023, 12), 31);
assert_eq!(days_in_month(2023, 11), 30);
}
#[test]
fn leap_day_decodes() {
// 2024-02-29 12:00 UTC.
let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
let t = civil_from_timestamp(secs * 1000, 0);
assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
assert_eq!(t.hour, 12);
}
}
@@ -0,0 +1,231 @@
//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
//! completed calendar-day sessions.
use std::collections::VecDeque;
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Average Daily Range over the last `period` completed sessions.
///
/// The indicator tracks the running high / low of the current session (the
/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`). When a new day begins, the just-finished session's
/// range (`high - low`) joins a rolling window of the last `period` completed
/// days, and the reported value is their mean. The current, still-forming day is
/// excluded until it closes. No value is produced until the first session
/// completes.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, AverageDailyRange};
///
/// let hour = 3_600_000;
/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
/// // First bar of day 2 closes day 1: ADR = 10.
/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((v - 10.0).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct AverageDailyRange {
period: usize,
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
cur_high: f64,
cur_low: f64,
completed: VecDeque<f64>,
sum: f64,
}
impl AverageDailyRange {
/// Construct an ADR indicator over `period` completed days.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `period == 0`.
pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
period,
utc_offset_minutes,
day_key: None,
cur_high: f64::NEG_INFINITY,
cur_low: f64::INFINITY,
completed: VecDeque::with_capacity(period),
sum: 0.0,
})
}
/// Configured `(period, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.period, self.utc_offset_minutes)
}
/// Most recent ADR if at least one session has completed.
pub fn value(&self) -> Option<f64> {
if self.completed.is_empty() {
None
} else {
Some(self.sum / self.completed.len() as f64)
}
}
}
impl Indicator for AverageDailyRange {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
match self.day_key {
Some(prev) if prev == key => {
if candle.high > self.cur_high {
self.cur_high = candle.high;
}
if candle.low < self.cur_low {
self.cur_low = candle.low;
}
}
Some(_) => {
let range = self.cur_high - self.cur_low;
self.completed.push_back(range);
self.sum += range;
if self.completed.len() > self.period {
self.sum -= self
.completed
.pop_front()
.expect("len > period implies a front element");
}
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
None => {
self.day_key = Some(key);
self.cur_high = candle.high;
self.cur_low = candle.low;
}
}
self.value()
}
fn reset(&mut self) {
self.day_key = None;
self.cur_high = f64::NEG_INFINITY;
self.cur_low = f64::INFINITY;
self.completed.clear();
self.sum = 0.0;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
!self.completed.is_empty()
}
fn name(&self) -> &'static str {
"AverageDailyRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
const DAY: i64 = 24 * HOUR;
fn c(high: f64, low: f64, ts: i64) -> Candle {
let mid = f64::midpoint(high, low);
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_period() {
assert!(matches!(
AverageDailyRange::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let adr = AverageDailyRange::new(5, -60).unwrap();
assert_eq!(adr.params(), (5, -60));
assert_eq!(adr.name(), "AverageDailyRange");
assert_eq!(adr.warmup_period(), 5);
assert!(!adr.is_ready());
assert!(adr.value().is_none());
}
#[test]
fn averages_completed_day_ranges() {
let mut adr = AverageDailyRange::new(3, 0).unwrap();
// Day 1: range 10.
assert!(adr.update(c(110.0, 100.0, 0)).is_none());
assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
// Day 2 opens -> day 1 (range 10) completes.
let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
assert_relative_eq!(v, 10.0);
assert!(adr.is_ready());
// Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 10.0);
}
#[test]
fn rolls_off_oldest_day_beyond_period() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0)); // day 1 range 10
let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
assert_relative_eq!(v, 10.0);
// Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
assert_relative_eq!(v, 12.5);
// Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
assert_relative_eq!(v, 17.5);
}
#[test]
fn reset_clears_state() {
let mut adr = AverageDailyRange::new(2, 0).unwrap();
adr.update(c(110.0, 100.0, 0));
adr.update(c(120.0, 110.0, DAY));
adr.reset();
assert!(!adr.is_ready());
assert!(adr.value().is_none());
assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..60)
.map(|i| {
c(
110.0 + f64::from(i % 5),
100.0 - f64::from(i % 3),
i64::from(i) * 6 * HOUR,
)
})
.collect();
let mut a = AverageDailyRange::new(4, 0).unwrap();
let mut b = AverageDailyRange::new(4, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,202 @@
//! Day-of-Week Profile — the mean bar return for each weekday.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
const DAYS: usize = 7;
/// Day-of-Week Profile output: the per-weekday mean return.
///
/// `bins[i]` is the mean simple return of all bars whose local weekday was `i`,
/// with Monday as `0` through Sunday as `6`. Weekdays with no bars read `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct DayOfWeekProfileOutput {
/// Per-weekday mean return, Monday first. Always length 7.
pub bins: Vec<f64>,
}
/// Mean bar return bucketed by local weekday (Monday `0` .. Sunday `6`).
///
/// Each bar's simple return `close / previous_close - 1` is accumulated into the
/// bucket of its local weekday (the wall-clock day of
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`), and the
/// profile reports the running mean per weekday. The first bar produces no output.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, DayOfWeekProfile};
///
/// let day = 24 * 3_600_000;
/// let mut prof = DayOfWeekProfile::new(0);
/// // 1970-01-01 was a Thursday (weekday 3).
/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, day).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 7);
/// ```
#[derive(Debug, Clone)]
pub struct DayOfWeekProfile {
utc_offset_minutes: i32,
prev_close: Option<f64>,
sum: [f64; DAYS],
count: [u64; DAYS],
last: Option<DayOfWeekProfileOutput>,
}
impl DayOfWeekProfile {
/// Construct a Day-of-Week Profile with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
prev_close: None,
sum: [0.0; DAYS],
count: [0; DAYS],
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent profile if at least one return has been recorded.
pub fn value(&self) -> Option<&DayOfWeekProfileOutput> {
self.last.as_ref()
}
fn snapshot(&self) -> DayOfWeekProfileOutput {
let bins = self
.sum
.iter()
.zip(&self.count)
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
.collect();
DayOfWeekProfileOutput { bins }
}
}
impl Indicator for DayOfWeekProfile {
type Input = Candle;
type Output = DayOfWeekProfileOutput;
fn update(&mut self, candle: Candle) -> Option<DayOfWeekProfileOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let result = if let Some(prev) = self.prev_close {
let ret = if prev == 0.0 {
0.0
} else {
candle.close / prev - 1.0
};
let day = civil.weekday as usize;
self.sum[day] += ret;
self.count[day] += 1;
let out = self.snapshot();
self.last = Some(out.clone());
Some(out)
} else {
None
};
self.prev_close = Some(candle.close);
result
}
fn reset(&mut self) {
self.prev_close = None;
self.sum = [0.0; DAYS];
self.count = [0; DAYS];
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"DayOfWeekProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const DAY: i64 = 24 * 3_600_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let prof = DayOfWeekProfile::new(60);
assert_eq!(prof.utc_offset_minutes(), 60);
assert_eq!(prof.name(), "DayOfWeekProfile");
assert_eq!(prof.warmup_period(), 2);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn buckets_by_weekday() {
let mut prof = DayOfWeekProfile::new(0);
// 1970-01-01 Thursday (3); 01-02 Friday (4).
assert!(prof.update(c(100.0, 0)).is_none());
let out = prof.update(c(101.0, DAY)).unwrap(); // Friday return +0.01
assert_eq!(out.bins.len(), 7);
assert_relative_eq!(out.bins[4], 0.01); // Friday
assert_relative_eq!(out.bins[3], 0.0); // Thursday had no return
assert!(prof.is_ready());
}
#[test]
fn averages_same_weekday_across_weeks() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(100.0, 0)); // Thu
prof.update(c(101.0, DAY)); // Fri +0.01
// Jump to next Friday (7 days later from day 0 -> +7 days, weekday 4).
prof.update(c(100.0, 7 * DAY)); // Thu+? actually day 7 -> weekday (7+3)%7=3 Thu
let out = prof.update(c(103.0, 8 * DAY)).unwrap(); // day 8 -> Fri, return
// Friday now has two samples; both positive.
assert!(out.bins[4] > 0.0);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(0.0, 0));
let out = prof.update(c(5.0, DAY)).unwrap();
assert_relative_eq!(out.bins[4], 0.0); // Friday, guarded return 0
}
#[test]
fn reset_clears_state() {
let mut prof = DayOfWeekProfile::new(0);
prof.update(c(100.0, 0));
prof.update(c(101.0, DAY));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
assert!(prof.update(c(100.0, 2 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| c(100.0 + f64::from(i % 5), i64::from(i) * DAY))
.collect();
let mut a = DayOfWeekProfile::new(0);
let mut b = DayOfWeekProfile::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,236 @@
//! Intraday Volatility Profile — the return volatility in each intraday bucket.
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Intraday Volatility Profile output: the per-bucket return standard deviation.
///
/// `bins[i]` is the sample standard deviation of the simple returns of all bars
/// whose local time-of-day fell in bucket `i`. Buckets with fewer than two
/// samples read `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct IntradayVolatilityProfileOutput {
/// Per-bucket return standard deviation, earliest bucket first.
pub bins: Vec<f64>,
}
/// Return volatility bucketed by local time of day.
///
/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each
/// bar's simple return `close / previous_close - 1` updates the per-bucket
/// running variance (Welford), and the profile reports the per-bucket sample
/// standard deviation. The first bar produces no output.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, IntradayVolatilityProfile};
///
/// let hour = 3_600_000;
/// let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, hour).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 24);
/// ```
#[derive(Debug, Clone)]
pub struct IntradayVolatilityProfile {
buckets: usize,
utc_offset_minutes: i32,
prev_close: Option<f64>,
count: Vec<u64>,
mean: Vec<f64>,
m2: Vec<f64>,
last: Option<IntradayVolatilityProfileOutput>,
}
impl IntradayVolatilityProfile {
/// Construct an Intraday Volatility Profile with `buckets` intraday slices.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `buckets == 0`.
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
if buckets == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
buckets,
utc_offset_minutes,
prev_close: None,
count: vec![0; buckets],
mean: vec![0.0; buckets],
m2: vec![0.0; buckets],
last: None,
})
}
/// Configured `(buckets, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.buckets, self.utc_offset_minutes)
}
/// Most recent profile if at least one return has been recorded.
pub fn value(&self) -> Option<&IntradayVolatilityProfileOutput> {
self.last.as_ref()
}
fn bucket_of(&self, minute_of_day: u32) -> usize {
let raw = (minute_of_day as usize * self.buckets) / 1440;
raw.min(self.buckets - 1)
}
fn snapshot(&self) -> IntradayVolatilityProfileOutput {
let bins = self
.count
.iter()
.zip(&self.m2)
.map(|(n, m2)| {
if *n >= 2 {
(m2 / (*n - 1) as f64).sqrt()
} else {
0.0
}
})
.collect();
IntradayVolatilityProfileOutput { bins }
}
}
impl Indicator for IntradayVolatilityProfile {
type Input = Candle;
type Output = IntradayVolatilityProfileOutput;
fn update(&mut self, candle: Candle) -> Option<IntradayVolatilityProfileOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let result = if let Some(prev) = self.prev_close {
let ret = if prev == 0.0 {
0.0
} else {
candle.close / prev - 1.0
};
let bucket = self.bucket_of(civil.minute_of_day());
self.count[bucket] += 1;
let delta = ret - self.mean[bucket];
self.mean[bucket] += delta / self.count[bucket] as f64;
let delta2 = ret - self.mean[bucket];
self.m2[bucket] += delta * delta2;
let out = self.snapshot();
self.last = Some(out.clone());
Some(out)
} else {
None
};
self.prev_close = Some(candle.close);
result
}
fn reset(&mut self) {
self.prev_close = None;
self.count.iter_mut().for_each(|x| *x = 0);
self.mean.iter_mut().for_each(|x| *x = 0.0);
self.m2.iter_mut().for_each(|x| *x = 0.0);
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"IntradayVolatilityProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
const DAY: i64 = 24 * HOUR;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_buckets() {
assert!(matches!(
IntradayVolatilityProfile::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let prof = IntradayVolatilityProfile::new(24, 90).unwrap();
assert_eq!(prof.params(), (24, 90));
assert_eq!(prof.name(), "IntradayVolatilityProfile");
assert_eq!(prof.warmup_period(), 2);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn single_sample_bucket_has_zero_vol() {
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
assert!(prof.update(c(100.0, 0)).is_none());
let out = prof.update(c(101.0, HOUR)).unwrap();
assert_eq!(out.bins.len(), 24);
assert_relative_eq!(out.bins[1], 0.0); // only one sample in bucket 1
assert!(prof.is_ready());
}
#[test]
fn std_matches_manual_two_samples() {
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
prof.update(c(100.0, 0)); // 00:00
prof.update(c(101.0, HOUR)); // 01:00 r=0.01 into bucket 1
// Next day 01:00, r2 = 0.03 into bucket 1.
let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap();
// sample std of {0.01, 0.03} = sqrt(((.01-.02)^2+(.03-.02)^2)/1) = 0.01414..
let mean = 0.02;
let expected = (((0.01_f64 - mean).powi(2) + (0.03 - mean).powi(2)) / 1.0).sqrt();
assert_relative_eq!(out.bins[1], expected, epsilon = 1e-9);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut prof = IntradayVolatilityProfile::new(4, 0).unwrap();
prof.update(c(0.0, 0));
let out = prof.update(c(5.0, HOUR)).unwrap();
assert_relative_eq!(out.bins[0], 0.0);
}
#[test]
fn reset_clears_state() {
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
prof.update(c(100.0, 0));
prof.update(c(101.0, HOUR));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
assert!(prof.update(c(100.0, DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 6), i64::from(i) * HOUR))
.collect();
let mut a = IntradayVolatilityProfile::new(12, 0).unwrap();
let mut b = IntradayVolatilityProfile::new(12, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+42 -1
View File
@@ -29,6 +29,7 @@ mod atr;
mod atr_bands;
mod atr_trailing_stop;
mod autocorrelation;
mod average_daily_range;
mod average_drawdown;
mod avg_price;
mod awesome_oscillator;
@@ -67,6 +68,7 @@ mod counterattack;
mod cumulative_volume_index;
mod cvd;
mod cybernetic_cycle;
mod day_of_week_profile;
mod decycler;
mod decycler_oscillator;
mod dema;
@@ -136,6 +138,7 @@ mod inertia;
mod information_ratio;
mod initial_balance;
mod instantaneous_trendline;
mod intraday_volatility_profile;
mod inverse_fisher_transform;
mod inverted_hammer;
mod jma;
@@ -202,6 +205,8 @@ mod on_neck;
mod opening_marubozu;
mod opening_range;
mod ou_half_life;
mod overnight_gap;
mod overnight_intraday_return;
mod pain_index;
mod pair_spread_zscore;
mod pairwise_beta;
@@ -242,7 +247,11 @@ mod rvi;
mod rvi_volatility;
mod rwi;
mod sar_ext;
mod seasonal_z_score;
mod separating_lines;
mod session_high_low;
mod session_range;
mod session_vwap;
mod sharpe_ratio;
mod shooting_star;
mod short_line;
@@ -295,6 +304,7 @@ mod three_stars_in_south;
mod thrusting;
mod tick_index;
mod tii;
mod time_of_day_return_profile;
mod tpo_profile;
mod trade_imbalance;
mod treynor_ratio;
@@ -306,6 +316,7 @@ mod tsf;
mod tsi;
mod tsv;
mod ttm_squeeze;
mod turn_of_month;
mod tweezer;
mod two_crows;
mod typical_price;
@@ -322,6 +333,7 @@ mod variance_ratio;
mod vertical_horizontal_filter;
mod vidya;
mod volty_stop;
mod volume_by_time_profile;
mod volume_oscillator;
mod volume_profile;
mod vortex;
@@ -368,6 +380,7 @@ pub use atr::Atr;
pub use atr_bands::{AtrBands, AtrBandsOutput};
pub use atr_trailing_stop::AtrTrailingStop;
pub use autocorrelation::Autocorrelation;
pub use average_daily_range::AverageDailyRange;
pub use average_drawdown::AverageDrawdown;
pub use avg_price::AvgPrice;
pub use awesome_oscillator::AwesomeOscillator;
@@ -406,6 +419,7 @@ pub use counterattack::Counterattack;
pub use cumulative_volume_index::CumulativeVolumeIndex;
pub use cvd::CumulativeVolumeDelta;
pub use cybernetic_cycle::CyberneticCycle;
pub use day_of_week_profile::{DayOfWeekProfile, DayOfWeekProfileOutput};
pub use decycler::Decycler;
pub use decycler_oscillator::DecyclerOscillator;
pub use dema::Dema;
@@ -475,6 +489,7 @@ pub use inertia::Inertia;
pub use information_ratio::InformationRatio;
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
pub use instantaneous_trendline::InstantaneousTrendline;
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
pub use inverse_fisher_transform::InverseFisherTransform;
pub use inverted_hammer::InvertedHammer;
pub use jma::Jma;
@@ -541,6 +556,8 @@ pub use on_neck::OnNeck;
pub use opening_marubozu::OpeningMarubozu;
pub use opening_range::{OpeningRange, OpeningRangeOutput};
pub use ou_half_life::OuHalfLife;
pub use overnight_gap::OvernightGap;
pub use overnight_intraday_return::{OvernightIntradayReturn, OvernightIntradayReturnOutput};
pub use pain_index::PainIndex;
pub use pair_spread_zscore::PairSpreadZScore;
pub use pairwise_beta::PairwiseBeta;
@@ -581,7 +598,11 @@ pub use rvi::Rvi;
pub use rvi_volatility::RviVolatility;
pub use rwi::{Rwi, RwiOutput};
pub use sar_ext::SarExt;
pub use seasonal_z_score::SeasonalZScore;
pub use separating_lines::SeparatingLines;
pub use session_high_low::{SessionHighLow, SessionHighLowOutput};
pub use session_range::{SessionRange, SessionRangeOutput};
pub use session_vwap::SessionVwap;
pub use sharpe_ratio::SharpeRatio;
pub use shooting_star::ShootingStar;
pub use short_line::ShortLine;
@@ -634,6 +655,7 @@ pub use three_stars_in_south::ThreeStarsInSouth;
pub use thrusting::Thrusting;
pub use tick_index::TickIndex;
pub use tii::Tii;
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
pub use trade_imbalance::TradeImbalance;
pub use treynor_ratio::TreynorRatio;
@@ -645,6 +667,7 @@ pub use tsf::Tsf;
pub use tsi::Tsi;
pub use tsv::Tsv;
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
pub use turn_of_month::TurnOfMonth;
pub use tweezer::Tweezer;
pub use two_crows::TwoCrows;
pub use typical_price::TypicalPrice;
@@ -661,6 +684,7 @@ pub use variance_ratio::VarianceRatio;
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
pub use vidya::Vidya;
pub use volty_stop::VoltyStop;
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
pub use volume_oscillator::VolumeOscillator;
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
pub use vortex::{Vortex, VortexOutput};
@@ -1118,6 +1142,23 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
"TickIndex",
],
),
(
"Seasonality & Session",
&[
"SessionVwap",
"SessionHighLow",
"SessionRange",
"AverageDailyRange",
"OvernightGap",
"OvernightIntradayReturn",
"TurnOfMonth",
"SeasonalZScore",
"TimeOfDayReturnProfile",
"DayOfWeekProfile",
"IntradayVolatilityProfile",
"VolumeByTimeProfile",
],
),
];
#[cfg(test)]
@@ -1146,6 +1187,6 @@ mod family_tests {
// the actual indicator count is the early-warning signal that an
// indicator was added without being assigned a family.
let total: usize = FAMILIES.iter().map(|(_, ns)| ns.len()).sum();
assert_eq!(total, 339, "FAMILIES total drifted from indicator count");
assert_eq!(total, 351, "FAMILIES total drifted from indicator count");
}
}
@@ -0,0 +1,191 @@
//! Overnight Gap — the return from the previous session's close to the current
//! session's open, detected automatically at each day boundary.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Close-to-open overnight gap as a simple return.
///
/// At every local day boundary the indicator computes
/// `open / previous_close - 1`, where `previous_close` is the close of the last
/// bar of the prior session and `open` is the open of the first bar of the new
/// session. The value holds for the rest of the session until the next boundary.
/// The boundary is the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`. The first session yields no gap (there is no
/// prior close to compare against).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, OvernightGap};
///
/// let hour = 3_600_000;
/// let mut gap = OvernightGap::new(0);
/// // Day 1 closes at 100.
/// assert!(gap.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
/// // Day 2 opens at 105 -> gap = 105 / 100 - 1 = 0.05.
/// let g = gap.update(Candle::new(105.0, 106.0, 104.0, 105.5, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((g - 0.05).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct OvernightGap {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
last_close: Option<f64>,
gap: Option<f64>,
}
impl OvernightGap {
/// Construct an Overnight Gap indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
last_close: None,
gap: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent overnight gap if at least one day boundary has been crossed.
pub const fn value(&self) -> Option<f64> {
self.gap
}
}
impl Indicator for OvernightGap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key != Some(key) {
if let Some(prev_close) = self.last_close {
self.gap = Some(if prev_close == 0.0 {
0.0
} else {
candle.open / prev_close - 1.0
});
}
self.day_key = Some(key);
}
self.last_close = Some(candle.close);
self.gap
}
fn reset(&mut self) {
self.day_key = None;
self.last_close = None;
self.gap = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.gap.is_some()
}
fn name(&self) -> &'static str {
"OvernightGap"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(open: f64, close: f64, ts: i64) -> Candle {
let high = open.max(close);
let low = open.min(close);
Candle::new(open, high, low, close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let gap = OvernightGap::new(330);
assert_eq!(gap.utc_offset_minutes(), 330);
assert_eq!(gap.name(), "OvernightGap");
assert_eq!(gap.warmup_period(), 2);
assert!(!gap.is_ready());
assert!(gap.value().is_none());
}
#[test]
fn first_session_has_no_gap() {
let mut gap = OvernightGap::new(0);
assert!(gap.update(c(99.0, 100.0, 0)).is_none());
// Same day, still no gap.
assert!(gap.update(c(100.0, 101.0, HOUR)).is_none());
assert!(!gap.is_ready());
}
#[test]
fn computes_gap_at_day_boundary() {
let mut gap = OvernightGap::new(0);
gap.update(c(99.0, 100.0, 0)); // day 1 closes 100
let g = gap.update(c(105.0, 105.5, 24 * HOUR)).unwrap();
assert_relative_eq!(g, 0.05);
assert!(gap.is_ready());
// Holds for the rest of the session.
let same = gap.update(c(106.0, 107.0, 25 * HOUR)).unwrap();
assert_relative_eq!(same, 0.05);
}
#[test]
fn negative_gap_down() {
let mut gap = OvernightGap::new(0);
gap.update(c(99.0, 100.0, 0));
let g = gap.update(c(90.0, 91.0, 24 * HOUR)).unwrap();
assert_relative_eq!(g, -0.1);
}
#[test]
fn zero_prev_close_yields_zero_gap() {
let mut gap = OvernightGap::new(0);
gap.update(c(0.0, 0.0, 0)); // degenerate day 1 closing at 0
let g = gap.update(c(5.0, 6.0, 24 * HOUR)).unwrap();
assert_relative_eq!(g, 0.0);
}
#[test]
fn reset_clears_state() {
let mut gap = OvernightGap::new(0);
gap.update(c(99.0, 100.0, 0));
gap.update(c(105.0, 105.5, 24 * HOUR));
gap.reset();
assert!(!gap.is_ready());
assert!(gap.value().is_none());
assert!(gap.update(c(10.0, 11.0, 48 * HOUR)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| {
c(
100.0 + f64::from(i % 7),
100.0 + f64::from(i % 5),
i64::from(i) * 6 * HOUR,
)
})
.collect();
let mut a = OvernightGap::new(0);
let mut b = OvernightGap::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,225 @@
//! Overnight vs. Intraday Return — decomposes a session's total return into its
//! overnight (close-to-open) and intraday (open-to-close) components.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// The two return components of the current session.
///
/// `overnight` is fixed at the session open (`open / previous_close - 1`);
/// `intraday` updates with every bar (`close / open - 1`). Compounding the two —
/// `(1 + overnight)(1 + intraday) - 1` — reconstructs the full previous-close to
/// latest-close return.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OvernightIntradayReturnOutput {
/// Close-to-open return carried into the session.
pub overnight: f64,
/// Open-to-latest-close return accumulated within the session.
pub intraday: f64,
}
/// Overnight / intraday return decomposition, re-anchored at each local day
/// boundary of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`.
///
/// The first session yields no output (there is no prior close to anchor the
/// overnight leg); from the second session onward every bar reports both
/// components.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, OvernightIntradayReturn};
///
/// let hour = 3_600_000;
/// let mut oi = OvernightIntradayReturn::new(0);
/// // Day 1 closes at 100.
/// assert!(oi.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
/// // Day 2 opens 110 (overnight +10%), closes 121 (intraday +10%).
/// let v = oi.update(Candle::new(110.0, 122.0, 109.0, 121.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert!((v.overnight - 0.10).abs() < 1e-9);
/// assert!((v.intraday - 0.10).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct OvernightIntradayReturn {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
last_close: Option<f64>,
today_open: f64,
overnight: Option<f64>,
last: Option<OvernightIntradayReturnOutput>,
}
impl OvernightIntradayReturn {
/// Construct the indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
last_close: None,
today_open: 0.0,
overnight: None,
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent decomposition if at least one day boundary has been crossed.
pub const fn value(&self) -> Option<OvernightIntradayReturnOutput> {
self.last
}
}
impl Indicator for OvernightIntradayReturn {
type Input = Candle;
type Output = OvernightIntradayReturnOutput;
fn update(&mut self, candle: Candle) -> Option<OvernightIntradayReturnOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key != Some(key) {
if let Some(prev_close) = self.last_close {
self.overnight = Some(if prev_close == 0.0 {
0.0
} else {
candle.open / prev_close - 1.0
});
}
self.today_open = candle.open;
self.day_key = Some(key);
}
self.last_close = Some(candle.close);
let overnight = self.overnight?;
let intraday = if self.today_open == 0.0 {
0.0
} else {
candle.close / self.today_open - 1.0
};
let out = OvernightIntradayReturnOutput {
overnight,
intraday,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.day_key = None;
self.last_close = None;
self.today_open = 0.0;
self.overnight = None;
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"OvernightIntradayReturn"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(open: f64, close: f64, ts: i64) -> Candle {
let high = open.max(close) + 1.0;
let low = open.min(close) - 1.0;
Candle::new(open, high, low.max(0.0), close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let oi = OvernightIntradayReturn::new(-300);
assert_eq!(oi.utc_offset_minutes(), -300);
assert_eq!(oi.name(), "OvernightIntradayReturn");
assert_eq!(oi.warmup_period(), 2);
assert!(!oi.is_ready());
assert!(oi.value().is_none());
}
#[test]
fn first_session_yields_none() {
let mut oi = OvernightIntradayReturn::new(0);
assert!(oi.update(c(99.0, 100.0, 0)).is_none());
assert!(oi.update(c(100.0, 102.0, HOUR)).is_none());
assert!(!oi.is_ready());
}
#[test]
fn decomposes_overnight_and_intraday() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(99.0, 100.0, 0)); // day 1 close 100
let v = oi.update(c(110.0, 121.0, 24 * HOUR)).unwrap();
assert_relative_eq!(v.overnight, 0.10);
assert_relative_eq!(v.intraday, 0.10);
assert!(oi.is_ready());
}
#[test]
fn intraday_updates_through_the_session() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(99.0, 100.0, 0));
oi.update(c(110.0, 110.0, 24 * HOUR)); // open 110, close 110 -> intraday 0
let later = oi.update(c(111.0, 132.0, 25 * HOUR)).unwrap();
assert_relative_eq!(later.overnight, 0.10); // fixed at open
assert_relative_eq!(later.intraday, 0.20); // 132 / 110 - 1
}
#[test]
fn zero_anchors_yield_zero_components() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(1.0, 0.0, 0)); // day 1 closes at 0
// Day 2 opens at 0: overnight uses zero prev_close -> 0; intraday uses
// zero today_open -> 0.
let candle = Candle::new(0.0, 5.0, 0.0, 4.0, 1.0, 24 * HOUR).unwrap();
let v = oi.update(candle).unwrap();
assert_relative_eq!(v.overnight, 0.0);
assert_relative_eq!(v.intraday, 0.0);
}
#[test]
fn reset_clears_state() {
let mut oi = OvernightIntradayReturn::new(0);
oi.update(c(99.0, 100.0, 0));
oi.update(c(110.0, 121.0, 24 * HOUR));
oi.reset();
assert!(!oi.is_ready());
assert!(oi.value().is_none());
assert!(oi.update(c(50.0, 55.0, 48 * HOUR)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..48)
.map(|i| {
c(
100.0 + f64::from(i % 6),
100.0 + f64::from(i % 4),
i64::from(i) * 8 * HOUR,
)
})
.collect();
let mut a = OvernightIntradayReturn::new(0);
let mut b = OvernightIntradayReturn::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,232 @@
//! Seasonal Z-Score — how far the current bar's return sits from the historical
//! mean return of bars in the *same hour of day*, in standard deviations.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
const HOURS: usize = 24;
/// Seasonal Z-Score keyed on hour of day.
///
/// For every bar the indicator forms the simple return `close / previous_close - 1`
/// and compares it to the running mean and standard deviation of all prior
/// returns that fell in the *same* local hour (the wall-clock hour of
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`). The
/// output is `(return - hour_mean) / hour_std`. A bucket needs at least two prior
/// samples before it can emit; a bucket with zero historical variance reports
/// `0.0`. The per-hour statistics use Welford's online algorithm.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SeasonalZScore};
///
/// let day = 24 * 3_600_000;
/// let mut z = SeasonalZScore::new(0);
/// // Same hour each day so they share a bucket; close grows then jumps.
/// for (i, close) in [100.0, 101.0, 103.0].iter().enumerate() {
/// z.update(Candle::new(*close, *close, *close, *close, 1.0, i as i64 * day).unwrap());
/// }
/// // Fourth same-hour sample has two priors in the bucket -> emits a z-score.
/// let out = z.update(Candle::new(110.0, 110.0, 110.0, 110.0, 1.0, 3 * day).unwrap());
/// assert!(out.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct SeasonalZScore {
utc_offset_minutes: i32,
prev_close: Option<f64>,
count: [u64; HOURS],
mean: [f64; HOURS],
m2: [f64; HOURS],
last: Option<f64>,
}
impl SeasonalZScore {
/// Construct a Seasonal Z-Score indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
prev_close: None,
count: [0; HOURS],
mean: [0.0; HOURS],
m2: [0.0; HOURS],
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent z-score if a populated bucket has produced one.
pub const fn value(&self) -> Option<f64> {
self.last
}
fn z_for(&self, hour: usize, ret: f64) -> Option<f64> {
if self.count[hour] < 2 {
return None;
}
let variance = self.m2[hour] / (self.count[hour] - 1) as f64;
if variance > 0.0 {
Some((ret - self.mean[hour]) / variance.sqrt())
} else {
Some(0.0)
}
}
fn accumulate(&mut self, hour: usize, ret: f64) {
self.count[hour] += 1;
let delta = ret - self.mean[hour];
self.mean[hour] += delta / self.count[hour] as f64;
let delta2 = ret - self.mean[hour];
self.m2[hour] += delta * delta2;
}
}
impl Indicator for SeasonalZScore {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let hour = civil.hour as usize;
let result = if let Some(prev) = self.prev_close {
let ret = if prev == 0.0 {
0.0
} else {
candle.close / prev - 1.0
};
let z = self.z_for(hour, ret);
self.accumulate(hour, ret);
z
} else {
None
};
self.prev_close = Some(candle.close);
if result.is_some() {
self.last = result;
}
result
}
fn reset(&mut self) {
self.prev_close = None;
self.count = [0; HOURS];
self.mean = [0.0; HOURS];
self.m2 = [0.0; HOURS];
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SeasonalZScore"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const DAY: i64 = 24 * 3_600_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let z = SeasonalZScore::new(120);
assert_eq!(z.utc_offset_minutes(), 120);
assert_eq!(z.name(), "SeasonalZScore");
assert_eq!(z.warmup_period(), 2);
assert!(!z.is_ready());
assert!(z.value().is_none());
}
#[test]
fn no_output_until_bucket_has_two_priors() {
let mut z = SeasonalZScore::new(0);
// Each bar shares the same hour bucket (same time-of-day, daily spacing).
assert!(z.update(c(100.0, 0)).is_none()); // first: no return
assert!(z.update(c(101.0, DAY)).is_none()); // return #1 -> bucket has 0 priors
assert!(z.update(c(102.0, 2 * DAY)).is_none()); // return #2 -> bucket has 1 prior
// return #3 -> bucket has 2 priors -> emits.
assert!(z.update(c(104.0, 3 * DAY)).is_some());
assert!(z.is_ready());
}
#[test]
fn z_score_matches_manual_welford() {
let mut z = SeasonalZScore::new(0);
// Returns into one hourly bucket: r1 = 0.01, r2 = 0.02, r3 = 0.03.
z.update(c(100.0, 0));
z.update(c(101.0, DAY)); // r1 = 0.01
z.update(c(103.02, 2 * DAY)); // r2 = 0.02
// Priors {0.01, 0.02}: mean 0.015, sample std = sqrt(((.005)^2*2)/1).
let mean = 0.015;
let std = (((0.01_f64 - mean).powi(2) + (0.02 - mean).powi(2)) / 1.0).sqrt();
let r3 = 0.03;
let expected = (r3 - mean) / std;
let close = 103.02 * (1.0 + r3);
let out = z.update(c(close, 3 * DAY)).unwrap();
assert_relative_eq!(out, expected, epsilon = 1e-9);
}
#[test]
fn zero_variance_bucket_reports_zero() {
let mut z = SeasonalZScore::new(0);
// Constant return into the bucket -> variance 0 -> z = 0.
z.update(c(100.0, 0));
z.update(c(110.0, DAY)); // r1 = 0.10
z.update(c(121.0, 2 * DAY)); // r2 = 0.10
let out = z.update(c(133.1, 3 * DAY)).unwrap(); // r3 = 0.10
assert_relative_eq!(out, 0.0);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut z = SeasonalZScore::new(0);
z.update(c(0.0, 0)); // prev close 0
z.update(c(0.0, DAY)); // ret = 0 (guarded), bucket sample
z.update(c(0.0, 2 * DAY)); // ret = 0, bucket now 2 priors
let out = z.update(c(0.0, 3 * DAY)).unwrap();
assert_relative_eq!(out, 0.0);
}
#[test]
fn reset_clears_state() {
let mut z = SeasonalZScore::new(0);
for i in 0..4 {
z.update(c(100.0 + f64::from(i), i64::from(i) * DAY));
}
z.reset();
assert!(!z.is_ready());
assert!(z.value().is_none());
assert!(z.update(c(100.0, 4 * DAY)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 9), i64::from(i) * 3 * 3_600_000))
.collect();
let mut a = SeasonalZScore::new(0);
let mut b = SeasonalZScore::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,226 @@
//! Session High/Low — the running high and low of the current calendar-day
//! session, re-anchored automatically at each day boundary.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Session High/Low output: the high and low established so far in the current
/// session.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SessionHighLowOutput {
/// Highest high seen since the current session opened.
pub high: f64,
/// Lowest low seen since the current session opened.
pub low: f64,
}
/// Running high / low of the current session, keyed off the wall-clock day of
/// [`Candle::timestamp`](crate::Candle).
///
/// Unlike [`crate::OpeningRange`] or [`crate::InitialBalance`], which require the
/// caller to invoke `reset()` at every session boundary, this indicator detects
/// the boundary itself: whenever a candle falls on a different local calendar
/// day (after shifting by `utc_offset_minutes`) the high / low are re-anchored to
/// that candle. `utc_offset_minutes` lets callers align the day boundary to an
/// exchange session — `0` for UTC, `-300` for U.S. Eastern standard time.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SessionHighLow};
///
/// // One bar per hour; the day rolls over after 24 bars at UTC.
/// let mut shl = SessionHighLow::new(0);
/// let hour = 3_600_000;
/// shl.update(Candle::new(100.0, 105.0, 99.0, 101.0, 1.0, 0).unwrap());
/// let v = shl.update(Candle::new(101.0, 108.0, 100.0, 107.0, 1.0, hour).unwrap()).unwrap();
/// assert_eq!(v.high, 108.0);
/// assert_eq!(v.low, 99.0);
/// // A bar on the next day re-anchors to that bar alone.
/// let v = shl.update(Candle::new(50.0, 51.0, 49.0, 50.0, 1.0, 24 * hour).unwrap()).unwrap();
/// assert_eq!(v.high, 51.0);
/// assert_eq!(v.low, 49.0);
/// ```
#[derive(Debug, Clone)]
pub struct SessionHighLow {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
high: f64,
low: f64,
last: Option<SessionHighLowOutput>,
}
impl SessionHighLow {
/// Construct a Session High/Low indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
high: f64::NEG_INFINITY,
low: f64::INFINITY,
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent output if at least one bar has been seen.
pub const fn value(&self) -> Option<SessionHighLowOutput> {
self.last
}
}
impl Indicator for SessionHighLow {
type Input = Candle;
type Output = SessionHighLowOutput;
fn update(&mut self, candle: Candle) -> Option<SessionHighLowOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key == Some(key) {
if candle.high > self.high {
self.high = candle.high;
}
if candle.low < self.low {
self.low = candle.low;
}
} else {
self.day_key = Some(key);
self.high = candle.high;
self.low = candle.low;
}
let out = SessionHighLowOutput {
high: self.high,
low: self.low,
};
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.day_key = None;
self.high = f64::NEG_INFINITY;
self.low = f64::INFINITY;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SessionHighLow"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(high: f64, low: f64, ts: i64) -> Candle {
let mid = f64::midpoint(high, low);
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let shl = SessionHighLow::new(-300);
assert_eq!(shl.utc_offset_minutes(), -300);
assert_eq!(shl.name(), "SessionHighLow");
assert_eq!(shl.warmup_period(), 1);
assert!(!shl.is_ready());
assert!(shl.value().is_none());
}
#[test]
fn tracks_high_low_within_day() {
let mut shl = SessionHighLow::new(0);
let first = shl.update(c(105.0, 99.0, 0)).unwrap();
assert_relative_eq!(first.high, 105.0);
assert_relative_eq!(first.low, 99.0);
assert!(shl.is_ready());
let second = shl.update(c(108.0, 100.0, HOUR)).unwrap();
assert_relative_eq!(second.high, 108.0);
assert_relative_eq!(second.low, 99.0);
// A narrower bar does not shrink the range.
let third = shl.update(c(106.0, 101.0, 2 * HOUR)).unwrap();
assert_relative_eq!(third.high, 108.0);
assert_relative_eq!(third.low, 99.0);
// A bar with a lower low extends the range downward (same day).
let fourth = shl.update(c(107.0, 95.0, 3 * HOUR)).unwrap();
assert_relative_eq!(fourth.high, 108.0);
assert_relative_eq!(fourth.low, 95.0);
}
#[test]
fn re_anchors_on_new_day() {
let mut shl = SessionHighLow::new(0);
shl.update(c(105.0, 99.0, 0));
shl.update(c(108.0, 100.0, HOUR));
let next = shl.update(c(51.0, 49.0, 24 * HOUR)).unwrap();
assert_relative_eq!(next.high, 51.0);
assert_relative_eq!(next.low, 49.0);
}
#[test]
fn utc_offset_shifts_day_boundary() {
// Two bars 1h apart straddling UTC midnight. At UTC they are different
// days; at +120 min they fall on the same local day.
let pre = 23 * HOUR; // 1970-01-01 23:00 UTC
let post = 24 * HOUR; // 1970-01-02 00:00 UTC
let mut utc = SessionHighLow::new(0);
utc.update(c(105.0, 99.0, pre));
let rolled = utc.update(c(108.0, 100.0, post)).unwrap();
assert_relative_eq!(rolled.high, 108.0);
assert_relative_eq!(rolled.low, 100.0); // re-anchored
let mut shifted = SessionHighLow::new(120);
shifted.update(c(105.0, 99.0, pre));
let same = shifted.update(c(108.0, 100.0, post)).unwrap();
assert_relative_eq!(same.high, 108.0);
assert_relative_eq!(same.low, 99.0); // same local day, range kept
}
#[test]
fn reset_clears_state() {
let mut shl = SessionHighLow::new(0);
shl.update(c(105.0, 99.0, 0));
shl.reset();
assert!(!shl.is_ready());
assert!(shl.value().is_none());
let after = shl.update(c(60.0, 50.0, HOUR)).unwrap();
assert_relative_eq!(after.high, 60.0);
assert_relative_eq!(after.low, 50.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
c(
100.0 + f64::from(i),
90.0 + f64::from(i) * 0.5,
i64::from(i) * HOUR,
)
})
.collect();
let mut a = SessionHighLow::new(0);
let mut b = SessionHighLow::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,248 @@
//! Session Range — the high-minus-low range accumulated within each of the
//! three canonical trading sessions (Asia / EU / US) of the current day.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Session Range output: the current day's range within each session.
///
/// A session with no bars yet reports `0.0`. All three reset at the local day
/// boundary.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SessionRangeOutput {
/// High low within the Asia session (local hours `00:00..08:00`).
pub asia: f64,
/// High low within the EU session (local hours `08:00..16:00`).
pub eu: f64,
/// High low within the US session (local hours `16:00..24:00`).
pub us: f64,
}
#[derive(Debug, Clone, Copy)]
struct Extent {
high: f64,
low: f64,
}
impl Extent {
const EMPTY: Self = Self {
high: f64::NEG_INFINITY,
low: f64::INFINITY,
};
fn add(&mut self, candle: Candle) {
if candle.high > self.high {
self.high = candle.high;
}
if candle.low < self.low {
self.low = candle.low;
}
}
fn range(self) -> f64 {
if self.high >= self.low {
self.high - self.low
} else {
0.0
}
}
}
/// Per-session high-low range, keyed off the wall-clock hour of
/// [`Candle::timestamp`](crate::Candle).
///
/// The local day (after shifting by `utc_offset_minutes`) is split into three
/// eight-hour sessions: **Asia** `00:00..08:00`, **EU** `08:00..16:00`, **US**
/// `16:00..24:00`. Each session accumulates its own high / low; the reported
/// range is `high - low`, or `0.0` before that session has seen a bar. All three
/// re-anchor automatically at the day boundary.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SessionRange};
///
/// let hour = 3_600_000;
/// let mut sr = SessionRange::new(0);
/// // 02:00 UTC — Asia session.
/// sr.update(Candle::new(100.0, 104.0, 98.0, 101.0, 1.0, 2 * hour).unwrap());
/// // 10:00 UTC — EU session.
/// let v = sr.update(Candle::new(101.0, 110.0, 100.0, 109.0, 1.0, 10 * hour).unwrap()).unwrap();
/// assert_eq!(v.asia, 6.0);
/// assert_eq!(v.eu, 10.0);
/// assert_eq!(v.us, 0.0);
/// ```
#[derive(Debug, Clone)]
pub struct SessionRange {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
sessions: [Extent; 3],
last: Option<SessionRangeOutput>,
}
impl SessionRange {
/// Construct a Session Range indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
sessions: [Extent::EMPTY; 3],
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent output if at least one bar has been seen.
pub const fn value(&self) -> Option<SessionRangeOutput> {
self.last
}
fn snapshot(&self) -> SessionRangeOutput {
SessionRangeOutput {
asia: self.sessions[0].range(),
eu: self.sessions[1].range(),
us: self.sessions[2].range(),
}
}
}
impl Indicator for SessionRange {
type Input = Candle;
type Output = SessionRangeOutput;
fn update(&mut self, candle: Candle) -> Option<SessionRangeOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key != Some(key) {
self.day_key = Some(key);
self.sessions = [Extent::EMPTY; 3];
}
let session = (civil.hour / 8) as usize; // 0 Asia, 1 EU, 2 US
self.sessions[session].add(candle);
let out = self.snapshot();
self.last = Some(out);
Some(out)
}
fn reset(&mut self) {
self.day_key = None;
self.sessions = [Extent::EMPTY; 3];
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SessionRange"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(high: f64, low: f64, ts: i64) -> Candle {
let mid = f64::midpoint(high, low);
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let sr = SessionRange::new(60);
assert_eq!(sr.utc_offset_minutes(), 60);
assert_eq!(sr.name(), "SessionRange");
assert_eq!(sr.warmup_period(), 1);
assert!(!sr.is_ready());
assert!(sr.value().is_none());
}
#[test]
fn assigns_bars_to_sessions() {
let mut sr = SessionRange::new(0);
let asia = sr.update(c(104.0, 98.0, 2 * HOUR)).unwrap();
assert_relative_eq!(asia.asia, 6.0);
assert_relative_eq!(asia.eu, 0.0);
assert_relative_eq!(asia.us, 0.0);
assert!(sr.is_ready());
let eu = sr.update(c(110.0, 100.0, 10 * HOUR)).unwrap();
assert_relative_eq!(eu.eu, 10.0);
let us = sr.update(c(120.0, 118.0, 20 * HOUR)).unwrap();
assert_relative_eq!(us.us, 2.0);
assert_relative_eq!(us.asia, 6.0);
}
#[test]
fn widens_within_one_session() {
let mut sr = SessionRange::new(0);
sr.update(c(104.0, 98.0, HOUR));
let wider = sr.update(c(106.0, 95.0, 3 * HOUR)).unwrap();
assert_relative_eq!(wider.asia, 11.0);
}
#[test]
fn resets_sessions_on_new_day() {
let mut sr = SessionRange::new(0);
sr.update(c(104.0, 98.0, 2 * HOUR));
sr.update(c(110.0, 100.0, 10 * HOUR));
let next = sr.update(c(101.0, 99.0, (24 + 2) * HOUR)).unwrap();
assert_relative_eq!(next.asia, 2.0);
assert_relative_eq!(next.eu, 0.0);
}
#[test]
fn utc_offset_moves_bar_between_sessions() {
// 07:00 UTC is Asia; shifted +120 min it becomes 09:00 -> EU.
let mut utc = SessionRange::new(0);
let a = utc.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
assert_relative_eq!(a.asia, 6.0);
assert_relative_eq!(a.eu, 0.0);
let mut shifted = SessionRange::new(120);
let e = shifted.update(c(104.0, 98.0, 7 * HOUR)).unwrap();
assert_relative_eq!(e.asia, 0.0);
assert_relative_eq!(e.eu, 6.0);
}
#[test]
fn reset_clears_state() {
let mut sr = SessionRange::new(0);
sr.update(c(104.0, 98.0, 2 * HOUR));
sr.reset();
assert!(!sr.is_ready());
assert!(sr.value().is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| {
c(
100.0 + f64::from(i % 5),
95.0 - f64::from(i % 3),
i64::from(i) * HOUR,
)
})
.collect();
let mut a = SessionRange::new(0);
let mut b = SessionRange::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,199 @@
//! Session VWAP — the volume-weighted average price accumulated since the start
//! of the current calendar-day session, re-anchored automatically each day.
use crate::calendar::civil_from_timestamp;
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume-weighted average price reset at each local day boundary.
///
/// Each bar contributes its typical price `(high + low + close) / 3` weighted by
/// volume. The running VWAP is `Σ(typical · volume) / Σ volume` over the current
/// session; if the session's volume is still zero the indicator falls back to the
/// latest typical price so the output is always finite. The session boundary is
/// the wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
/// `utc_offset_minutes`.
///
/// Where [`crate::RollingVwap`] averages over a fixed bar window and
/// [`crate::AnchoredVwap`] anchors at a caller-chosen bar, Session VWAP anchors
/// at the automatically detected day open.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, SessionVwap};
///
/// let hour = 3_600_000;
/// let mut vwap = SessionVwap::new(0);
/// // typical = 100, volume 10.
/// vwap.update(Candle::new(100.0, 100.0, 100.0, 100.0, 10.0, 0).unwrap());
/// // typical = 110, volume 30 -> VWAP = (100*10 + 110*30) / 40 = 107.5.
/// let v = vwap.update(Candle::new(110.0, 110.0, 110.0, 110.0, 30.0, hour).unwrap()).unwrap();
/// assert!((v - 107.5).abs() < 1e-9);
/// ```
#[derive(Debug, Clone)]
pub struct SessionVwap {
utc_offset_minutes: i32,
day_key: Option<(i64, u32, u32)>,
cum_pv: f64,
cum_volume: f64,
last: Option<f64>,
}
impl SessionVwap {
/// Construct a Session VWAP indicator with the given UTC offset (minutes).
pub const fn new(utc_offset_minutes: i32) -> Self {
Self {
utc_offset_minutes,
day_key: None,
cum_pv: 0.0,
cum_volume: 0.0,
last: None,
}
}
/// Configured UTC offset in minutes.
pub const fn utc_offset_minutes(&self) -> i32 {
self.utc_offset_minutes
}
/// Most recent VWAP if at least one bar has been seen.
pub const fn value(&self) -> Option<f64> {
self.last
}
}
impl Indicator for SessionVwap {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
if self.day_key != Some(key) {
self.day_key = Some(key);
self.cum_pv = 0.0;
self.cum_volume = 0.0;
}
let typical = (candle.high + candle.low + candle.close) / 3.0;
self.cum_pv += typical * candle.volume;
self.cum_volume += candle.volume;
let vwap = if self.cum_volume > 0.0 {
self.cum_pv / self.cum_volume
} else {
typical
};
self.last = Some(vwap);
Some(vwap)
}
fn reset(&mut self) {
self.day_key = None;
self.cum_pv = 0.0;
self.cum_volume = 0.0;
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"SessionVwap"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(price: f64, volume: f64, ts: i64) -> Candle {
Candle::new(price, price, price, price, volume, ts).unwrap()
}
#[test]
fn metadata_and_accessors() {
let vwap = SessionVwap::new(-480);
assert_eq!(vwap.utc_offset_minutes(), -480);
assert_eq!(vwap.name(), "SessionVwap");
assert_eq!(vwap.warmup_period(), 1);
assert!(!vwap.is_ready());
assert!(vwap.value().is_none());
}
#[test]
fn volume_weights_the_average() {
let mut vwap = SessionVwap::new(0);
let first = vwap.update(c(100.0, 10.0, 0)).unwrap();
assert_relative_eq!(first, 100.0);
assert!(vwap.is_ready());
let second = vwap.update(c(110.0, 30.0, HOUR)).unwrap();
assert_relative_eq!(second, 107.5);
}
#[test]
fn zero_volume_session_falls_back_to_typical() {
let mut vwap = SessionVwap::new(0);
let v = vwap.update(c(100.0, 0.0, 0)).unwrap();
assert_relative_eq!(v, 100.0);
let v2 = vwap.update(c(120.0, 0.0, HOUR)).unwrap();
assert_relative_eq!(v2, 120.0);
}
#[test]
fn re_anchors_on_new_day() {
let mut vwap = SessionVwap::new(0);
vwap.update(c(100.0, 10.0, 0));
vwap.update(c(110.0, 30.0, HOUR));
// New day: VWAP restarts from the first bar of day 2.
let next = vwap.update(c(200.0, 5.0, 24 * HOUR)).unwrap();
assert_relative_eq!(next, 200.0);
}
#[test]
fn typical_price_uses_high_low_close() {
let mut vwap = SessionVwap::new(0);
// typical = (120 + 90 + 102) / 3 = 104.
let candle = Candle::new(100.0, 120.0, 90.0, 102.0, 10.0, 0).unwrap();
let v = vwap.update(candle).unwrap();
assert_relative_eq!(v, 104.0);
}
#[test]
fn reset_clears_state() {
let mut vwap = SessionVwap::new(0);
vwap.update(c(100.0, 10.0, 0));
vwap.reset();
assert!(!vwap.is_ready());
assert!(vwap.value().is_none());
let after = vwap.update(c(50.0, 1.0, HOUR)).unwrap();
assert_relative_eq!(after, 50.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..30)
.map(|i| {
c(
100.0 + f64::from(i),
1.0 + f64::from(i % 4),
i64::from(i) * HOUR,
)
})
.collect();
let mut a = SessionVwap::new(0);
let mut b = SessionVwap::new(0);
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,226 @@
//! Time-of-Day Return Profile — the mean bar return in each intraday time bucket.
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Time-of-Day Return Profile output: the per-bucket mean return.
///
/// `bins[i]` is the mean simple return of all bars whose local time-of-day fell
/// in bucket `i`, where bucket `i` spans the minutes
/// `[i * 1440 / bins.len(), (i + 1) * 1440 / bins.len())`. Empty buckets read
/// `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct TimeOfDayReturnProfileOutput {
/// Per-bucket mean return, earliest bucket first. Length equals `buckets`.
pub bins: Vec<f64>,
}
/// Mean bar return bucketed by local time of day.
///
/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`) is divided into `buckets` equal slices. Each
/// bar's simple return `close / previous_close - 1` is accumulated into the bucket
/// of its time-of-day, and the profile reports the running mean per bucket. The
/// first bar produces no output (no return yet).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TimeOfDayReturnProfile};
///
/// let hour = 3_600_000;
/// let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, hour).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 24);
/// ```
#[derive(Debug, Clone)]
pub struct TimeOfDayReturnProfile {
buckets: usize,
utc_offset_minutes: i32,
prev_close: Option<f64>,
sum: Vec<f64>,
count: Vec<u64>,
last: Option<TimeOfDayReturnProfileOutput>,
}
impl TimeOfDayReturnProfile {
/// Construct a Time-of-Day Return Profile with `buckets` intraday slices.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `buckets == 0`.
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
if buckets == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
buckets,
utc_offset_minutes,
prev_close: None,
sum: vec![0.0; buckets],
count: vec![0; buckets],
last: None,
})
}
/// Configured `(buckets, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.buckets, self.utc_offset_minutes)
}
/// Most recent profile if at least one return has been recorded.
pub fn value(&self) -> Option<&TimeOfDayReturnProfileOutput> {
self.last.as_ref()
}
fn bucket_of(&self, minute_of_day: u32) -> usize {
let raw = (minute_of_day as usize * self.buckets) / 1440;
raw.min(self.buckets - 1)
}
fn snapshot(&self) -> TimeOfDayReturnProfileOutput {
let bins = self
.sum
.iter()
.zip(&self.count)
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
.collect();
TimeOfDayReturnProfileOutput { bins }
}
}
impl Indicator for TimeOfDayReturnProfile {
type Input = Candle;
type Output = TimeOfDayReturnProfileOutput;
fn update(&mut self, candle: Candle) -> Option<TimeOfDayReturnProfileOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let result = if let Some(prev) = self.prev_close {
let ret = if prev == 0.0 {
0.0
} else {
candle.close / prev - 1.0
};
let bucket = self.bucket_of(civil.minute_of_day());
self.sum[bucket] += ret;
self.count[bucket] += 1;
let out = self.snapshot();
self.last = Some(out.clone());
Some(out)
} else {
None
};
self.prev_close = Some(candle.close);
result
}
fn reset(&mut self) {
self.prev_close = None;
self.sum.iter_mut().for_each(|x| *x = 0.0);
self.count.iter_mut().for_each(|x| *x = 0);
self.last = None;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"TimeOfDayReturnProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn rejects_zero_buckets() {
assert!(matches!(
TimeOfDayReturnProfile::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let prof = TimeOfDayReturnProfile::new(24, -300).unwrap();
assert_eq!(prof.params(), (24, -300));
assert_eq!(prof.name(), "TimeOfDayReturnProfile");
assert_eq!(prof.warmup_period(), 2);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn buckets_by_hour_and_means_returns() {
let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
assert!(prof.update(c(100.0, 0)).is_none()); // 00:00, no return
// 01:00 return +0.01 -> bucket 1.
let out = prof.update(c(101.0, HOUR)).unwrap();
assert_eq!(out.bins.len(), 24);
assert_relative_eq!(out.bins[1], 0.01);
assert_relative_eq!(out.bins[0], 0.0);
assert!(prof.is_ready());
// 01:00 next day, return -> averages into bucket 1.
let out = prof.update(c(102.01, 25 * HOUR)).unwrap();
// two returns in bucket 1: 0.01 and 0.01 -> mean 0.01.
assert_relative_eq!(out.bins[1], 0.01);
}
#[test]
fn last_bucket_clamped_for_end_of_day() {
let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
prof.update(c(100.0, 23 * HOUR));
// 23:59 -> minute 1439 -> bucket min(23, 23) = 23.
let out = prof.update(c(110.0, 23 * HOUR + 59 * 60_000)).unwrap();
assert_relative_eq!(out.bins[23], 0.10);
}
#[test]
fn zero_prev_close_uses_zero_return() {
let mut prof = TimeOfDayReturnProfile::new(4, 0).unwrap();
prof.update(c(0.0, 0));
let out = prof.update(c(5.0, HOUR)).unwrap();
assert_relative_eq!(out.bins[0], 0.0);
}
#[test]
fn reset_clears_state() {
let mut prof = TimeOfDayReturnProfile::new(24, 0).unwrap();
prof.update(c(100.0, 0));
prof.update(c(101.0, HOUR));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
assert!(prof.update(c(100.0, 2 * HOUR)).is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 7), i64::from(i) * HOUR))
.collect();
let mut a = TimeOfDayReturnProfile::new(12, 0).unwrap();
let mut b = TimeOfDayReturnProfile::new(12, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,275 @@
//! Turn-of-Month Effect — the mean daily return of sessions that fall inside the
//! turn-of-month window (the last `n_last` and first `n_first` days of a month).
use crate::calendar::{civil_from_timestamp, days_in_month};
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Whether a day-of-month lies in the turn-of-month window.
///
/// The window is the first `n_first` calendar days plus the last `n_last` days of
/// the month (`days_in_month - n_last < dom`).
fn in_turn_window(dom: u32, dim: u32, n_first: u32, n_last: u32) -> bool {
dom <= n_first || dom > dim.saturating_sub(n_last)
}
/// Turn-of-Month effect: the running mean of daily close-to-close returns for the
/// sessions that fall in the turn-of-month window.
///
/// Each completed session (the wall-clock day of
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`)
/// contributes its return `close / previous_close - 1`. Only sessions whose
/// day-of-month is within the first `n_first` or last `n_last` days of their month
/// are averaged; the rest are ignored. The classic effect uses `n_first = 3`,
/// `n_last = 1`.
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, TurnOfMonth};
///
/// let day = 24 * 3_600_000;
/// // 2021-01-29 .. 02-02 — all turn-of-month days with n_first=3, n_last=1.
/// let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
/// let start = 1_611_878_400_000; // 2021-01-29 00:00 UTC
/// let mut last = None;
/// for (i, close) in [100.0, 101.0, 102.0, 103.0].iter().enumerate() {
/// let ts = start + i as i64 * day;
/// last = tom.update(Candle::new(*close, *close, *close, *close, 1.0, ts).unwrap());
/// }
/// assert!(last.is_some());
/// ```
#[derive(Debug, Clone)]
pub struct TurnOfMonth {
n_first: u32,
n_last: u32,
utc_offset_minutes: i32,
day: Option<(i64, u32, u32)>,
cur_close: f64,
prev_day_close: Option<f64>,
sum: f64,
count: u64,
}
impl TurnOfMonth {
/// Construct a Turn-of-Month indicator.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if both `n_first` and `n_last` are zero (the
/// window would never include a day).
pub fn new(n_first: u32, n_last: u32, utc_offset_minutes: i32) -> Result<Self> {
if n_first == 0 && n_last == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
n_first,
n_last,
utc_offset_minutes,
day: None,
cur_close: 0.0,
prev_day_close: None,
sum: 0.0,
count: 0,
})
}
/// Classic turn-of-month window: first 3 and last 1 day of the month.
pub fn classic() -> Self {
Self::new(3, 1, 0).expect("classic turn-of-month window is valid")
}
/// Configured `(n_first, n_last, utc_offset_minutes)`.
pub const fn params(&self) -> (u32, u32, i32) {
(self.n_first, self.n_last, self.utc_offset_minutes)
}
/// Most recent mean turn-of-month return if any in-window day has completed.
pub fn value(&self) -> Option<f64> {
if self.count == 0 {
None
} else {
Some(self.sum / self.count as f64)
}
}
/// Settle the just-finished day `(year, month, dom)` whose last close is
/// `self.cur_close`, then start `next_key`.
fn roll_into(
&mut self,
year: i64,
month: u32,
dom: u32,
next_key: (i64, u32, u32),
close: f64,
) {
if let Some(prev) = self.prev_day_close {
let ret = if prev == 0.0 {
0.0
} else {
self.cur_close / prev - 1.0
};
if in_turn_window(dom, days_in_month(year, month), self.n_first, self.n_last) {
self.sum += ret;
self.count += 1;
}
}
self.prev_day_close = Some(self.cur_close);
self.day = Some(next_key);
self.cur_close = close;
}
}
impl Indicator for TurnOfMonth {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let key = (civil.year, civil.month, civil.day);
match self.day {
Some(prev) if prev == key => {
self.cur_close = candle.close;
}
Some((year, month, dom)) => {
self.roll_into(year, month, dom, key, candle.close);
}
None => {
self.day = Some(key);
self.cur_close = candle.close;
}
}
self.value()
}
fn reset(&mut self) {
self.day = None;
self.cur_close = 0.0;
self.prev_day_close = None;
self.sum = 0.0;
self.count = 0;
}
fn warmup_period(&self) -> usize {
2
}
fn is_ready(&self) -> bool {
self.count > 0
}
fn name(&self) -> &'static str {
"TurnOfMonth"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const DAY: i64 = 24 * 3_600_000;
// 2021-01-28 00:00 UTC.
const JAN28_2021: i64 = 1_611_792_000_000;
fn c(close: f64, ts: i64) -> Candle {
Candle::new(close, close, close, close, 1.0, ts).unwrap()
}
#[test]
fn window_predicate_branches() {
// First-days branch.
assert!(in_turn_window(1, 31, 3, 1));
assert!(in_turn_window(3, 31, 3, 1));
assert!(!in_turn_window(4, 31, 3, 1));
// Last-days branch.
assert!(in_turn_window(31, 31, 3, 1));
assert!(!in_turn_window(30, 31, 3, 1));
// Saturating subtraction when n_last exceeds the month length.
assert!(in_turn_window(1, 28, 0, 40));
}
#[test]
fn rejects_empty_window() {
assert!(matches!(TurnOfMonth::new(0, 0, 0), Err(Error::PeriodZero)));
}
#[test]
fn metadata_and_accessors() {
let tom = TurnOfMonth::classic();
assert_eq!(tom.params(), (3, 1, 0));
assert_eq!(tom.name(), "TurnOfMonth");
assert_eq!(tom.warmup_period(), 2);
assert!(!tom.is_ready());
assert!(tom.value().is_none());
}
#[test]
fn averages_in_window_returns_only() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
// 2021-01-28 (out of window, no prior close): close 100.
assert!(tom.update(c(100.0, JAN28_2021)).is_none());
// 2021-01-29 (out of window: dom 29, dim 31 -> 29 <= 30): return ignored.
assert!(tom.update(c(110.0, JAN28_2021 + DAY)).is_none());
// 2021-01-30 (out of window): completes 01-29; still none.
assert!(tom.update(c(120.0, JAN28_2021 + 2 * DAY)).is_none());
// 2021-01-31 (last day, in window): completes 01-30 (out). Still none.
assert!(tom.update(c(121.0, JAN28_2021 + 3 * DAY)).is_none());
// 2021-02-01 (first day, in window): completes 01-31 (in window).
// return = 121 / 120 - 1.
let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
assert_relative_eq!(v, 121.0 / 120.0 - 1.0);
assert!(tom.is_ready());
}
#[test]
fn zero_prev_close_contributes_zero() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
// 2021-01-30 closes at 0 — becomes the prior close for 01-31.
tom.update(c(0.0, JAN28_2021 + 2 * DAY));
// 2021-01-31 (last day, in window): finalizes 01-30 with no prior -> no
// contribution, but records prev_day_close = 0.
tom.update(c(5.0, JAN28_2021 + 3 * DAY));
// 2021-02-01 (in window): finalizes 01-31 with prev_close 0 -> ret 0.
let v = tom.update(c(50.0, JAN28_2021 + 4 * DAY)).unwrap();
assert_relative_eq!(v, 0.0);
}
#[test]
fn same_day_bars_use_latest_close() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
// 2021-01-30 closes at 100 (prior day, sets prev_day_close).
tom.update(c(100.0, JAN28_2021 + 2 * DAY));
// 2021-01-31 two bars on the same day; the later close (120) wins.
tom.update(c(110.0, JAN28_2021 + 3 * DAY));
tom.update(c(120.0, JAN28_2021 + 3 * DAY + 3_600_000));
// 2021-02-01 (in window) finalizes 01-31: return = 120 / 100 - 1 = 0.20.
let v = tom.update(c(130.0, JAN28_2021 + 4 * DAY)).unwrap();
assert_relative_eq!(v, 0.20);
}
#[test]
fn reset_clears_state() {
let mut tom = TurnOfMonth::new(3, 1, 0).unwrap();
tom.update(c(121.0, JAN28_2021 + 3 * DAY));
tom.update(c(130.0, JAN28_2021 + 4 * DAY));
tom.reset();
assert!(!tom.is_ready());
assert!(tom.value().is_none());
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| c(100.0 + f64::from(i), JAN28_2021 + i64::from(i) * DAY))
.collect();
let mut a = TurnOfMonth::new(3, 2, 0).unwrap();
let mut b = TurnOfMonth::new(3, 2, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
@@ -0,0 +1,198 @@
//! Volume-by-Time Profile — the mean traded volume in each intraday bucket.
use crate::calendar::civil_from_timestamp;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
/// Volume-by-Time Profile output: the per-bucket mean volume.
///
/// `bins[i]` is the mean volume of all bars whose local time-of-day fell in
/// bucket `i`. Empty buckets read `0.0`.
#[derive(Debug, Clone, PartialEq)]
pub struct VolumeByTimeProfileOutput {
/// Per-bucket mean volume, earliest bucket first. Length equals `buckets`.
pub bins: Vec<f64>,
}
/// Mean traded volume bucketed by local time of day.
///
/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each
/// bar's volume is accumulated into the bucket of its time-of-day, and the
/// profile reports the running mean volume per bucket. Unlike the return
/// profiles, the first bar already produces output (volume needs no prior bar).
///
/// # Example
///
/// ```
/// use wickra_core::{Candle, Indicator, VolumeByTimeProfile};
///
/// let hour = 3_600_000;
/// let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
/// let out = prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 500.0, hour).unwrap()).unwrap();
/// assert_eq!(out.bins.len(), 24);
/// assert_eq!(out.bins[1], 500.0);
/// ```
#[derive(Debug, Clone)]
pub struct VolumeByTimeProfile {
buckets: usize,
utc_offset_minutes: i32,
sum: Vec<f64>,
count: Vec<u64>,
last: Option<VolumeByTimeProfileOutput>,
}
impl VolumeByTimeProfile {
/// Construct a Volume-by-Time Profile with `buckets` intraday slices.
///
/// # Errors
///
/// Returns [`Error::PeriodZero`] if `buckets == 0`.
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
if buckets == 0 {
return Err(Error::PeriodZero);
}
Ok(Self {
buckets,
utc_offset_minutes,
sum: vec![0.0; buckets],
count: vec![0; buckets],
last: None,
})
}
/// Configured `(buckets, utc_offset_minutes)`.
pub const fn params(&self) -> (usize, i32) {
(self.buckets, self.utc_offset_minutes)
}
/// Most recent profile if at least one bar has been seen.
pub fn value(&self) -> Option<&VolumeByTimeProfileOutput> {
self.last.as_ref()
}
fn bucket_of(&self, minute_of_day: u32) -> usize {
let raw = (minute_of_day as usize * self.buckets) / 1440;
raw.min(self.buckets - 1)
}
fn snapshot(&self) -> VolumeByTimeProfileOutput {
let bins = self
.sum
.iter()
.zip(&self.count)
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
.collect();
VolumeByTimeProfileOutput { bins }
}
}
impl Indicator for VolumeByTimeProfile {
type Input = Candle;
type Output = VolumeByTimeProfileOutput;
fn update(&mut self, candle: Candle) -> Option<VolumeByTimeProfileOutput> {
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
let bucket = self.bucket_of(civil.minute_of_day());
self.sum[bucket] += candle.volume;
self.count[bucket] += 1;
let out = self.snapshot();
self.last = Some(out.clone());
Some(out)
}
fn reset(&mut self) {
self.sum.iter_mut().for_each(|x| *x = 0.0);
self.count.iter_mut().for_each(|x| *x = 0);
self.last = None;
}
fn warmup_period(&self) -> usize {
1
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"VolumeByTimeProfile"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
use approx::assert_relative_eq;
const HOUR: i64 = 3_600_000;
fn c(volume: f64, ts: i64) -> Candle {
Candle::new(100.0, 100.0, 100.0, 100.0, volume, ts).unwrap()
}
#[test]
fn rejects_zero_buckets() {
assert!(matches!(
VolumeByTimeProfile::new(0, 0),
Err(Error::PeriodZero)
));
}
#[test]
fn metadata_and_accessors() {
let prof = VolumeByTimeProfile::new(24, -60).unwrap();
assert_eq!(prof.params(), (24, -60));
assert_eq!(prof.name(), "VolumeByTimeProfile");
assert_eq!(prof.warmup_period(), 1);
assert!(!prof.is_ready());
assert!(prof.value().is_none());
}
#[test]
fn emits_from_first_bar_and_means_volume() {
let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
let out = prof.update(c(500.0, HOUR)).unwrap(); // 01:00 -> bucket 1
assert_eq!(out.bins.len(), 24);
assert_relative_eq!(out.bins[1], 500.0);
assert_relative_eq!(out.bins[0], 0.0);
assert!(prof.is_ready());
// Next day 01:00, volume 700 -> mean (500 + 700) / 2 = 600.
let out = prof.update(c(700.0, 25 * HOUR)).unwrap();
assert_relative_eq!(out.bins[1], 600.0);
}
#[test]
fn last_bucket_clamped() {
let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
// 23:59 -> minute 1439 -> bucket 23.
let out = prof.update(c(300.0, 23 * HOUR + 59 * 60_000)).unwrap();
assert_relative_eq!(out.bins[23], 300.0);
}
#[test]
fn reset_clears_state() {
let mut prof = VolumeByTimeProfile::new(24, 0).unwrap();
prof.update(c(500.0, HOUR));
prof.reset();
assert!(!prof.is_ready());
assert!(prof.value().is_none());
let out = prof.update(c(100.0, 2 * HOUR)).unwrap();
assert_relative_eq!(out.bins[2], 100.0);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..50)
.map(|i| c(100.0 + f64::from(i % 8), i64::from(i) * HOUR))
.collect();
let mut a = VolumeByTimeProfile::new(12, 0).unwrap();
let mut b = VolumeByTimeProfile::new(12, 0).unwrap();
assert_eq!(
a.batch(&candles),
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
);
}
}
+51 -45
View File
@@ -42,6 +42,7 @@
// builds — library code is still linted for genuinely large stack arrays.
#![cfg_attr(test, allow(clippy::large_stack_arrays))]
mod calendar;
mod cross_section;
mod derivatives;
mod error;
@@ -59,17 +60,18 @@ pub use indicators::{
AcceleratorOscillator, AdOscillator, AdVolumeLine, AdaptiveCycle, Adl, AdvanceBlock,
AdvanceDecline, AdvanceDeclineRatio, Adx, AdxOutput, Adxr, Alligator, AlligatorOutput, Alma,
Alpha, AnchoredRsi, AnchoredVwap, Apo, Aroon, AroonOscillator, AroonOutput, Atr, AtrBands,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDrawdown, AvgPrice, AwesomeOscillator,
AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta, BetaNeutralSpread, BollingerBands,
BollingerBandwidth, BollingerOutput, BreadthThrust, Breakaway, BullishPercentIndex,
CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput, Cci, CenterOfGravity, Cfo,
ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput,
ChandelierExit, ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput,
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, Counterattack,
CumulativeVolumeDelta, CumulativeVolumeIndex, CyberneticCycle, Decycler, DecyclerOscillator,
Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd,
Doji, DojiStar, Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
AtrBandsOutput, AtrTrailingStop, Autocorrelation, AverageDailyRange, AverageDrawdown, AvgPrice,
AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BeltHold, Beta,
BetaNeutralSpread, BollingerBands, BollingerBandwidth, BollingerOutput, BreadthThrust,
Breakaway, BullishPercentIndex, CalendarSpread, CalmarRatio, Camarilla, CamarillaPivotsOutput,
Cci, CenterOfGravity, Cfo, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility,
ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit, ChandelierExitOutput, ChoppinessIndex,
ClassicPivots, ClassicPivotsOutput, ClosingMarubozu, Cmo, CoefficientOfVariation,
Cointegration, CointegrationOutput, ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi,
Coppock, Counterattack, CumulativeVolumeDelta, CumulativeVolumeIndex, CyberneticCycle,
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, DistanceSsd, Doji, DojiStar,
Donchian, DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger,
DoubleBollingerOutput, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, Dx,
EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
EmpiricalModeDecomposition, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, Fama,
@@ -81,44 +83,48 @@ pub use indicators::{
HistoricalVolatility, Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode,
HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows,
InNeck, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, KagiBars,
KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput,
Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom,
LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle,
LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope, LinearRegression,
LiquidationFeatures, LiquidationFeaturesOutput, LongLeggedDoji, LongLine, LongShortRatio,
MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdIndicator, MacdOutput, Mama, MamaOutput,
MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown,
McClellanOscillator, McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation,
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar,
MorningEveningStar, Natr, NewHighsNewLows, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio,
OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex,
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Pmo,
PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared, RealizedSpread,
RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop,
RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility,
RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter, Rsi, Rvi, RviVolatility,
Rwi, RwiOutput, SarExt, SeparatingLines, SharpeRatio, ShootingStar, ShortLine, SignedVolume,
SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop,
SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
StepTrailingStop, StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother,
SuperTrend, SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown,
TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
InstantaneousTrendline, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
InverseFisherTransform, InvertedHammer, Jma, KagiBars, KalmanHedgeRatio,
KalmanHedgeRatioOutput, Kama, KellyCriterion, Keltner, KeltnerOutput, Kicking, KickingByLength,
Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation,
LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel, LinRegChannelOutput,
LinRegIntercept, LinRegSlope, LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput,
LongLeggedDoji, LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix,
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice,
MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, NewHighsNewLows, Nvi,
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
OrderBookImbalanceTopN, OuHalfLife, OvernightGap, OvernightIntradayReturn,
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
PlusDi, PlusDm, Pmo, PointAndFigureBars, Ppo, ProfitFactor, Psar, Pvi, QuotedSpread, RSquared,
RealizedSpread, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars,
RenkoTrailingStop, RickshawMan, RisingThreeMethods, Roc, Rocp, Rocr, Rocr100,
RogersSatchellVolatility, RollingCorrelation, RollingCovariance, RollingVwap, RoofingFilter,
Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeasonalZScore, SeparatingLines,
SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap,
SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma,
SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadBollingerBands,
SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError, StandardErrorBands,
StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev, StepTrailingStop,
StickSandwich, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
SuperTrendOutput, TakerBuySellRatio, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker,
TdDifferential, TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection,
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
TdSequentialOutput, TdSetup, Tema, TermStructureBasis, ThreeInside, ThreeLineStrike,
ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii, TpoProfile,
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv,
TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TickIndex, Tii,
TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput, TpoProfile, TpoProfileOutput,
TradeImbalance, TreynorRatio, Trima, Trin, Trix, TrueRange, Tsf, Tsi, Tsv, TtmSqueeze,
TtmSqueezeOutput, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
UniqueThreeRiver, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea,
ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio, VerticalHorizontalFilter, Vidya,
VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeProfileOutput, Vortex,
VortexOutput, Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend,
WaveTrendOutput, WeightedClose, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, Wma,
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend,
VolumeProfile, VolumeProfileOutput, Vortex, VortexOutput, Vwap, VwapStdDevBands,
VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals,
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
};
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
// line so the indicator-count tooling (which scans the braced block above and
+1 -1
View File
@@ -8,7 +8,7 @@ That includes:
[Python](https://docs.wickra.org/Quickstart-Python),
[Node](https://docs.wickra.org/Quickstart-Node), and
[WASM](https://docs.wickra.org/Quickstart-WASM).
- A per-indicator deep dive for every one of the **339 indicators** across
- A per-indicator deep dive for every one of the **351 indicators** across
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
+7 -7
View File
@@ -17,7 +17,7 @@
},
"../../bindings/node": {
"name": "wickra",
"version": "0.5.0",
"version": "0.5.1",
"license": "MIT OR Apache-2.0",
"devDependencies": {
"@napi-rs/cli": "^2.18.0"
@@ -26,12 +26,12 @@
"node": ">= 18"
},
"optionalDependencies": {
"wickra-darwin-arm64": "0.5.0",
"wickra-darwin-x64": "0.5.0",
"wickra-linux-arm64-gnu": "0.5.0",
"wickra-linux-x64-gnu": "0.5.0",
"wickra-win32-arm64-msvc": "0.5.0",
"wickra-win32-x64-msvc": "0.5.0"
"wickra-darwin-arm64": "0.5.1",
"wickra-darwin-x64": "0.5.1",
"wickra-linux-arm64-gnu": "0.5.1",
"wickra-linux-x64-gnu": "0.5.1",
"wickra-win32-arm64-msvc": "0.5.1",
"wickra-win32-x64-msvc": "0.5.1"
}
},
"node_modules/wickra": {
+26 -3
View File
@@ -22,9 +22,7 @@
//! WeightedClose.
use libfuzzer_sys::fuzz_target;
use wickra_core::{
AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeparatingLines, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TpoProfile, TrueRange, Tsv, TtmSqueeze, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag
};
use wickra_core::{AbandonedBaby, AccelerationBands, AcceleratorOscillator, AdOscillator, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, Aroon, AroonOscillator, Atr, AtrBands, AtrTrailingStop, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, BatchExt, BeltHold, Breakaway, Camarilla, Candle, Cci, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, ClosingMarubozu, ConcealingBabySwallow, Counterattack, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DownsideGapThreeMethods, DragonflyDoji, Dx, EaseOfMovement, Engulfing, EveningDojiStar, Evwma, FallingThreeMethods, FibonacciPivots, ForceIndex, FractalChaosBands, GapSideBySideWhite, GarmanKlassVolatility, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HiLoActivator, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayVolatilityProfile, InvertedHammer, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, MorningDojiStar, MorningEveningStar, Natr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PlusDi, PlusDm, Psar, Pvi, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, ShootingStar, ShortLine, Smi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, SuperTrend, Takuri, TasukiGap, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdOpen, TdPressure, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, ThreeInside, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeOfDayReturnProfile, TpoProfile, TrueRange, Tsv, TtmSqueeze, TurnOfMonth, Tweezer, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, WeightedClose, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
@@ -349,4 +347,29 @@ fuzz_target!(|data: Vec<f64>| {
drive(TwoCrows::new, &candles);
drive(UpsideGapTwoCrows::new, &candles);
drive(IdenticalThreeCrows::new, &candles);
// --- Seasonality & Session ---
drive(|| VolumeByTimeProfile::new(24, 0).unwrap(), &candles);
drive(|| IntradayVolatilityProfile::new(24, 0).unwrap(), &candles);
drive(|| DayOfWeekProfile::new(0), &candles);
drive(|| TimeOfDayReturnProfile::new(24, 0).unwrap(), &candles);
drive(|| SeasonalZScore::new(0), &candles);
drive(|| TurnOfMonth::new(3, 1, 0).unwrap(), &candles);
drive(|| OvernightIntradayReturn::new(0), &candles);
drive(|| OvernightGap::new(0), &candles);
drive(|| AverageDailyRange::new(14, 0).unwrap(), &candles);
drive(|| SessionRange::new(0), &candles);
drive(|| SessionHighLow::new(0), &candles);
drive(|| SessionVwap::new(0), &candles);
});