Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c44f625e69 | |||
| 46dc8f5a00 | |||
| a3a1ae4dba | |||
| 53941b7b07 | |||
| 72ec65bbde |
@@ -41,17 +41,17 @@ name: Sync indicator count
|
||||
# `RollingVwap`, so the mod-count under-reports by one. lib.rs is the
|
||||
# single source of truth for what the bindings reach.
|
||||
#
|
||||
# Design: keep README in sync *before* a PR is merged, by pushing a
|
||||
# fix-up commit to the PR head branch. After squash-merge into main
|
||||
# the bot commit is folded into the single signed merge commit, so
|
||||
# main's history never shows an unsigned "sync indicator count" entry.
|
||||
# Design: on PRs this workflow is a READ-ONLY check. The indicator wiring
|
||||
# (ScriptHelpers/_common.py wire_readme_counter) bumps both README.md and
|
||||
# docs/README.md inside the author's code commit, so the counter is already
|
||||
# correct by the time CI runs. If it is not, the check below fails loud and
|
||||
# asks the author to re-run the wiring — it never pushes a fix-up commit.
|
||||
#
|
||||
# The push to PR head uses the default `GITHUB_TOKEN`, whose pushes
|
||||
# explicitly do NOT trigger downstream workflows (anti-recursion
|
||||
# policy). So a counter fix-up does not re-trigger ci.yml on the PR
|
||||
# — it does, however, re-trigger sync-about.yml on the next PR
|
||||
# `synchronize` event, which is what we want (a no-op if the counter
|
||||
# is now correct).
|
||||
# (An earlier version pushed a GITHUB_TOKEN "sync indicator count" commit to
|
||||
# the PR head. Because GITHUB_TOKEN pushes trigger no workflows, that commit
|
||||
# moved the PR head onto a commit with no CI run, which hid the Codecov patch
|
||||
# status — keyed to the PR head sha — from the PR. Keeping the counter in the
|
||||
# code commit avoids that entirely.)
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -73,53 +73,24 @@ permissions:
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
# The only GITHUB_TOKEN write in this workflow: pushing the counter fix-up
|
||||
# commit onto a same-repo PR head branch (git push origin HEAD:<ref>).
|
||||
# This workflow never writes to wickra-lib/wickra with GITHUB_TOKEN: the PR
|
||||
# flow is a read-only check, and the main/tag flow writes only to other
|
||||
# repos (About metadata, docs, webpage, wiki, org) through the fine-grained
|
||||
# ABOUT_SYNC_TOKEN PAT. So GITHUB_TOKEN stays read-only (OpenSSF Scorecard:
|
||||
# Token-Permissions).
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
# On PRs from forks the head ref lives in another repo; pushing
|
||||
# back to it from this workflow is blocked by GitHub. We still
|
||||
# want the PR to surface the missing counter, so the check below
|
||||
# falls back to a hard failure when push isn't possible.
|
||||
- name: Determine if push to PR head is possible
|
||||
id: ctx
|
||||
# Untrusted PR contexts (head.ref / head.repo.full_name are attacker
|
||||
# controlled on fork PRs) are passed through the environment, never
|
||||
# interpolated straight into the shell, so a crafted branch name cannot
|
||||
# inject commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
if [ "$HEAD_REPO" = "$BASE_REPO" ]; then
|
||||
echo "can_push=true" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=$HEAD_REF" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "can_push=false" >> "$GITHUB_OUTPUT"
|
||||
echo "head_ref=" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# On PRs we check out the *head* commit (not the merge ref) so
|
||||
# any fix-up commit we make goes onto the PR branch itself. On
|
||||
# push events we check out the default ref. fetch-depth: 0 lets
|
||||
# us push back without "shallow update not allowed".
|
||||
# On PRs we check out the PR *head* commit (the author's code, not the
|
||||
# merge ref) so the counter check validates exactly what will land. On
|
||||
# push events we check out the default ref. No push is made, so a shallow
|
||||
# checkout is enough.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-depth: 1
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref }}
|
||||
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
# Default GITHUB_TOKEN is fine for the same-repo PR-branch
|
||||
# push; the About / Wiki steps re-authenticate with the PAT
|
||||
# below where needed.
|
||||
|
||||
- name: Count indicators
|
||||
id: count
|
||||
@@ -139,69 +110,33 @@ jobs:
|
||||
|
||||
# ----- PR flow ---------------------------------------------------
|
||||
|
||||
- name: Check README counter (PR)
|
||||
- name: Check README counter (PR, read-only)
|
||||
if: github.event_name == 'pull_request'
|
||||
id: pr_check
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
if grep -qE "^${n} streaming-first indicators" README.md \
|
||||
&& grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
|
||||
echo "matches=true" >> "$GITHUB_OUTPUT"
|
||||
echo "README + docs/README counter already at ${n}; nothing to do."
|
||||
else
|
||||
echo "matches=false" >> "$GITHUB_OUTPUT"
|
||||
echo "README/docs counter does not match ${n}; will fix up."
|
||||
ok=true
|
||||
if ! grep -qE "^${n} streaming-first indicators" README.md; then
|
||||
echo "::error::README.md does not say '${n} streaming-first indicators' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps README.md), then push again."
|
||||
ok=false
|
||||
fi
|
||||
|
||||
- name: Fix counter on fork PR head (read-only, fail loud)
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'false'
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
echo "::error::README.md / docs/README.md say a different indicator count than lib.rs (${n}). This PR is from a fork, so the workflow cannot push the fix; please set README.md to '${n} streaming-first indicators' and docs/README.md to '**${n} indicators**', then push again."
|
||||
exit 1
|
||||
|
||||
- name: Patch README on PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_check.outputs.matches == 'false' && steps.ctx.outputs.can_push == 'true'
|
||||
id: pr_patch
|
||||
run: |
|
||||
n="${{ steps.count.outputs.count }}"
|
||||
# docs/README.md carries the count in its docs.wickra.org pointer prose
|
||||
# ("**N indicators**"); keep it in sync with README's prose count.
|
||||
sed -i -E "s/[0-9]+ (streaming-first )?indicators/${n} \1indicators/g" README.md docs/README.md
|
||||
# Bump the banner cache-buster so GitHub's Camo proxy refetches the org
|
||||
# profile image (regenerated with the new count by .github/banner.yml)
|
||||
# instead of serving a stale cached copy. (README only — docs has no banner.)
|
||||
sed -i -E "s|(wickra-banner\.webp\?v=)[0-9]+|\1${n}|" README.md
|
||||
if git diff --quiet; then
|
||||
echo "No README changes after sed (counter regex did not match anything); skipping push."
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
if ! grep -qE "\*\*${n} indicators\*\*" docs/README.md; then
|
||||
echo "::error::docs/README.md does not say '**${n} indicators**' — lib.rs exports ${n}. Re-run the indicator wiring (it bumps docs/README.md), then push again."
|
||||
ok=false
|
||||
fi
|
||||
if [ "$ok" = "true" ]; then
|
||||
echo "README.md + docs/README.md counter already at ${n}; nothing to do."
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Commit & push counter fix to PR head
|
||||
if: github.event_name == 'pull_request' && steps.pr_patch.outputs.changed == 'true'
|
||||
# head_ref still carries the (untrusted) PR branch name forwarded by the
|
||||
# ctx step; pass it through the environment so the push refspec cannot be
|
||||
# used to inject shell commands (OpenSSF Scorecard: Dangerous-Workflow).
|
||||
env:
|
||||
COUNT: ${{ steps.count.outputs.count }}
|
||||
HEAD_REF: ${{ steps.ctx.outputs.head_ref }}
|
||||
run: |
|
||||
git config user.name "wickra-bot"
|
||||
git config user.email "wickra-bot@users.noreply.github.com"
|
||||
git add README.md docs/README.md
|
||||
git commit -m "chore: sync indicator count to ${COUNT}"
|
||||
git push origin "HEAD:${HEAD_REF}"
|
||||
|
||||
# ----- main / tag flow ------------------------------------------
|
||||
#
|
||||
# After a PR squash-merges, this workflow runs again on the push
|
||||
# to main. README is already correct (it was fixed on the PR
|
||||
# branch before the merge); the only outward syncs left are the
|
||||
# GitHub About description (repo metadata, not a commit) and the
|
||||
# wiki repo (separate repo, no main history pollution). README is
|
||||
# not touched on main any more.
|
||||
# After a PR squash-merges, this workflow runs again on the push to main.
|
||||
# README.md / docs/README.md are already correct (the indicator wiring
|
||||
# bumped them in the merged code commit); the only outward syncs left are
|
||||
# the GitHub About description (repo metadata, not a commit) and the docs /
|
||||
# webpage / wiki / org repos (separate repos, no main history pollution).
|
||||
# The wickra repo's own README is not touched on main any more.
|
||||
|
||||
- name: Update GitHub About (description + homepage)
|
||||
if: github.event_name != 'pull_request'
|
||||
|
||||
+25
-1
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.7] - 2026-06-03
|
||||
|
||||
### Added
|
||||
- **Spread Bollinger Bands** — Bollinger bands on the spread of two series for pairs mean-reversion (`SPREAD_BOLLINGER_BANDS`).
|
||||
- **Kalman Hedge Ratio** — Kalman-filter dynamic hedge ratio and spread between two series (`KALMAN_HEDGE_RATIO`).
|
||||
- **Granger Causality** — Granger causality F-statistic measuring whether one series predicts another (`GRANGER_CAUSALITY`).
|
||||
- **Variance Ratio** — Lo-MacKinlay variance-ratio test on the spread of two series (`VARIANCE_RATIO`).
|
||||
- **Beta-Neutral Spread** — beta-neutral spread: the rolling OLS regression residual of two series (`BETA_NEUTRAL_SPREAD`).
|
||||
- **Distance SSD** — Gatev sum-of-squared-deviations distance between two normalised series (`DISTANCE_SSD`).
|
||||
- **Spread Hurst** — Hurst exponent of the spread of two series for regime detection (`SPREAD_HURST`).
|
||||
- **OU Half-Life** — Ornstein-Uhlenbeck half-life of mean reversion for the spread of two series (`OU_HALF_LIFE`).
|
||||
- **Rolling Covariance** — rolling covariance of the period-over-period returns of two series (`ROLLING_COVARIANCE`).
|
||||
- **Rolling Correlation** — rolling Pearson correlation of the period-over-period returns of two series (`ROLLING_CORRELATION`).
|
||||
|
||||
- **Market Breadth family** — a new indicator family built on a new
|
||||
`CrossSection` input type that carries the per-symbol state of an entire
|
||||
universe in one tick (each `Member` holds a signed `change`, a `volume`, and
|
||||
`new_high` / `new_low` flags). `CrossSection::new` validates the universe
|
||||
(non-empty, finite changes, finite non-negative volumes); `new_unchecked`
|
||||
skips validation for hot paths.
|
||||
- `AdvanceDecline` (`ADVANCE_DECLINE`) — the Advance/Decline Line, the running
|
||||
cumulative sum of net advancing-minus-declining issues across the universe.
|
||||
|
||||
## [0.4.6] - 2026-06-03
|
||||
|
||||
### Added
|
||||
@@ -1124,7 +1147,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.4.6...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.7...HEAD
|
||||
[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
|
||||
[0.4.5]: https://github.com/wickra-lib/wickra/compare/v0.4.4...v0.4.5
|
||||
[0.4.4]: https://github.com/wickra-lib/wickra/compare/v0.4.3...v0.4.4
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
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.4.6" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.4.7" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -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=314" 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=325" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](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 314 indicators; start at the
|
||||
every one of the 325 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
|
||||
|
||||
314 streaming-first indicators across nineteen families. Every one passes the
|
||||
325 streaming-first indicators across twenty 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).
|
||||
@@ -150,7 +150,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| Bands & Channels | MA Envelope, Acceleration Bands, STARC Bands, ATR Bands, Hurst Channel, LinReg Channel, Standard Error Bands, Double Bollinger Bands, TTM Squeeze, Fractal Chaos Bands, VWAP StdDev Bands |
|
||||
| Trailing Stops | Parabolic SAR, Parabolic SAR Extended (SAREXT), SuperTrend, Chandelier Exit, Chande Kroll Stop, ATR Trailing Stop, HiLo Activator, Volty Stop, Yo-Yo Exit, Donchian Channel Stop, Percentage Trailing Stop, Step Trailing Stop, Renko Trailing Stop |
|
||||
| Volume | OBV, VWAP (cumulative + rolling), ADL, Volume-Price Trend, Chaikin Money Flow, Chaikin Oscillator, Force Index, Ease of Movement, Klinger Volume Oscillator, Volume Oscillator, NVI, PVI, Williams A/D, Anchored VWAP, Demand Index, TSV, VZO, Market Facilitation Index |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast |
|
||||
| Price Statistics | Typical Price, Median Price, Weighted Close, Linear Regression, Linear Regression Slope, Z-Score, Linear Regression Angle, Variance, Coefficient of Variation, Skewness, Kurtosis, Standard Error, Detrended StdDev, R², Median Absolute Deviation, Autocorrelation, Hurst Exponent, Pearson Correlation, Beta, Pairwise Beta, Pair Spread Z-Score, Lead-Lag Cross-Correlation, Cointegration, Relative Strength A-vs-B, Spearman Correlation, Mid Price, Mid Point, Average Price, Linear Regression Intercept, Time Series Forecast, Rolling Correlation, Rolling Covariance, OU Half-Life, Spread Hurst, Distance SSD, Beta-Neutral Spread, Variance Ratio, Granger Causality, Kalman Hedge Ratio, Spread Bollinger Bands |
|
||||
| Ehlers / Cycle (DSP) | MAMA, FAMA, Fisher Transform, Inverse Fisher Transform, SuperSmoother, Hilbert Dominant Cycle, Hilbert Phasor, Hilbert DC Phase, Hilbert Trend Mode, Sine Wave, Decycler, Decycler Oscillator, Roofing Filter, Center of Gravity, Cybernetic Cycle, Adaptive Cycle, Empirical Mode Decomposition, Ehlers Stochastic, Instantaneous Trendline |
|
||||
| Pivots & S/R | Classic Pivots, Fibonacci Pivots, Camarilla, Woodie Pivots, DeMark Pivots, Williams Fractals, ZigZag |
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
@@ -160,6 +160,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Depth Slope, Signed Volume, Cumulative Volume Delta, Trade Imbalance, Effective Spread, Realized Spread, Kyle's Lambda, Footprint |
|
||||
| Derivatives | Funding Rate, Funding Rate Mean, Funding Rate Z-Score, Funding Basis, Open-Interest Delta, OI / Price Divergence, OI-Weighted Price, Long/Short Ratio, Taker Buy/Sell Ratio, Liquidation Features, Term-Structure Basis, Calendar Spread |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range |
|
||||
| Market Breadth | Advance/Decline Line |
|
||||
| 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) |
|
||||
|
||||
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
|
||||
@@ -239,7 +240,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 314 indicators
|
||||
│ ├── wickra-core/ core engine + all 325 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
|
||||
@@ -529,6 +529,14 @@ const pairFactories = {
|
||||
PairwiseBeta: () => new wickra.PairwiseBeta(14),
|
||||
PairSpreadZScore: () => new wickra.PairSpreadZScore(14, 14),
|
||||
SpearmanCorrelation: () => new wickra.SpearmanCorrelation(14),
|
||||
RollingCorrelation: () => new wickra.RollingCorrelation(20),
|
||||
RollingCovariance: () => new wickra.RollingCovariance(20),
|
||||
OuHalfLife: () => new wickra.OuHalfLife(60),
|
||||
SpreadHurst: () => new wickra.SpreadHurst(60),
|
||||
DistanceSsd: () => new wickra.DistanceSsd(20),
|
||||
BetaNeutralSpread: () => new wickra.BetaNeutralSpread(20),
|
||||
VarianceRatio: () => new wickra.VarianceRatio(60, 2),
|
||||
GrangerCausality: () => new wickra.GrangerCausality(60, 1),
|
||||
};
|
||||
|
||||
for (const [name, make] of Object.entries(pairFactories)) {
|
||||
@@ -619,6 +627,47 @@ test('Cointegration batch is flat 3*n with last row matching', () => {
|
||||
assert.ok(out[3 * (n - 1) + 2] < -2);
|
||||
});
|
||||
|
||||
test('KalmanHedgeRatio converges to a static hedge ratio (object output)', () => {
|
||||
const n = 500;
|
||||
const b = Array.from({ length: n }, (_, t) => 100 + 95 * Math.sin(t * 0.5));
|
||||
const a = b.map((v) => 2 * v + 5);
|
||||
const k = new wickra.KalmanHedgeRatio(1e-2, 1e-3);
|
||||
let last = null;
|
||||
for (let i = 0; i < n; i++) last = k.update(a[i], b[i]);
|
||||
assert.ok(Math.abs(last.hedgeRatio - 2) < 0.05);
|
||||
assert.ok(Math.abs(last.spread) < 0.05);
|
||||
});
|
||||
|
||||
test('KalmanHedgeRatio batch is flat 3*n with last row matching', () => {
|
||||
const n = 500;
|
||||
const b = Array.from({ length: n }, (_, t) => 100 + 95 * Math.sin(t * 0.5));
|
||||
const a = b.map((v) => 2 * v + 5);
|
||||
const out = new wickra.KalmanHedgeRatio(1e-2, 1e-3).batch(a, b);
|
||||
assert.equal(out.length, 3 * n);
|
||||
assert.ok(Math.abs(out[3 * (n - 1)] - 2) < 0.05);
|
||||
assert.ok(Math.abs(out[3 * (n - 1) + 2]) < 0.05);
|
||||
});
|
||||
|
||||
test('SpreadBollingerBands bands are ordered (object output)', () => {
|
||||
const n = 60;
|
||||
const b = Array.from({ length: n }, (_, t) => 100 + t);
|
||||
const a = b.map((v, t) => v + 3 * Math.sin(t * 0.4));
|
||||
const bb = new wickra.SpreadBollingerBands(20, 2.0);
|
||||
let last = null;
|
||||
for (let i = 0; i < n; i++) last = bb.update(a[i], b[i]);
|
||||
assert.ok(last.lower <= last.middle && last.middle <= last.upper);
|
||||
});
|
||||
|
||||
test('SpreadBollingerBands batch is flat 4*n with last row matching', () => {
|
||||
const n = 60;
|
||||
const b = Array.from({ length: n }, (_, t) => 100 + t);
|
||||
const a = b.map((v, t) => v + 3 * Math.sin(t * 0.4));
|
||||
const out = new wickra.SpreadBollingerBands(20, 2.0).batch(a, b);
|
||||
assert.equal(out.length, 4 * n);
|
||||
const base = 4 * (n - 1);
|
||||
assert.ok(out[base + 2] <= out[base] && out[base] <= out[base + 1]);
|
||||
});
|
||||
|
||||
test('RelativeStrengthAB constant ratio is flat (object output)', () => {
|
||||
const rs = new wickra.RelativeStrengthAB(5, 5);
|
||||
let last = null;
|
||||
@@ -1202,6 +1251,39 @@ test('derivatives reject bad input', () => {
|
||||
assert.throws(() => new wickra.FundingBasis().update(100, 0));
|
||||
});
|
||||
|
||||
test('market breadth: AdvanceDecline reference values', () => {
|
||||
// A breadth tick is the universe as parallel arrays; the sign of `change`
|
||||
// classifies each symbol as advancing / declining / unchanged.
|
||||
const change = [
|
||||
[1.0, 0.5, 2.0, -1.0], // 3 up, 1 down -> net +2
|
||||
[-1.0, -0.5, -2.0, 1.0], // 1 up, 3 down -> net -2
|
||||
[0.0, 0.0, 1.0, -1.0], // 1 up, 1 down -> net 0
|
||||
];
|
||||
const volume = change.map((row) => row.map(() => 10.0));
|
||||
const flags = change.map((row) => row.map(() => false));
|
||||
|
||||
const ad = new wickra.AdvanceDecline();
|
||||
// Cumulative line: +2 -> 0 -> 0.
|
||||
assert.equal(ad.update(change[0], volume[0], flags[0], flags[0]), 2.0);
|
||||
assert.equal(ad.update(change[1], volume[1], flags[1], flags[1]), 0.0);
|
||||
assert.equal(ad.update(change[2], volume[2], flags[2], flags[2]), 0.0);
|
||||
|
||||
// batch matches streaming.
|
||||
const batch = new wickra.AdvanceDecline().batch(change, volume, flags, flags);
|
||||
assert.deepEqual(Array.from(batch), [2.0, 0.0, 0.0]);
|
||||
});
|
||||
|
||||
test('market breadth: AdvanceDecline rejects ragged universe', () => {
|
||||
assert.throws(() =>
|
||||
new wickra.AdvanceDecline().update(
|
||||
[1.0, -1.0],
|
||||
[10.0],
|
||||
[false, false],
|
||||
[false, false],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('OI / flow / liquidation indicators reference values', () => {
|
||||
// OI +10% while price flat -> divergence +0.1.
|
||||
const div = new wickra.OIPriceDivergence(1);
|
||||
|
||||
Vendored
+169
@@ -33,6 +33,26 @@ export interface RelativeStrengthValue {
|
||||
/** RSI of the ratio. */
|
||||
ratioRsi: number
|
||||
}
|
||||
/** Kalman hedge-ratio result: dynamic hedge ratio, intercept, and spread. */
|
||||
export interface KalmanHedgeRatioValue {
|
||||
/** Current hedge ratio (filtered slope of `a` on `b`). */
|
||||
hedgeRatio: number
|
||||
/** Current intercept (filtered level offset). */
|
||||
intercept: number
|
||||
/** Forecast error `a - (intercept + hedgeRatio*b)` — the spread signal. */
|
||||
spread: number
|
||||
}
|
||||
/** Spread Bollinger-bands result: middle, upper and lower bands plus `%b`. */
|
||||
export interface SpreadBollingerBandsValue {
|
||||
/** Middle band: the rolling mean of the spread. */
|
||||
middle: number
|
||||
/** Upper band. */
|
||||
upper: number
|
||||
/** Lower band. */
|
||||
lower: number
|
||||
/** `%b`: where the spread sits across the band (`0` lower, `1` upper). */
|
||||
percentB: number
|
||||
}
|
||||
/** MACD triple: macd line, signal line, histogram. */
|
||||
export interface MacdValue {
|
||||
macd: number
|
||||
@@ -786,6 +806,84 @@ export declare class SpearmanCorrelation {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RollingCorrelationNode = RollingCorrelation
|
||||
export declare class RollingCorrelation {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RollingCovarianceNode = RollingCovariance
|
||||
export declare class RollingCovariance {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OuHalfLifeNode = OuHalfLife
|
||||
export declare class OuHalfLife {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SpreadHurstNode = SpreadHurst
|
||||
export declare class SpreadHurst {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DistanceSsdNode = DistanceSsd
|
||||
export declare class DistanceSsd {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BetaNeutralSpreadNode = BetaNeutralSpread
|
||||
export declare class BetaNeutralSpread {
|
||||
constructor(period: number)
|
||||
update(x: number, y: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array
|
||||
* with `NaN` for warmup positions.
|
||||
*/
|
||||
batch(x: Array<number>, y: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PairSpreadZScoreNode = PairSpreadZScore
|
||||
/**
|
||||
* Pair spread z-score: two ctor params (`betaPeriod`, `zPeriod`), one `(a, b)`
|
||||
@@ -845,6 +943,68 @@ export declare class RelativeStrengthAB {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VarianceRatioNode = VarianceRatio
|
||||
/**
|
||||
* Lo–MacKinlay variance ratio: two ctor params (`period`, `q`), one `(a, b)`
|
||||
* pair per update, a single ratio out.
|
||||
*/
|
||||
export declare class VarianceRatio {
|
||||
constructor(period: number, q: number)
|
||||
update(a: number, b: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array with
|
||||
* `NaN` for warmup positions.
|
||||
*/
|
||||
batch(a: Array<number>, b: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type GrangerCausalityNode = GrangerCausality
|
||||
/**
|
||||
* Granger causality F-statistic: two ctor params (`period`, `lag`), one
|
||||
* `(a, b)` pair per update, a single F-statistic out.
|
||||
*/
|
||||
export declare class GrangerCausality {
|
||||
constructor(period: number, lag: number)
|
||||
update(a: number, b: number): number | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a length-`n` array with
|
||||
* `NaN` for warmup positions.
|
||||
*/
|
||||
batch(a: Array<number>, b: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KalmanHedgeRatioNode = KalmanHedgeRatio
|
||||
export declare class KalmanHedgeRatio {
|
||||
constructor(delta: number, observationVar: number)
|
||||
update(a: number, b: number): KalmanHedgeRatioValue | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a flat array of length
|
||||
* `3 * n`, interleaved per row as `[hedgeRatio0, intercept0, spread0, ...]`.
|
||||
* Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
|
||||
*/
|
||||
batch(a: Array<number>, b: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SpreadBollingerBandsNode = SpreadBollingerBands
|
||||
export declare class SpreadBollingerBands {
|
||||
constructor(period: number, numStd: number)
|
||||
update(a: number, b: number): SpreadBollingerBandsValue | null
|
||||
/**
|
||||
* Batch over two equally-sized arrays. Returns a flat array of length
|
||||
* `4 * n`, interleaved per row as `[middle0, upper0, lower0, percentB0, ...]`.
|
||||
* Read column `j` of row `i` as `result[i * 4 + j]`. Warmup rows are `NaN`.
|
||||
*/
|
||||
batch(a: Array<number>, b: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type MacdNode = MACD
|
||||
export declare class MACD {
|
||||
constructor(fast: number, slow: number, signal: number)
|
||||
@@ -3084,6 +3244,15 @@ export declare class CalendarSpread {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AdvanceDeclineNode = AdvanceDecline
|
||||
export declare class AdvanceDecline {
|
||||
constructor()
|
||||
update(change: Array<number>, volume: Array<number>, newHigh: Array<boolean>, newLow: Array<boolean>): number | null
|
||||
batch(change: Array<Array<number>>, volume: Array<Array<number>>, newHigh: Array<Array<boolean>>, newLow: Array<Array<boolean>>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SharpeRatioNode = SharpeRatio
|
||||
export declare class SharpeRatio {
|
||||
constructor(period: number, riskFree: number)
|
||||
|
||||
+12
-1
@@ -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, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, 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, 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, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, RenkoBars, KagiBars, PointAndFigureBars, Alpha } = nativeBinding
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -362,10 +362,20 @@ module.exports.PearsonCorrelation = PearsonCorrelation
|
||||
module.exports.Beta = Beta
|
||||
module.exports.PairwiseBeta = PairwiseBeta
|
||||
module.exports.SpearmanCorrelation = SpearmanCorrelation
|
||||
module.exports.RollingCorrelation = RollingCorrelation
|
||||
module.exports.RollingCovariance = RollingCovariance
|
||||
module.exports.OuHalfLife = OuHalfLife
|
||||
module.exports.SpreadHurst = SpreadHurst
|
||||
module.exports.DistanceSsd = DistanceSsd
|
||||
module.exports.BetaNeutralSpread = BetaNeutralSpread
|
||||
module.exports.PairSpreadZScore = PairSpreadZScore
|
||||
module.exports.LeadLagCrossCorrelation = LeadLagCrossCorrelation
|
||||
module.exports.Cointegration = Cointegration
|
||||
module.exports.RelativeStrengthAB = RelativeStrengthAB
|
||||
module.exports.VarianceRatio = VarianceRatio
|
||||
module.exports.GrangerCausality = GrangerCausality
|
||||
module.exports.KalmanHedgeRatio = KalmanHedgeRatio
|
||||
module.exports.SpreadBollingerBands = SpreadBollingerBands
|
||||
module.exports.MACD = MACD
|
||||
module.exports.MACDFIX = MACDFIX
|
||||
module.exports.MACDEXT = MACDEXT
|
||||
@@ -607,6 +617,7 @@ module.exports.TakerBuySellRatio = TakerBuySellRatio
|
||||
module.exports.LiquidationFeatures = LiquidationFeatures
|
||||
module.exports.TermStructureBasis = TermStructureBasis
|
||||
module.exports.CalendarSpread = CalendarSpread
|
||||
module.exports.AdvanceDecline = AdvanceDecline
|
||||
module.exports.SharpeRatio = SharpeRatio
|
||||
module.exports.SortinoRatio = SortinoRatio
|
||||
module.exports.CalmarRatio = CalmarRatio
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"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.4.6",
|
||||
"version": "0.4.7",
|
||||
"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,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"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.4.6",
|
||||
"version": "0.4.7",
|
||||
"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.4.6",
|
||||
"version": "0.4.7",
|
||||
"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": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.4.6",
|
||||
"wickra-darwin-x64": "0.4.6",
|
||||
"wickra-linux-arm64-gnu": "0.4.6",
|
||||
"wickra-linux-x64-gnu": "0.4.6",
|
||||
"wickra-win32-arm64-msvc": "0.4.6",
|
||||
"wickra-win32-x64-msvc": "0.4.6"
|
||||
"wickra-darwin-arm64": "0.4.7",
|
||||
"wickra-darwin-x64": "0.4.7",
|
||||
"wickra-linux-arm64-gnu": "0.4.7",
|
||||
"wickra-linux-x64-gnu": "0.4.7",
|
||||
"wickra-win32-arm64-msvc": "0.4.7",
|
||||
"wickra-win32-x64-msvc": "0.4.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.6.tgz",
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.7.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.6.tgz",
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.7.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.6.tgz",
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.7.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.6.tgz",
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.7.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.6.tgz",
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.7.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.6.tgz",
|
||||
"version": "0.4.7",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.7.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"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.4.6",
|
||||
"wickra-linux-arm64-gnu": "0.4.6",
|
||||
"wickra-darwin-x64": "0.4.6",
|
||||
"wickra-darwin-arm64": "0.4.6",
|
||||
"wickra-win32-x64-msvc": "0.4.6",
|
||||
"wickra-win32-arm64-msvc": "0.4.6"
|
||||
"wickra-linux-x64-gnu": "0.4.7",
|
||||
"wickra-linux-arm64-gnu": "0.4.7",
|
||||
"wickra-darwin-x64": "0.4.7",
|
||||
"wickra-darwin-arm64": "0.4.7",
|
||||
"wickra-win32-x64-msvc": "0.4.7",
|
||||
"wickra-win32-arm64-msvc": "0.4.7"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -322,6 +322,24 @@ node_pair_indicator!(
|
||||
"SpearmanCorrelation",
|
||||
wc::SpearmanCorrelation
|
||||
);
|
||||
node_pair_indicator!(
|
||||
RollingCorrelationNode,
|
||||
"RollingCorrelation",
|
||||
wc::RollingCorrelation
|
||||
);
|
||||
node_pair_indicator!(
|
||||
RollingCovarianceNode,
|
||||
"RollingCovariance",
|
||||
wc::RollingCovariance
|
||||
);
|
||||
node_pair_indicator!(OuHalfLifeNode, "OuHalfLife", wc::OuHalfLife);
|
||||
node_pair_indicator!(SpreadHurstNode, "SpreadHurst", wc::SpreadHurst);
|
||||
node_pair_indicator!(DistanceSsdNode, "DistanceSsd", wc::DistanceSsd);
|
||||
node_pair_indicator!(
|
||||
BetaNeutralSpreadNode,
|
||||
"BetaNeutralSpread",
|
||||
wc::BetaNeutralSpread
|
||||
);
|
||||
|
||||
// ============================== PairSpreadZScore ==============================
|
||||
|
||||
@@ -581,6 +599,252 @@ impl RelativeStrengthAbNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== VarianceRatio ==============================
|
||||
|
||||
/// Lo–MacKinlay variance ratio: two ctor params (`period`, `q`), one `(a, b)`
|
||||
/// pair per update, a single ratio out.
|
||||
#[napi(js_name = "VarianceRatio")]
|
||||
pub struct VarianceRatioNode {
|
||||
inner: wc::VarianceRatio,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl VarianceRatioNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, q: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::VarianceRatio::new(period as usize, q as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized arrays. Returns a length-`n` array with
|
||||
/// `NaN` for warmup positions.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(a.len());
|
||||
for i in 0..a.len() {
|
||||
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== GrangerCausality ==============================
|
||||
|
||||
/// Granger causality F-statistic: two ctor params (`period`, `lag`), one
|
||||
/// `(a, b)` pair per update, a single F-statistic out.
|
||||
#[napi(js_name = "GrangerCausality")]
|
||||
pub struct GrangerCausalityNode {
|
||||
inner: wc::GrangerCausality,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl GrangerCausalityNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, lag: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::GrangerCausality::new(period as usize, lag as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized arrays. Returns a length-`n` array with
|
||||
/// `NaN` for warmup positions.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(a.len());
|
||||
for i in 0..a.len() {
|
||||
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== KalmanHedgeRatio ==============================
|
||||
|
||||
/// Kalman hedge-ratio result: dynamic hedge ratio, intercept, and spread.
|
||||
#[napi(object)]
|
||||
pub struct KalmanHedgeRatioValue {
|
||||
/// Current hedge ratio (filtered slope of `a` on `b`).
|
||||
pub hedge_ratio: f64,
|
||||
/// Current intercept (filtered level offset).
|
||||
pub intercept: f64,
|
||||
/// Forecast error `a - (intercept + hedgeRatio*b)` — the spread signal.
|
||||
pub spread: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "KalmanHedgeRatio")]
|
||||
pub struct KalmanHedgeRatioNode {
|
||||
inner: wc::KalmanHedgeRatio,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl KalmanHedgeRatioNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(delta: f64, observation_var: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::KalmanHedgeRatio::new(delta, observation_var).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<KalmanHedgeRatioValue> {
|
||||
self.inner.update((a, b)).map(|o| KalmanHedgeRatioValue {
|
||||
hedge_ratio: o.hedge_ratio,
|
||||
intercept: o.intercept,
|
||||
spread: o.spread,
|
||||
})
|
||||
}
|
||||
/// Batch over two equally-sized arrays. Returns a flat array of length
|
||||
/// `3 * n`, interleaved per row as `[hedgeRatio0, intercept0, spread0, ...]`.
|
||||
/// Read column `j` of row `i` as `result[i * 3 + j]`. Warmup rows are `NaN`.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = vec![f64::NAN; a.len() * 3];
|
||||
for i in 0..a.len() {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 3] = o.hedge_ratio;
|
||||
out[i * 3 + 1] = o.intercept;
|
||||
out[i * 3 + 2] = o.spread;
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== SpreadBollingerBands ==============================
|
||||
|
||||
/// Spread Bollinger-bands result: middle, upper and lower bands plus `%b`.
|
||||
#[napi(object)]
|
||||
pub struct SpreadBollingerBandsValue {
|
||||
/// Middle band: the rolling mean of the spread.
|
||||
pub middle: f64,
|
||||
/// Upper band.
|
||||
pub upper: f64,
|
||||
/// Lower band.
|
||||
pub lower: f64,
|
||||
/// `%b`: where the spread sits across the band (`0` lower, `1` upper).
|
||||
pub percent_b: f64,
|
||||
}
|
||||
|
||||
#[napi(js_name = "SpreadBollingerBands")]
|
||||
pub struct SpreadBollingerBandsNode {
|
||||
inner: wc::SpreadBollingerBands,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl SpreadBollingerBandsNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, num_std: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::SpreadBollingerBands::new(period as usize, num_std).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<SpreadBollingerBandsValue> {
|
||||
self.inner
|
||||
.update((a, b))
|
||||
.map(|o| SpreadBollingerBandsValue {
|
||||
middle: o.middle,
|
||||
upper: o.upper,
|
||||
lower: o.lower,
|
||||
percent_b: o.percent_b,
|
||||
})
|
||||
}
|
||||
/// Batch over two equally-sized arrays. Returns a flat array of length
|
||||
/// `4 * n`, interleaved per row as `[middle0, upper0, lower0, percentB0, ...]`.
|
||||
/// Read column `j` of row `i` as `result[i * 4 + j]`. Warmup rows are `NaN`.
|
||||
#[napi]
|
||||
pub fn batch(&mut self, a: Vec<f64>, b: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if a.len() != b.len() {
|
||||
return Err(NapiError::new(
|
||||
Status::InvalidArg,
|
||||
"a and b must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = vec![f64::NAN; a.len() * 4];
|
||||
for i in 0..a.len() {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 4] = o.middle;
|
||||
out[i * 4 + 1] = o.upper;
|
||||
out[i * 4 + 2] = o.lower;
|
||||
out[i * 4 + 3] = o.percent_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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== MACD ==============================
|
||||
|
||||
/// MACD triple: macd line, signal line, histogram.
|
||||
@@ -11168,6 +11432,100 @@ impl CalendarSpreadNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Market Breadth (CrossSection input) ----------
|
||||
//
|
||||
// A breadth tick is the per-symbol state of the whole universe, passed as four
|
||||
// equal-length parallel arrays (`change`, `volume`, `newHigh`, `newLow`).
|
||||
// `batch` takes one such group of arrays per tick.
|
||||
|
||||
fn build_cross_section(
|
||||
change: &[f64],
|
||||
volume: &[f64],
|
||||
new_high: &[bool],
|
||||
new_low: &[bool],
|
||||
) -> napi::Result<wc::CrossSection> {
|
||||
if change.len() != volume.len()
|
||||
|| change.len() != new_high.len()
|
||||
|| change.len() != new_low.len()
|
||||
{
|
||||
return Err(NapiError::from_reason(
|
||||
"change, volume, newHigh and newLow must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let members = (0..change.len())
|
||||
.map(|i| wc::Member::new(change[i], volume[i], new_high[i], new_low[i]))
|
||||
.collect();
|
||||
wc::CrossSection::new(members, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
#[napi(js_name = "AdvanceDecline")]
|
||||
pub struct AdvanceDeclineNode {
|
||||
inner: wc::AdvanceDecline,
|
||||
}
|
||||
|
||||
impl Default for AdvanceDeclineNode {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl AdvanceDeclineNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::AdvanceDecline::new(),
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
change: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
new_high: Vec<bool>,
|
||||
new_low: Vec<bool>,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_cross_section(&change, &volume, &new_high, &new_low)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
change: Vec<Vec<f64>>,
|
||||
volume: Vec<Vec<f64>>,
|
||||
new_high: Vec<Vec<bool>>,
|
||||
new_low: Vec<Vec<bool>>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if change.len() != volume.len()
|
||||
|| change.len() != new_high.len()
|
||||
|| change.len() != new_low.len()
|
||||
{
|
||||
return Err(NapiError::from_reason(
|
||||
"change, volume, newHigh and newLow must have the same number of ticks".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(change.len());
|
||||
for i in 0..change.len() {
|
||||
let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?;
|
||||
out.push(self.inner.update(section).unwrap_or(f64::NAN));
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
// Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.4.6"
|
||||
version = "0.4.7"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
|
||||
|
||||
@@ -159,6 +159,16 @@ from ._wickra import (
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
SpreadBollingerBands,
|
||||
KalmanHedgeRatio,
|
||||
GrangerCausality,
|
||||
VarianceRatio,
|
||||
BetaNeutralSpread,
|
||||
DistanceSsd,
|
||||
SpreadHurst,
|
||||
OuHalfLife,
|
||||
RollingCovariance,
|
||||
RollingCorrelation,
|
||||
TypicalPrice,
|
||||
MedianPrice,
|
||||
WeightedClose,
|
||||
@@ -341,6 +351,8 @@ from ._wickra import (
|
||||
LiquidationFeatures,
|
||||
TermStructureBasis,
|
||||
CalendarSpread,
|
||||
# Market Breadth
|
||||
AdvanceDecline,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
@@ -497,6 +509,16 @@ __all__ = [
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"SpreadBollingerBands",
|
||||
"KalmanHedgeRatio",
|
||||
"GrangerCausality",
|
||||
"VarianceRatio",
|
||||
"BetaNeutralSpread",
|
||||
"DistanceSsd",
|
||||
"SpreadHurst",
|
||||
"OuHalfLife",
|
||||
"RollingCovariance",
|
||||
"RollingCorrelation",
|
||||
"TypicalPrice",
|
||||
"MedianPrice",
|
||||
"WeightedClose",
|
||||
@@ -679,6 +701,8 @@ __all__ = [
|
||||
"LiquidationFeatures",
|
||||
"TermStructureBasis",
|
||||
"CalendarSpread",
|
||||
# Market Breadth
|
||||
"AdvanceDecline",
|
||||
# Risk / Performance
|
||||
"SharpeRatio",
|
||||
"SortinoRatio",
|
||||
|
||||
+790
-1
@@ -29,7 +29,9 @@ fn map_err(e: wc::Error) -> PyErr {
|
||||
| wc::Error::InvalidTick { .. }
|
||||
| wc::Error::InvalidOrderBook { .. }
|
||||
| wc::Error::InvalidTrade { .. }
|
||||
| wc::Error::InvalidDerivatives { .. } => PyValueError::new_err(e.to_string()),
|
||||
| wc::Error::InvalidDerivatives { .. }
|
||||
| wc::Error::InvalidCrossSection { .. }
|
||||
| wc::Error::InvalidParameter { .. } => PyValueError::new_err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12249,6 +12251,687 @@ impl PyRelativeStrengthAB {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RollingCorrelation ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "RollingCorrelation",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyRollingCorrelation {
|
||||
inner: wc::RollingCorrelation,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRollingCorrelation {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=20))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::RollingCorrelation::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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!("RollingCorrelation(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== RollingCovariance ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "RollingCovariance",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyRollingCovariance {
|
||||
inner: wc::RollingCovariance,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRollingCovariance {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=20))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::RollingCovariance::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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!("RollingCovariance(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== OuHalfLife ==============================
|
||||
|
||||
#[pyclass(name = "OuHalfLife", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyOuHalfLife {
|
||||
inner: wc::OuHalfLife,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyOuHalfLife {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=60))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::OuHalfLife::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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!("OuHalfLife(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== SpreadHurst ==============================
|
||||
|
||||
#[pyclass(name = "SpreadHurst", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PySpreadHurst {
|
||||
inner: wc::SpreadHurst,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PySpreadHurst {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=60))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::SpreadHurst::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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!("SpreadHurst(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== DistanceSsd ==============================
|
||||
|
||||
#[pyclass(name = "DistanceSsd", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyDistanceSsd {
|
||||
inner: wc::DistanceSsd,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyDistanceSsd {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=20))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::DistanceSsd::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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!("DistanceSsd(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== BetaNeutralSpread ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "BetaNeutralSpread",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyBetaNeutralSpread {
|
||||
inner: wc::BetaNeutralSpread,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyBetaNeutralSpread {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=20))]
|
||||
fn new(period: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::BetaNeutralSpread::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
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!("BetaNeutralSpread(period={})", self.inner.period())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== VarianceRatio ==============================
|
||||
|
||||
#[pyclass(name = "VarianceRatio", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyVarianceRatio {
|
||||
inner: wc::VarianceRatio,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyVarianceRatio {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=60, q=2))]
|
||||
fn new(period: usize, q: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::VarianceRatio::new(period, q).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
#[getter]
|
||||
fn q(&self) -> usize {
|
||||
self.inner.q()
|
||||
}
|
||||
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!(
|
||||
"VarianceRatio(period={}, q={})",
|
||||
self.inner.period(),
|
||||
self.inner.q()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== GrangerCausality ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "GrangerCausality",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyGrangerCausality {
|
||||
inner: wc::GrangerCausality,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyGrangerCausality {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=60, lag=1))]
|
||||
fn new(period: usize, lag: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::GrangerCausality::new(period, lag).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays: `a` and `b`.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(xs.len());
|
||||
for i in 0..xs.len() {
|
||||
out.push(self.inner.update((xs[i], ys[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
#[getter]
|
||||
fn lag(&self) -> usize {
|
||||
self.inner.lag()
|
||||
}
|
||||
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!(
|
||||
"GrangerCausality(period={}, lag={})",
|
||||
self.inner.period(),
|
||||
self.inner.lag()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== KalmanHedgeRatio ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "KalmanHedgeRatio",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyKalmanHedgeRatio {
|
||||
inner: wc::KalmanHedgeRatio,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKalmanHedgeRatio {
|
||||
#[new]
|
||||
#[pyo3(signature = (delta=1e-4, observation_var=1e-3))]
|
||||
fn new(delta: f64, observation_var: f64) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::KalmanHedgeRatio::new(delta, observation_var).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `(hedge_ratio, intercept, spread)` or `None` during warmup.
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64)> {
|
||||
self.inner
|
||||
.update((a, b))
|
||||
.map(|o| (o.hedge_ratio, o.intercept, o.spread))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
|
||||
/// `(n, 3)` with columns `[hedge_ratio, intercept, spread]`. Warmup rows are
|
||||
/// NaN.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let n = xs.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((xs[i], ys[i])) {
|
||||
out[i * 3] = o.hedge_ratio;
|
||||
out[i * 3 + 1] = o.intercept;
|
||||
out[i * 3 + 2] = o.spread;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 3), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn delta(&self) -> f64 {
|
||||
self.inner.delta()
|
||||
}
|
||||
#[getter]
|
||||
fn observation_var(&self) -> f64 {
|
||||
self.inner.observation_var()
|
||||
}
|
||||
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!(
|
||||
"KalmanHedgeRatio(delta={}, observation_var={})",
|
||||
self.inner.delta(),
|
||||
self.inner.observation_var()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== SpreadBollingerBands ==============================
|
||||
|
||||
#[pyclass(
|
||||
name = "SpreadBollingerBands",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PySpreadBollingerBands {
|
||||
inner: wc::SpreadBollingerBands,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PySpreadBollingerBands {
|
||||
#[new]
|
||||
#[pyo3(signature = (period=20, num_std=2.0))]
|
||||
fn new(period: usize, num_std: f64) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::SpreadBollingerBands::new(period, num_std).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `(middle, upper, lower, percent_b)` or `None` during warmup.
|
||||
fn update(&mut self, a: f64, b: f64) -> Option<(f64, f64, f64, f64)> {
|
||||
self.inner
|
||||
.update((a, b))
|
||||
.map(|o| (o.middle, o.upper, o.lower, o.percent_b))
|
||||
}
|
||||
/// Batch over two equally-sized numpy arrays. Returns a 2D array of shape
|
||||
/// `(n, 4)` with columns `[middle, upper, lower, percent_b]`. Warmup rows are
|
||||
/// NaN.
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
a: PyReadonlyArray1<'py, f64>,
|
||||
b: PyReadonlyArray1<'py, f64>,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let xs = a
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
let ys = b
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err(NON_CONTIGUOUS))?;
|
||||
if xs.len() != ys.len() {
|
||||
return Err(PyValueError::new_err("a and b must be equal length"));
|
||||
}
|
||||
let n = xs.len();
|
||||
let mut out = vec![f64::NAN; n * 4];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((xs[i], ys[i])) {
|
||||
out[i * 4] = o.middle;
|
||||
out[i * 4 + 1] = o.upper;
|
||||
out[i * 4 + 2] = o.lower;
|
||||
out[i * 4 + 3] = o.percent_b;
|
||||
}
|
||||
}
|
||||
Ok(numpy::ndarray::Array2::from_shape_vec((n, 4), out)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py))
|
||||
}
|
||||
#[getter]
|
||||
fn period(&self) -> usize {
|
||||
self.inner.period()
|
||||
}
|
||||
#[getter]
|
||||
fn num_std(&self) -> f64 {
|
||||
self.inner.num_std()
|
||||
}
|
||||
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!(
|
||||
"SpreadBollingerBands(period={}, num_std={})",
|
||||
self.inner.period(),
|
||||
self.inner.num_std()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== SpearmanCorrelation ==============================
|
||||
|
||||
#[pyclass(
|
||||
@@ -14383,6 +15066,101 @@ impl PyCalendarSpread {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Market Breadth ==============================
|
||||
//
|
||||
// Market-breadth indicators consume a `CrossSection`: one tick carrying the
|
||||
// per-symbol state of the whole universe. The Python convention passes a tick as
|
||||
// four equal-length parallel arrays (`change`, `volume`, `new_high`, `new_low`);
|
||||
// `batch` takes one such group of arrays per tick.
|
||||
|
||||
fn build_cross_section(
|
||||
change: &[f64],
|
||||
volume: &[f64],
|
||||
new_high: &[bool],
|
||||
new_low: &[bool],
|
||||
) -> PyResult<wc::CrossSection> {
|
||||
if change.len() != volume.len()
|
||||
|| change.len() != new_high.len()
|
||||
|| change.len() != new_low.len()
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"change, volume, new_high and new_low must be equal length",
|
||||
));
|
||||
}
|
||||
let members = (0..change.len())
|
||||
.map(|i| wc::Member::new(change[i], volume[i], new_high[i], new_low[i]))
|
||||
.collect();
|
||||
wc::CrossSection::new(members, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
// AdvanceDecline takes no parameters; streaming `update(change, volume, new_high,
|
||||
// new_low)` over one universe, `batch` over one such array group per tick.
|
||||
#[pyclass(
|
||||
name = "AdvanceDecline",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyAdvanceDecline {
|
||||
inner: wc::AdvanceDecline,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyAdvanceDecline {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::AdvanceDecline::new(),
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
change: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
new_high: Vec<bool>,
|
||||
new_low: Vec<bool>,
|
||||
) -> PyResult<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_cross_section(&change, &volume, &new_high, &new_low)?))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
change: Vec<Vec<f64>>,
|
||||
volume: Vec<Vec<f64>>,
|
||||
new_high: Vec<Vec<bool>>,
|
||||
new_low: Vec<Vec<bool>>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if change.len() != volume.len()
|
||||
|| change.len() != new_high.len()
|
||||
|| change.len() != new_low.len()
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"change, volume, new_high and new_low must have the same number of ticks",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(change.len());
|
||||
for i in 0..change.len() {
|
||||
let section = build_cross_section(&change[i], &volume[i], &new_high[i], &new_low[i])?;
|
||||
out.push(self.inner.update(section).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
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 {
|
||||
"AdvanceDecline()".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
|
||||
@@ -15676,6 +16454,16 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyLeadLagCrossCorrelation>()?;
|
||||
m.add_class::<PyCointegration>()?;
|
||||
m.add_class::<PyRelativeStrengthAB>()?;
|
||||
m.add_class::<PyRollingCorrelation>()?;
|
||||
m.add_class::<PyRollingCovariance>()?;
|
||||
m.add_class::<PyOuHalfLife>()?;
|
||||
m.add_class::<PySpreadHurst>()?;
|
||||
m.add_class::<PyDistanceSsd>()?;
|
||||
m.add_class::<PyBetaNeutralSpread>()?;
|
||||
m.add_class::<PyVarianceRatio>()?;
|
||||
m.add_class::<PyGrangerCausality>()?;
|
||||
m.add_class::<PyKalmanHedgeRatio>()?;
|
||||
m.add_class::<PySpreadBollingerBands>()?;
|
||||
m.add_class::<PySpearmanCorrelation>()?;
|
||||
m.add_class::<PyValueArea>()?;
|
||||
m.add_class::<PyVolumeProfile>()?;
|
||||
@@ -15776,6 +16564,7 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyLiquidationFeatures>()?;
|
||||
m.add_class::<PyTermStructureBasis>()?;
|
||||
m.add_class::<PyCalendarSpread>()?;
|
||||
m.add_class::<PyAdvanceDecline>()?;
|
||||
// Family 15: Risk / Performance metrics.
|
||||
m.add_class::<PySharpeRatio>()?;
|
||||
m.add_class::<PySortinoRatio>()?;
|
||||
|
||||
@@ -167,6 +167,14 @@ def test_scalar_streaming_matches_batch(cls, args, sine_prices):
|
||||
# --- Two-series (asset, benchmark) indicators -----------------------------
|
||||
|
||||
PAIR = [
|
||||
(ta.GrangerCausality, (60, 1)),
|
||||
(ta.VarianceRatio, (60, 2)),
|
||||
(ta.BetaNeutralSpread, (20,)),
|
||||
(ta.DistanceSsd, (20,)),
|
||||
(ta.SpreadHurst, (60,)),
|
||||
(ta.OuHalfLife, (60,)),
|
||||
(ta.RollingCovariance, (20,)),
|
||||
(ta.RollingCorrelation, (20,)),
|
||||
(ta.TreynorRatio, (20, 0.0)),
|
||||
(ta.InformationRatio, (20,)),
|
||||
(ta.Alpha, (20, 0.0)),
|
||||
@@ -251,6 +259,42 @@ def test_cointegration_streaming_matches_batch():
|
||||
assert math.isclose(batch[i, 2], adf, rel_tol=1e-12, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_kalman_hedge_ratio_converges_and_streaming_matches_batch():
|
||||
n = 500
|
||||
b = np.array([100.0 + 95.0 * math.sin(t * 0.5) for t in range(n)])
|
||||
a = 2.0 * b + 5.0 # a = 2*b + 5 with a wide-ranging b ⇒ identifiable
|
||||
batch = ta.KalmanHedgeRatio(1e-2, 1e-3).batch(a, b)
|
||||
assert batch.shape == (n, 3)
|
||||
assert abs(batch[-1, 0] - 2.0) < 0.05 # hedge ratio
|
||||
assert abs(batch[-1, 2]) < 0.05 # spread (forecast error)
|
||||
streamer = ta.KalmanHedgeRatio(1e-2, 1e-3)
|
||||
for i in range(n):
|
||||
hr, ic, sp = streamer.update(float(a[i]), float(b[i]))
|
||||
assert math.isclose(batch[i, 0], hr, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 1], ic, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 2], sp, rel_tol=1e-12, abs_tol=1e-12)
|
||||
|
||||
|
||||
def test_spread_bollinger_bands_streaming_matches_batch():
|
||||
n = 60
|
||||
b = np.array([100.0 + t for t in range(n)])
|
||||
a = b + 3.0 * np.sin(np.arange(n) * 0.4)
|
||||
batch = ta.SpreadBollingerBands(20, 2.0).batch(a, b)
|
||||
assert batch.shape == (n, 4)
|
||||
streamer = ta.SpreadBollingerBands(20, 2.0)
|
||||
for i in range(n):
|
||||
v = streamer.update(float(a[i]), float(b[i]))
|
||||
if v is None:
|
||||
assert np.all(np.isnan(batch[i]))
|
||||
else:
|
||||
mid, up, lo, pct_b = v
|
||||
assert math.isclose(batch[i, 0], mid, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 1], up, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 2], lo, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert math.isclose(batch[i, 3], pct_b, rel_tol=1e-12, abs_tol=1e-12)
|
||||
assert lo <= mid <= up
|
||||
|
||||
|
||||
def test_relative_strength_constant_ratio():
|
||||
n = 30
|
||||
a = np.full(n, 200.0)
|
||||
@@ -2262,6 +2306,54 @@ def test_concealing_baby_swallow_reference():
|
||||
assert t.update((11.0, 13.0, 9.9, 10.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((14.0, 14.1, 8.9, 9.0, 1.0, 3)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_rolling_correlation_reference():
|
||||
t = ta.RollingCorrelation(20)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
|
||||
def test_rolling_covariance_reference():
|
||||
t = ta.RollingCovariance(20)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
|
||||
def test_ou_half_life_reference():
|
||||
t = ta.OuHalfLife(60)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
|
||||
def test_spread_hurst_reference():
|
||||
t = ta.SpreadHurst(60)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
|
||||
def test_distance_ssd_reference():
|
||||
t = ta.DistanceSsd(20)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
|
||||
def test_beta_neutral_spread_reference():
|
||||
t = ta.BetaNeutralSpread(20)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
|
||||
def test_variance_ratio_reference():
|
||||
t = ta.VarianceRatio(60, 2)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
|
||||
def test_granger_causality_reference():
|
||||
t = ta.GrangerCausality(60, 1)
|
||||
assert t.update(1.0, 1.0) is None
|
||||
assert t.update(2.0, 1.5) is None
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2659,6 +2751,38 @@ def test_funding_indicators_streaming_equals_batch():
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_advance_decline_streaming_equals_batch():
|
||||
# Three ticks over a universe of four symbols; the sign of `change`
|
||||
# classifies each symbol as advancing / declining / unchanged.
|
||||
change = [
|
||||
[1.0, 0.5, 2.0, -1.0], # 3 up, 1 down -> net +2
|
||||
[-1.0, -0.5, -2.0, 1.0], # 1 up, 3 down -> net -2
|
||||
[0.0, 0.0, 1.0, -1.0], # 1 up, 1 down -> net 0
|
||||
]
|
||||
volume = [[10.0] * 4 for _ in range(3)]
|
||||
new_high = [[False] * 4 for _ in range(3)]
|
||||
new_low = [[False] * 4 for _ in range(3)]
|
||||
batch = ta.AdvanceDecline().batch(change, volume, new_high, new_low)
|
||||
streamer = ta.AdvanceDecline()
|
||||
streamed = np.array(
|
||||
[
|
||||
streamer.update(change[i], volume[i], new_high[i], new_low[i])
|
||||
for i in range(3)
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert batch.shape == (3,)
|
||||
assert _eq_nan(batch, streamed)
|
||||
# Cumulative line: +2 -> 0 -> 0.
|
||||
assert list(batch) == [2.0, 0.0, 0.0]
|
||||
|
||||
|
||||
def test_advance_decline_rejects_ragged_universe():
|
||||
ad = ta.AdvanceDecline()
|
||||
with pytest.raises(ValueError):
|
||||
ad.update([1.0, -1.0], [10.0], [False, False], [False, False])
|
||||
|
||||
|
||||
def test_funding_basis_streaming_equals_batch():
|
||||
n = 40
|
||||
index = np.array([100.0 + 0.5 * math.sin(i * 0.2) for i in range(n)], dtype=np.float64)
|
||||
|
||||
@@ -531,6 +531,24 @@ wasm_pair_indicator!(
|
||||
"SpearmanCorrelation",
|
||||
wc::SpearmanCorrelation
|
||||
);
|
||||
wasm_pair_indicator!(
|
||||
WasmRollingCorrelation,
|
||||
"RollingCorrelation",
|
||||
wc::RollingCorrelation
|
||||
);
|
||||
wasm_pair_indicator!(
|
||||
WasmRollingCovariance,
|
||||
"RollingCovariance",
|
||||
wc::RollingCovariance
|
||||
);
|
||||
wasm_pair_indicator!(WasmOuHalfLife, "OuHalfLife", wc::OuHalfLife);
|
||||
wasm_pair_indicator!(WasmSpreadHurst, "SpreadHurst", wc::SpreadHurst);
|
||||
wasm_pair_indicator!(WasmDistanceSsd, "DistanceSsd", wc::DistanceSsd);
|
||||
wasm_pair_indicator!(
|
||||
WasmBetaNeutralSpread,
|
||||
"BetaNeutralSpread",
|
||||
wc::BetaNeutralSpread
|
||||
);
|
||||
|
||||
// ---------- PairSpreadZScore (two params) ----------
|
||||
|
||||
@@ -748,6 +766,210 @@ impl WasmRelativeStrengthAb {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- VarianceRatio (two params) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "VarianceRatio")]
|
||||
pub struct WasmVarianceRatio {
|
||||
inner: wc::VarianceRatio,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "VarianceRatio")]
|
||||
impl WasmVarianceRatio {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, q: usize) -> Result<WasmVarianceRatio, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::VarianceRatio::new(period, q).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
|
||||
/// input position (`NaN` during warmup).
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(a.len());
|
||||
for i in 0..a.len() {
|
||||
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- GrangerCausality (two params) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "GrangerCausality")]
|
||||
pub struct WasmGrangerCausality {
|
||||
inner: wc::GrangerCausality,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "GrangerCausality")]
|
||||
impl WasmGrangerCausality {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, lag: usize) -> Result<WasmGrangerCausality, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::GrangerCausality::new(period, lag).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, a: f64, b: f64) -> Option<f64> {
|
||||
self.inner.update((a, b))
|
||||
}
|
||||
/// Batch over two equally-sized arrays of prices. Returns one `f64` per
|
||||
/// input position (`NaN` during warmup).
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(a.len());
|
||||
for i in 0..a.len() {
|
||||
out.push(self.inner.update((a[i], b[i])).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- KalmanHedgeRatio (two params, object output) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "KalmanHedgeRatio")]
|
||||
pub struct WasmKalmanHedgeRatio {
|
||||
inner: wc::KalmanHedgeRatio,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "KalmanHedgeRatio")]
|
||||
impl WasmKalmanHedgeRatio {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(delta: f64, observation_var: f64) -> Result<WasmKalmanHedgeRatio, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::KalmanHedgeRatio::new(delta, observation_var).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `{ hedgeRatio, intercept, spread }`, or `null` during warmup.
|
||||
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
|
||||
match self.inner.update((a, b)) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"hedgeRatio".into(), &o.hedge_ratio.into()).ok();
|
||||
Reflect::set(&obj, &"intercept".into(), &o.intercept.into()).ok();
|
||||
Reflect::set(&obj, &"spread".into(), &o.spread.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
/// Flat `Float64Array` of length `3 * n`:
|
||||
/// `[hedgeRatio0, intercept0, spread0, hedgeRatio1, ...]`. Warmup rows are NaN.
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let n = a.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 3] = o.hedge_ratio;
|
||||
out[i * 3 + 1] = o.intercept;
|
||||
out[i * 3 + 2] = o.spread;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SpreadBollingerBands (two params, object output) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = "SpreadBollingerBands")]
|
||||
pub struct WasmSpreadBollingerBands {
|
||||
inner: wc::SpreadBollingerBands,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = "SpreadBollingerBands")]
|
||||
impl WasmSpreadBollingerBands {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, num_std: f64) -> Result<WasmSpreadBollingerBands, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::SpreadBollingerBands::new(period, num_std).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
/// Returns `{ middle, upper, lower, percentB }`, or `null` during warmup.
|
||||
pub fn update(&mut self, a: f64, b: f64) -> JsValue {
|
||||
match self.inner.update((a, b)) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"middle".into(), &o.middle.into()).ok();
|
||||
Reflect::set(&obj, &"upper".into(), &o.upper.into()).ok();
|
||||
Reflect::set(&obj, &"lower".into(), &o.lower.into()).ok();
|
||||
Reflect::set(&obj, &"percentB".into(), &o.percent_b.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
}
|
||||
}
|
||||
/// Flat `Float64Array` of length `4 * n`:
|
||||
/// `[middle0, upper0, lower0, percentB0, middle1, ...]`. Warmup rows are NaN.
|
||||
pub fn batch(&mut self, a: &[f64], b: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if a.len() != b.len() {
|
||||
return Err(JsError::new("a and b must be equal length"));
|
||||
}
|
||||
let n = a.len();
|
||||
let mut out = vec![f64::NAN; n * 4];
|
||||
for i in 0..n {
|
||||
if let Some(o) = self.inner.update((a[i], b[i])) {
|
||||
out[i * 4] = o.middle;
|
||||
out[i * 4 + 1] = o.upper;
|
||||
out[i * 4 + 2] = o.lower;
|
||||
out[i * 4 + 3] = o.percent_b;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- KAMA (three params) ----------
|
||||
|
||||
#[wasm_bindgen(js_name = KAMA)]
|
||||
@@ -8143,6 +8365,78 @@ impl WasmCalendarSpread {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Market Breadth (CrossSection input) ----------
|
||||
//
|
||||
// A breadth tick is the per-symbol state of the whole universe, passed as four
|
||||
// equal-length parallel arrays (`change`, `volume`, `newHigh`, `newLow`). The
|
||||
// high/low flag arrays are numeric (non-zero is true) so the whole tick crosses
|
||||
// the wasm boundary as `Float64Array`s. The universe is ragged across ticks, so
|
||||
// only `update` is exposed (no `batch`), matching the other multi-input wasm
|
||||
// indicators.
|
||||
|
||||
fn build_cross_section(
|
||||
change: &[f64],
|
||||
volume: &[f64],
|
||||
new_high: &[f64],
|
||||
new_low: &[f64],
|
||||
) -> Result<wc::CrossSection, JsError> {
|
||||
if change.len() != volume.len()
|
||||
|| change.len() != new_high.len()
|
||||
|| change.len() != new_low.len()
|
||||
{
|
||||
return Err(JsError::new(
|
||||
"change, volume, newHigh and newLow must be equal length",
|
||||
));
|
||||
}
|
||||
let members = (0..change.len())
|
||||
.map(|i| wc::Member::new(change[i], volume[i], new_high[i] != 0.0, new_low[i] != 0.0))
|
||||
.collect();
|
||||
wc::CrossSection::new(members, 0).map_err(map_err)
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = AdvanceDecline)]
|
||||
pub struct WasmAdvanceDecline {
|
||||
inner: wc::AdvanceDecline,
|
||||
}
|
||||
|
||||
impl Default for WasmAdvanceDecline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = AdvanceDecline)]
|
||||
impl WasmAdvanceDecline {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmAdvanceDecline {
|
||||
Self {
|
||||
inner: wc::AdvanceDecline::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
change: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
new_high: Vec<f64>,
|
||||
new_low: Vec<f64>,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_cross_section(&change, &volume, &new_high, &new_low)?))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Cross-section value type: a market-breadth snapshot across a whole universe.
|
||||
//!
|
||||
//! A [`CrossSection`] is a single tick that carries the per-symbol state of
|
||||
//! *every* symbol in a universe at one point in time. It is the non-OHLCV input
|
||||
//! consumed by the market-breadth indicator family (advance/decline, `McClellan`,
|
||||
//! the TRIN / Arms index, the high-low index, ...), each of which aggregates the
|
||||
//! whole cross-section into a single breadth reading. This is the same
|
||||
//! one-rich-type-per-family pattern as [`DerivativesTick`] and [`OrderBook`].
|
||||
//!
|
||||
//! Each [`Member`] precomputes the per-symbol signals the breadth indicators
|
||||
//! need — a signed price `change` (whose sign classifies the symbol as
|
||||
//! advancing, declining or unchanged), the period `volume`, and the
|
||||
//! `new_high` / `new_low` extreme flags — so the indicators stay stateless per
|
||||
//! tick and never have to track per-symbol history.
|
||||
//!
|
||||
//! [`DerivativesTick`]: crate::DerivativesTick
|
||||
//! [`OrderBook`]: crate::OrderBook
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// One symbol's contribution to a [`CrossSection`] tick.
|
||||
///
|
||||
/// Field invariants enforced by [`CrossSection::new`] when the member is placed
|
||||
/// into a tick:
|
||||
///
|
||||
/// - `change` is finite (its sign classifies the symbol — positive is
|
||||
/// advancing, negative is declining, zero is unchanged).
|
||||
/// - `volume` is finite and non-negative.
|
||||
///
|
||||
/// `new_high` / `new_low` are caller-supplied flags marking whether the symbol
|
||||
/// printed a new period extreme; they carry no numeric invariant.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Member {
|
||||
/// Price change versus the previous close. Sign classifies the symbol:
|
||||
/// positive is advancing, negative is declining, zero is unchanged.
|
||||
pub change: f64,
|
||||
/// Period volume for the symbol (finite, non-negative).
|
||||
pub volume: f64,
|
||||
/// Whether the symbol printed a new period high.
|
||||
pub new_high: bool,
|
||||
/// Whether the symbol printed a new period low.
|
||||
pub new_low: bool,
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// Assemble a cross-section member.
|
||||
///
|
||||
/// The field invariants documented on [`Member`] are validated centrally by
|
||||
/// [`CrossSection::new`] when the member is placed into a tick; this
|
||||
/// constructor only assembles the value so the `#[non_exhaustive]` struct can
|
||||
/// be built from outside the crate.
|
||||
#[must_use]
|
||||
pub const fn new(change: f64, volume: f64, new_high: bool, new_low: bool) -> Self {
|
||||
Self {
|
||||
change,
|
||||
volume,
|
||||
new_high,
|
||||
new_low,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A market-breadth cross-section: the per-symbol state of an entire universe at
|
||||
/// a single point in time.
|
||||
///
|
||||
/// Invariants enforced by [`new`](CrossSection::new):
|
||||
///
|
||||
/// - `members` is non-empty (a breadth reading needs at least one symbol).
|
||||
/// - every member's `change` is finite, and `volume` is finite and non-negative.
|
||||
///
|
||||
/// `timestamp` is a caller-defined epoch / resolution and is not validated.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CrossSection {
|
||||
/// Per-symbol members of the universe for this tick.
|
||||
pub members: Vec<Member>,
|
||||
/// Tick timestamp (caller-defined epoch / resolution).
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
impl CrossSection {
|
||||
/// Construct a cross-section, validating every member invariant.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidCrossSection`] if `members` is empty, if any
|
||||
/// member has a non-finite `change`, or if any member has a `volume` that is
|
||||
/// not a finite non-negative number.
|
||||
pub fn new(members: Vec<Member>, timestamp: i64) -> Result<Self> {
|
||||
if members.is_empty() {
|
||||
return Err(Error::InvalidCrossSection {
|
||||
message: "cross-section must contain at least one member",
|
||||
});
|
||||
}
|
||||
for member in &members {
|
||||
if !member.change.is_finite() {
|
||||
return Err(Error::InvalidCrossSection {
|
||||
message: "member change must be finite",
|
||||
});
|
||||
}
|
||||
if !member.volume.is_finite() || member.volume < 0.0 {
|
||||
return Err(Error::InvalidCrossSection {
|
||||
message: "member volume must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self { members, timestamp })
|
||||
}
|
||||
|
||||
/// Construct a cross-section without validation. The caller asserts that
|
||||
/// every invariant documented on [`CrossSection`] holds.
|
||||
#[must_use]
|
||||
pub const fn new_unchecked(members: Vec<Member>, timestamp: i64) -> Self {
|
||||
Self { members, timestamp }
|
||||
}
|
||||
|
||||
/// Number of advancing symbols (those with a strictly positive `change`).
|
||||
#[must_use]
|
||||
pub fn advancers(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.change > 0.0).count()
|
||||
}
|
||||
|
||||
/// Number of declining symbols (those with a strictly negative `change`).
|
||||
#[must_use]
|
||||
pub fn decliners(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.change < 0.0).count()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn members() -> Vec<Member> {
|
||||
vec![
|
||||
Member::new(1.5, 100.0, true, false),
|
||||
Member::new(-0.5, 50.0, false, true),
|
||||
Member::new(0.0, 0.0, false, false),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_accepts_valid() {
|
||||
let cs = CrossSection::new(members(), 42).unwrap();
|
||||
assert_eq!(cs.members.len(), 3);
|
||||
assert_eq!(cs.timestamp, 42);
|
||||
assert_eq!(cs.members[0].change, 1.5);
|
||||
assert_eq!(cs.members[0].volume, 100.0);
|
||||
assert!(cs.members[0].new_high);
|
||||
assert!(cs.members[1].new_low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn member_new_assembles_fields() {
|
||||
let m = Member::new(2.0, 10.0, true, false);
|
||||
assert_eq!(m.change, 2.0);
|
||||
assert_eq!(m.volume, 10.0);
|
||||
assert!(m.new_high);
|
||||
assert!(!m.new_low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_empty() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(Vec::new(), 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_finite_change() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(f64::NAN, 10.0, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(f64::INFINITY, 10.0, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_negative_volume() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(1.0, -1.0, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_rejects_non_finite_volume() {
|
||||
assert!(matches!(
|
||||
CrossSection::new(vec![Member::new(1.0, f64::NAN, false, false)], 0),
|
||||
Err(Error::InvalidCrossSection { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_unchecked_skips_validation() {
|
||||
let cs = CrossSection::new_unchecked(vec![Member::new(f64::NAN, -1.0, false, false)], 7);
|
||||
assert_eq!(cs.members.len(), 1);
|
||||
assert_eq!(cs.timestamp, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advancers_and_decliners_count_by_sign() {
|
||||
let cs = CrossSection::new(members(), 0).unwrap();
|
||||
assert_eq!(cs.advancers(), 1);
|
||||
assert_eq!(cs.decliners(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_members_count_as_neither() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::new(0.0, 1.0, false, false),
|
||||
Member::new(0.0, 1.0, false, false),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.advancers(), 0);
|
||||
assert_eq!(cs.decliners(), 0);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,21 @@ pub enum Error {
|
||||
/// own variant.
|
||||
#[error("invalid derivatives tick: {message}")]
|
||||
InvalidDerivatives { message: &'static str },
|
||||
|
||||
/// A market-breadth cross-section whose members do not satisfy the
|
||||
/// cross-section invariants (an empty universe, a non-finite change, or a
|
||||
/// negative / non-finite volume) was provided. A cross-section is a
|
||||
/// breadth input distinct from candles, ticks, order books and trades, so
|
||||
/// it surfaces as its own variant.
|
||||
#[error("invalid cross-section: {message}")]
|
||||
InvalidCrossSection { message: &'static str },
|
||||
|
||||
/// A real-valued configuration parameter was outside its admissible range
|
||||
/// (e.g. a non-positive standard-deviation multiplier, or a Kalman filter
|
||||
/// covariance that is not strictly positive). This is the floating-point
|
||||
/// analogue of [`Error::InvalidPeriod`], which only covers integer windows.
|
||||
#[error("invalid parameter: {message}")]
|
||||
InvalidParameter { message: &'static str },
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, wickra_core::Error>`.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Advance/Decline Line — cumulative net advancing-minus-declining issues.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Advance/Decline Line (A/D Line) — the running cumulative sum of net advancing
|
||||
/// issues across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the net breadth is `advancers - decliners`:
|
||||
/// the number of symbols with a positive price change minus the number with a
|
||||
/// negative change (unchanged symbols are ignored). The line accumulates this
|
||||
/// net value over time, so a rising line means advancers have persistently
|
||||
/// outnumbered decliners — broad participation — while a falling line warns that
|
||||
/// a rally is being carried by fewer and fewer names (a breadth divergence when
|
||||
/// the index itself is still rising).
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`. The line is defined from the very
|
||||
/// first tick, so `warmup_period == 1` and the indicator is ready after one
|
||||
/// update.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AdvanceDecline, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut ad = AdvanceDecline::new();
|
||||
/// // 3 advancers, 1 decliner -> net +2.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(0.5, 10.0, false, false),
|
||||
/// Member::new(2.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(ad.update(tick), Some(2.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdvanceDecline {
|
||||
line: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdvanceDecline {
|
||||
/// Construct a new Advance/Decline Line indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
line: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdvanceDecline {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let net = section.advancers() as f64 - section.decliners() as f64;
|
||||
self.line += net;
|
||||
self.has_emitted = true;
|
||||
Some(self.line)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.line = 0.0;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdvanceDecline"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
/// Build a cross-section with `up` advancers, `down` decliners and `flat`
|
||||
/// unchanged symbols.
|
||||
fn section(up: usize, down: usize, flat: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..up {
|
||||
members.push(Member::new(1.0, 10.0, false, false));
|
||||
}
|
||||
for _ in 0..down {
|
||||
members.push(Member::new(-1.0, 10.0, false, false));
|
||||
}
|
||||
for _ in 0..flat {
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
}
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ad = AdvanceDecline::new();
|
||||
assert_eq!(ad.name(), "AdvanceDecline");
|
||||
assert_eq!(ad.warmup_period(), 1);
|
||||
assert!(!ad.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_net_breadth() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0));
|
||||
assert!(ad.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_accumulates_across_ticks() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
assert_eq!(ad.update(section(3, 1, 0)), Some(2.0)); // +2 -> 2
|
||||
assert_eq!(ad.update(section(1, 4, 0)), Some(-1.0)); // -3 -> -1
|
||||
assert_eq!(ad.update(section(2, 0, 0)), Some(1.0)); // +2 -> 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_symbols_are_ignored() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
// 2 up, 2 down, 5 unchanged -> net 0, line stays flat.
|
||||
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
|
||||
assert_eq!(ad.update(section(2, 2, 5)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ad = AdvanceDecline::new();
|
||||
ad.update(section(5, 0, 0));
|
||||
assert!(ad.is_ready());
|
||||
ad.reset();
|
||||
assert!(!ad.is_ready());
|
||||
// Line restarts from zero, not from the pre-reset value.
|
||||
assert_eq!(ad.update(section(1, 0, 0)), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![
|
||||
section(3, 1, 2),
|
||||
section(1, 4, 0),
|
||||
section(2, 2, 1),
|
||||
section(5, 0, 3),
|
||||
];
|
||||
let mut a = AdvanceDecline::new();
|
||||
let mut b = AdvanceDecline::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Beta-neutral spread: the rolling OLS regression residual of two series.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The beta-neutral spread between two assets — the residual of a rolling
|
||||
/// ordinary-least-squares regression of `a` on `b`.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
|
||||
/// `period` pairs the indicator fits the hedge ratio `β` (and intercept `α`) by
|
||||
/// OLS and reports the **current** residual:
|
||||
///
|
||||
/// ```text
|
||||
/// β = cov(a, b) / var(b) α = ā − β · b̄
|
||||
/// spread = a_now − (α + β · b_now)
|
||||
/// ```
|
||||
///
|
||||
/// Subtracting `β · b` removes `a`'s exposure to `b`, so the spread is market-
|
||||
/// (beta-)neutral: it is what is left after the common factor is hedged out.
|
||||
/// Positive means `a` is rich relative to its hedge, negative means cheap — the
|
||||
/// raw signal a pairs trade fades. Where [`crate::PairSpreadZScore`] standardises
|
||||
/// this residual into a z-score and [`crate::Cointegration`] bundles it with an
|
||||
/// ADF test, this indicator returns the residual itself, in price units.
|
||||
///
|
||||
/// If `b` is flat over the window (`var(b) = 0`) there is no defined slope; the
|
||||
/// indicator falls back to `β = 0`, so the spread becomes `a_now − ā`.
|
||||
///
|
||||
/// Each `update` is `O(1)`: four running sums (`Σa`, `Σb`, `Σb²`, `Σab`) are
|
||||
/// maintained as the window slides.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BetaNeutralSpread, Indicator};
|
||||
///
|
||||
/// let mut s = BetaNeutralSpread::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..40 {
|
||||
/// let b = 100.0 + f64::from(t);
|
||||
/// // a = 2·b + 5 exactly ⇒ the regression explains a fully ⇒ spread ≈ 0.
|
||||
/// last = s.update((2.0 * b + 5.0, b));
|
||||
/// }
|
||||
/// assert!(last.unwrap().abs() < 1e-6);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BetaNeutralSpread {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_a: f64,
|
||||
sum_b: f64,
|
||||
sum_bb: f64,
|
||||
sum_ab: f64,
|
||||
}
|
||||
|
||||
impl BetaNeutralSpread {
|
||||
/// Construct a new beta-neutral spread.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a regression slope
|
||||
/// needs at least two points.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "beta-neutral spread needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_a: 0.0,
|
||||
sum_b: 0.0,
|
||||
sum_bb: 0.0,
|
||||
sum_ab: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured look-back window.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BetaNeutralSpread {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if self.window.len() == self.period {
|
||||
let (oa, ob) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_a -= oa;
|
||||
self.sum_b -= ob;
|
||||
self.sum_bb -= ob * ob;
|
||||
self.sum_ab -= oa * ob;
|
||||
}
|
||||
self.window.push_back((a, b));
|
||||
self.sum_a += a;
|
||||
self.sum_b += b;
|
||||
self.sum_bb += b * b;
|
||||
self.sum_ab += a * b;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean_a = self.sum_a / n;
|
||||
let mean_b = self.sum_b / n;
|
||||
let var_b = (self.sum_bb / n - mean_b * mean_b).max(0.0);
|
||||
let (beta, intercept) = if var_b == 0.0 {
|
||||
(0.0, mean_a)
|
||||
} else {
|
||||
let cov = self.sum_ab / n - mean_a * mean_b;
|
||||
let slope = cov / var_b;
|
||||
(slope, mean_a - slope * mean_b)
|
||||
};
|
||||
Some(a - (intercept + beta * b))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_a = 0.0;
|
||||
self.sum_b = 0.0;
|
||||
self.sum_bb = 0.0;
|
||||
self.sum_ab = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BetaNeutralSpread"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(BetaNeutralSpread::new(1).is_err());
|
||||
assert!(BetaNeutralSpread::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = BetaNeutralSpread::new(20).unwrap();
|
||||
assert_eq!(s.period(), 20);
|
||||
assert_eq!(s.warmup_period(), 20);
|
||||
assert_eq!(s.name(), "BetaNeutralSpread");
|
||||
assert!(!s.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut s = BetaNeutralSpread::new(3).unwrap();
|
||||
assert_eq!(s.update((1.0, 1.0)), None);
|
||||
assert_eq!(s.update((2.0, 2.0)), None);
|
||||
assert!(s.update((3.0, 3.0)).is_some());
|
||||
assert!(s.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perfect_linear_relationship_has_zero_spread() {
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
(2.0 * b + 5.0, b)
|
||||
})
|
||||
.collect();
|
||||
let last = BetaNeutralSpread::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dislocation_produces_nonzero_spread() {
|
||||
// a tracks 2·b, then the last bar jumps up ⇒ positive residual.
|
||||
let mut pairs: Vec<(f64, f64)> = (0..19)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
(2.0 * b + 5.0, b)
|
||||
})
|
||||
.collect();
|
||||
pairs.push((2.0 * 119.0 + 5.0 + 10.0, 119.0));
|
||||
let last = BetaNeutralSpread::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 1.0, "spread {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_b_falls_back_to_demeaned_a() {
|
||||
// b constant ⇒ β = 0 ⇒ spread = a − mean(a). Last window of a = 0..9,
|
||||
// mean = 4.5, last a = 9 ⇒ spread = 4.5.
|
||||
let pairs: Vec<(f64, f64)> = (0..10).map(|t| (f64::from(t), 7.0)).collect();
|
||||
let last = BetaNeutralSpread::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 4.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = BetaNeutralSpread::new(4).unwrap();
|
||||
s.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 5.0), (4.0, 9.0), (5.0, 2.0)]);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|t| {
|
||||
let b = 30.0 + 0.7 * f64::from(t);
|
||||
(1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
|
||||
})
|
||||
.collect();
|
||||
let batch = BetaNeutralSpread::new(20).unwrap().batch(&pairs);
|
||||
let mut s = BetaNeutralSpread::new(20).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| s.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Gatev distance (sum of squared deviations) between two normalised series.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Sum of squared deviations between two price series, normalised to a common
|
||||
/// start — the classic Gatev et al. pairs-selection distance.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` price pair. Over the trailing window of
|
||||
/// `period` pairs each series is rebased to `1` at the window's first bar and
|
||||
/// the squared gap between the two normalised paths is summed:
|
||||
///
|
||||
/// ```text
|
||||
/// ãᵢ = aᵢ / a_first b̃ᵢ = bᵢ / b_first
|
||||
/// SSD = Σ (ãᵢ − b̃ᵢ)²
|
||||
/// ```
|
||||
///
|
||||
/// Rebasing puts the two series on the same scale (both start at `1`), so the
|
||||
/// distance measures how far their *relative* paths drift apart. A **small**
|
||||
/// SSD means the two assets track each other tightly — the screen Gatev,
|
||||
/// Goetzmann and Rouwenhorst use to pick tradeable pairs; a large SSD means
|
||||
/// they have decoupled. The output is always `≥ 0`. If either series is `0` at
|
||||
/// the start of the window the normalisation is undefined and the indicator
|
||||
/// returns `0`.
|
||||
///
|
||||
/// Each `update` is `O(period)`, bounded by the fixed window.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DistanceSsd, Indicator};
|
||||
///
|
||||
/// let mut d = DistanceSsd::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..40 {
|
||||
/// let base = 100.0 + f64::from(t);
|
||||
/// // Two near-identical paths ⇒ tiny distance.
|
||||
/// last = d.update((base, base * 1.0001));
|
||||
/// }
|
||||
/// assert!(last.unwrap() < 1e-3);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DistanceSsd {
|
||||
period: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl DistanceSsd {
|
||||
/// Construct a new Gatev distance estimator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — a distance needs at
|
||||
/// least two points.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "distance SSD needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured look-back window.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DistanceSsd {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let &(a_first, b_first) = self.window.front().expect("window is full");
|
||||
if a_first == 0.0 || b_first == 0.0 {
|
||||
// Cannot rebase a series that starts at zero.
|
||||
return Some(0.0);
|
||||
}
|
||||
let ssd = self
|
||||
.window
|
||||
.iter()
|
||||
.map(|&(a, b)| {
|
||||
let gap = a / a_first - b / b_first;
|
||||
gap * gap
|
||||
})
|
||||
.sum();
|
||||
Some(ssd)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DistanceSsd"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(DistanceSsd::new(1).is_err());
|
||||
assert!(DistanceSsd::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let d = DistanceSsd::new(20).unwrap();
|
||||
assert_eq!(d.period(), 20);
|
||||
assert_eq!(d.warmup_period(), 20);
|
||||
assert_eq!(d.name(), "DistanceSsd");
|
||||
assert!(!d.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut d = DistanceSsd::new(3).unwrap();
|
||||
assert_eq!(d.update((1.0, 1.0)), None);
|
||||
assert_eq!(d.update((2.0, 2.0)), None);
|
||||
assert!(d.update((3.0, 3.0)).is_some());
|
||||
assert!(d.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_normalised_paths_have_zero_distance() {
|
||||
// b = 2·a ⇒ both rebase to the same path ⇒ SSD = 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|t| {
|
||||
let a = 100.0 + f64::from(t);
|
||||
(a, 2.0 * a)
|
||||
})
|
||||
.collect();
|
||||
let last = DistanceSsd::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diverging_paths_have_positive_distance() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|t| (100.0 + f64::from(t), 100.0 + 3.0 * f64::from(t)))
|
||||
.collect();
|
||||
let last = DistanceSsd::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 0.0, "ssd {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_computed_value() {
|
||||
// Window of three pairs, a_first = b_first = 1:
|
||||
// (1,1) → 0; (2,4) → (2−4)² = 4; (3,9) → (3−9)² = 36 ⇒ SSD = 40.
|
||||
let pairs = [(1.0, 1.0), (2.0, 4.0), (3.0, 9.0)];
|
||||
let last = DistanceSsd::new(3)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 40.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_start_returns_zero() {
|
||||
// First bar of the window has a = 0 ⇒ rebasing undefined ⇒ 0.
|
||||
let pairs = [(0.0, 1.0), (2.0, 2.0), (3.0, 3.0)];
|
||||
let last = DistanceSsd::new(3)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut d = DistanceSsd::new(4).unwrap();
|
||||
d.batch(&[(1.0, 1.0), (2.0, 2.0), (3.0, 4.0), (4.0, 5.0), (5.0, 6.0)]);
|
||||
assert!(d.is_ready());
|
||||
d.reset();
|
||||
assert!(!d.is_ready());
|
||||
assert_eq!(d.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|t| {
|
||||
let a = 100.0 + f64::from(t);
|
||||
(a, 100.0 + 1.2 * f64::from(t) + (f64::from(t) * 0.5).sin())
|
||||
})
|
||||
.collect();
|
||||
let batch = DistanceSsd::new(15).unwrap().batch(&pairs);
|
||||
let mut d = DistanceSsd::new(15).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| d.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! Granger causality F-statistic: does series `b` help predict series `a`?
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Granger causality of `b` on `a` over a rolling window, as an F-statistic.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` pair. Over the trailing window of `period`
|
||||
/// observations the indicator fits two autoregressions of `a` and compares them
|
||||
/// with an F-test:
|
||||
///
|
||||
/// ```text
|
||||
/// restricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ (a's own lags only)
|
||||
/// unrestricted: aₜ = c + Σ φᵢ·aₜ₋ᵢ + Σ ψᵢ·bₜ₋ᵢ (+ b's lags)
|
||||
/// F = ((RSSᵣ − RSSᵤ) / lag) / (RSSᵤ / (n − 2·lag − 1))
|
||||
/// ```
|
||||
///
|
||||
/// If adding `b`'s lags significantly reduces the residual sum of squares, `b`
|
||||
/// **Granger-causes** `a`: past values of `b` carry information about the future
|
||||
/// of `a` beyond what `a`'s own past holds. A **larger** F means stronger
|
||||
/// predictive causality (lead–lag structure a stat-arb model can trade); a
|
||||
/// value near `0` means `b` adds nothing. Note Granger causality is purely
|
||||
/// predictive — it is not structural cause and effect.
|
||||
///
|
||||
/// The statistic is `0` when a regression is degenerate — a collinear or flat
|
||||
/// window makes the normal equations singular. The output is always `≥ 0`.
|
||||
///
|
||||
/// Each `update` is `O(period · lag² + lag³)`, bounded by the fixed parameters.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{GrangerCausality, Indicator};
|
||||
///
|
||||
/// let mut g = GrangerCausality::new(60, 1).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..120 {
|
||||
/// let drive = (f64::from(t) * 0.3).sin();
|
||||
/// // a echoes b's previous value plus noise ⇒ b Granger-causes a.
|
||||
/// let b = drive;
|
||||
/// let a = 0.5 * (f64::from(t.max(1) - 1) * 0.3).sin() + 0.1 * (f64::from(t) * 0.9).cos();
|
||||
/// last = g.update((a, b));
|
||||
/// }
|
||||
/// assert!(last.unwrap() >= 0.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrangerCausality {
|
||||
period: usize,
|
||||
lag: usize,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl GrangerCausality {
|
||||
/// Construct a new Granger causality test.
|
||||
///
|
||||
/// `period` is the look-back window; `lag` is the autoregressive order
|
||||
/// (number of own/cross lags in each model).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `lag < 1` or if `period < 3·lag + 2`
|
||||
/// (the smallest window that leaves the unrestricted regression at least one
|
||||
/// residual degree of freedom).
|
||||
pub fn new(period: usize, lag: usize) -> Result<Self> {
|
||||
if lag < 1 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "granger causality needs lag >= 1",
|
||||
});
|
||||
}
|
||||
if period < 3 * lag + 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "granger causality needs period >= 3*lag + 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
lag,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured look-back window.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured autoregressive order.
|
||||
pub const fn lag(&self) -> usize {
|
||||
self.lag
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GrangerCausality {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(input);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let lag = self.lag;
|
||||
let a: Vec<f64> = self.window.iter().map(|&(av, _)| av).collect();
|
||||
let b: Vec<f64> = self.window.iter().map(|&(_, bv)| bv).collect();
|
||||
let num_obs = self.period - lag;
|
||||
|
||||
let mut target = Vec::with_capacity(num_obs);
|
||||
let mut restricted = Vec::with_capacity(num_obs);
|
||||
let mut unrestricted = Vec::with_capacity(num_obs);
|
||||
for k in 0..num_obs {
|
||||
let now = lag + k;
|
||||
target.push(a[now]);
|
||||
let mut row_r = Vec::with_capacity(lag + 1);
|
||||
row_r.push(1.0);
|
||||
for back in 1..=lag {
|
||||
row_r.push(a[now - back]);
|
||||
}
|
||||
let mut row_u = row_r.clone();
|
||||
for back in 1..=lag {
|
||||
row_u.push(b[now - back]);
|
||||
}
|
||||
restricted.push(row_r);
|
||||
unrestricted.push(row_u);
|
||||
}
|
||||
|
||||
let Some(rss_r) = ols_rss(&restricted, &target, lag + 1) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let Some(rss_u) = ols_rss(&unrestricted, &target, 2 * lag + 1) else {
|
||||
return Some(0.0);
|
||||
};
|
||||
let dof = (num_obs - (2 * lag + 1)) as f64;
|
||||
let numerator = (rss_r - rss_u) / lag as f64;
|
||||
let denominator = rss_u / dof;
|
||||
Some((numerator / denominator).max(0.0))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GrangerCausality"
|
||||
}
|
||||
}
|
||||
|
||||
/// Residual sum of squares of the OLS fit of `target` on the design `rows`
|
||||
/// (each a length-`num_reg` regressor vector). Returns `None` if the normal
|
||||
/// equations are singular.
|
||||
fn ols_rss(rows: &[Vec<f64>], target: &[f64], num_reg: usize) -> Option<f64> {
|
||||
let mut xtx = vec![vec![0.0; num_reg]; num_reg];
|
||||
let mut xty = vec![0.0; num_reg];
|
||||
for (row, &observed) in rows.iter().zip(target) {
|
||||
for (ri, &left) in row.iter().enumerate() {
|
||||
xty[ri] += left * observed;
|
||||
for (ci, &right) in row.iter().enumerate() {
|
||||
xtx[ri][ci] += left * right;
|
||||
}
|
||||
}
|
||||
}
|
||||
let theta = solve(xtx, xty)?;
|
||||
let mut rss = 0.0;
|
||||
for (row, &observed) in rows.iter().zip(target) {
|
||||
let pred: f64 = row
|
||||
.iter()
|
||||
.zip(&theta)
|
||||
.map(|(coeff, value)| coeff * value)
|
||||
.sum();
|
||||
let resid = observed - pred;
|
||||
rss += resid * resid;
|
||||
}
|
||||
Some(rss)
|
||||
}
|
||||
|
||||
/// Solve the linear system `mat·x = rhs` by Gaussian elimination, returning
|
||||
/// `None` if the matrix is (numerically) singular. `mat` is row-major.
|
||||
fn solve(mut mat: Vec<Vec<f64>>, mut rhs: Vec<f64>) -> Option<Vec<f64>> {
|
||||
let dim = rhs.len();
|
||||
for col in 0..dim {
|
||||
let pivot = mat[col][col];
|
||||
if pivot.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let pivot_row = mat[col].clone();
|
||||
for row in (col + 1)..dim {
|
||||
let factor = mat[row][col] / pivot;
|
||||
for (cell, &above) in mat[row].iter_mut().zip(&pivot_row).skip(col) {
|
||||
*cell -= factor * above;
|
||||
}
|
||||
rhs[row] -= factor * rhs[col];
|
||||
}
|
||||
}
|
||||
let mut sol = vec![0.0; dim];
|
||||
for row in (0..dim).rev() {
|
||||
let known: f64 = mat[row]
|
||||
.iter()
|
||||
.zip(&sol)
|
||||
.skip(row + 1)
|
||||
.map(|(coeff, value)| coeff * value)
|
||||
.sum();
|
||||
sol[row] = (rhs[row] - known) / mat[row][row];
|
||||
}
|
||||
Some(sol)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_parameters() {
|
||||
assert!(GrangerCausality::new(10, 0).is_err()); // lag must be >= 1
|
||||
assert!(GrangerCausality::new(4, 1).is_err()); // period must be >= 3*lag + 2
|
||||
assert!(GrangerCausality::new(5, 1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let g = GrangerCausality::new(60, 2).unwrap();
|
||||
assert_eq!(g.period(), 60);
|
||||
assert_eq!(g.lag(), 2);
|
||||
assert_eq!(g.warmup_period(), 60);
|
||||
assert_eq!(g.name(), "GrangerCausality");
|
||||
assert!(!g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut g = GrangerCausality::new(5, 1).unwrap();
|
||||
for t in 0..4 {
|
||||
assert_eq!(g.update((f64::from(t), f64::from(t) * 0.5)), None);
|
||||
}
|
||||
assert!(g.update((4.0, 2.0)).is_some());
|
||||
assert!(g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn b_leading_a_has_positive_statistic() {
|
||||
// a[t] is driven by b[t-1] plus a little of its own past ⇒ b helps.
|
||||
let mut prev_drive = 0.0;
|
||||
let pairs: Vec<(f64, f64)> = (0..120)
|
||||
.map(|t| {
|
||||
let drive = (f64::from(t) * 0.3).sin() + 0.4 * (f64::from(t) * 0.11).cos();
|
||||
let a = 0.8 * prev_drive + 0.05 * (f64::from(t) * 0.7).sin();
|
||||
prev_drive = drive;
|
||||
(a, drive)
|
||||
})
|
||||
.collect();
|
||||
let last = GrangerCausality::new(60, 1)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 1.0, "F {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_b_is_singular_and_returns_zero() {
|
||||
// b is constant ⇒ its lag columns are collinear with the intercept ⇒
|
||||
// the unrestricted normal equations are singular ⇒ 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|t| (f64::from(t) + (f64::from(t) * 0.6).sin(), 3.0))
|
||||
.collect();
|
||||
let last = GrangerCausality::new(20, 1)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_a_restricted_singular_returns_zero() {
|
||||
// a is constant ⇒ its own lag columns are collinear with the intercept
|
||||
// ⇒ the restricted normal equations are singular ⇒ 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..40).map(|t| (5.0, (f64::from(t) * 0.4).sin())).collect();
|
||||
let last = GrangerCausality::new(20, 1)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut g = GrangerCausality::new(8, 1).unwrap();
|
||||
for t in 0..12 {
|
||||
g.update((
|
||||
f64::from(t) + (f64::from(t) * 0.7).sin(),
|
||||
(f64::from(t) * 0.3).cos(),
|
||||
));
|
||||
}
|
||||
assert!(g.is_ready());
|
||||
g.reset();
|
||||
assert!(!g.is_ready());
|
||||
assert_eq!(g.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|t| {
|
||||
let b = (f64::from(t) * 0.4).sin();
|
||||
(
|
||||
0.6 * (f64::from(t.max(1) - 1) * 0.4).sin() + 0.1 * f64::from(t % 3),
|
||||
b,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = GrangerCausality::new(30, 2).unwrap().batch(&pairs);
|
||||
let mut g = GrangerCausality::new(30, 2).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| g.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Kalman-filter dynamic hedge ratio between two series.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`KalmanHedgeRatio`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct KalmanHedgeRatioOutput {
|
||||
/// Current hedge ratio `β` — the filtered slope of `a` on `b`.
|
||||
pub hedge_ratio: f64,
|
||||
/// Current intercept `α` — the filtered level offset.
|
||||
pub intercept: f64,
|
||||
/// Forecast error `a − (α + β·b)`: how far the latest `a` sits from the
|
||||
/// Kalman-predicted relationship. This is the tradeable spread signal.
|
||||
pub spread: f64,
|
||||
}
|
||||
|
||||
/// Dynamic hedge ratio between two series, estimated online with a Kalman filter.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` price pair and treats the linear relation
|
||||
/// `aₜ = αₜ + βₜ·bₜ + noise` as a state-space model whose hidden state
|
||||
/// `[βₜ, αₜ]` follows a random walk. The filter updates the state from every
|
||||
/// observation, so the hedge ratio **adapts continuously** instead of being a
|
||||
/// flat OLS slope over a fixed window:
|
||||
///
|
||||
/// ```text
|
||||
/// state xₜ = [βₜ, αₜ], drifts as a random walk with covariance Vw·I
|
||||
/// observe aₜ = [bₜ, 1]·xₜ + εₜ, Var(εₜ) = observation_var
|
||||
/// Vw = delta / (1 − delta)
|
||||
/// ```
|
||||
///
|
||||
/// `delta` controls how fast the hedge ratio is allowed to move: a larger
|
||||
/// `delta` tracks regime changes faster but is noisier; a smaller `delta` is
|
||||
/// smoother but slower. `observation_var` is the measurement-noise variance.
|
||||
/// The reported `spread` (the filter's forecast error) is the mean-reverting
|
||||
/// signal a pairs trade fades — the Kalman analogue of the
|
||||
/// [`crate::Cointegration`] residual, but with a hedge ratio that breathes.
|
||||
///
|
||||
/// The filter emits an estimate from the **first** update (warmup of one bar);
|
||||
/// early estimates are diffuse and settle as observations accumulate. Each
|
||||
/// `update` is `O(1)` over the fixed 2×2 covariance.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, KalmanHedgeRatio};
|
||||
///
|
||||
/// let mut k = KalmanHedgeRatio::new(1e-2, 1e-3).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..400 {
|
||||
/// // `b` sweeps a wide range so the slope and intercept are identifiable.
|
||||
/// let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
|
||||
/// last = k.update((2.0 * b + 5.0, b)); // a = 2·b + 5
|
||||
/// }
|
||||
/// let out = last.unwrap();
|
||||
/// assert!((out.hedge_ratio - 2.0).abs() < 0.05);
|
||||
/// assert!(out.spread.abs() < 0.05);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KalmanHedgeRatio {
|
||||
delta: f64,
|
||||
transition_var: f64,
|
||||
observation_var: f64,
|
||||
beta: f64,
|
||||
alpha: f64,
|
||||
// State covariance, row-major 2×2: [[p00, p01], [p10, p11]].
|
||||
cov: [[f64; 2]; 2],
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl KalmanHedgeRatio {
|
||||
/// Construct a new Kalman hedge-ratio filter.
|
||||
///
|
||||
/// `delta` is the state-drift ratio in `(0, 1)`; `observation_var` is the
|
||||
/// measurement-noise variance (`> 0`).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidParameter`] if `delta` is not in `(0, 1)` or if
|
||||
/// `observation_var` is not strictly positive (both must also be finite).
|
||||
pub fn new(delta: f64, observation_var: f64) -> Result<Self> {
|
||||
if !delta.is_finite() || delta <= 0.0 || delta >= 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "kalman hedge ratio needs delta in (0, 1)",
|
||||
});
|
||||
}
|
||||
if !observation_var.is_finite() || observation_var <= 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "kalman hedge ratio needs observation_var > 0",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
delta,
|
||||
transition_var: delta / (1.0 - delta),
|
||||
observation_var,
|
||||
beta: 0.0,
|
||||
alpha: 0.0,
|
||||
cov: [[0.0; 2]; 2],
|
||||
count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured state-drift ratio `delta`.
|
||||
pub const fn delta(&self) -> f64 {
|
||||
self.delta
|
||||
}
|
||||
|
||||
/// Configured measurement-noise variance.
|
||||
pub const fn observation_var(&self) -> f64 {
|
||||
self.observation_var
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KalmanHedgeRatio {
|
||||
type Input = (f64, f64);
|
||||
type Output = KalmanHedgeRatioOutput;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<KalmanHedgeRatioOutput> {
|
||||
let (a, b) = input;
|
||||
// Predicted state covariance: add the transition noise to the diagonal
|
||||
// (the very first observation starts from a zero prior).
|
||||
let mut cov_pred = self.cov;
|
||||
if self.count > 0 {
|
||||
cov_pred[0][0] += self.transition_var;
|
||||
cov_pred[1][1] += self.transition_var;
|
||||
}
|
||||
// Observation row is F = [b, 1].
|
||||
let predicted = self.beta * b + self.alpha;
|
||||
let innovation = a - predicted;
|
||||
// F·cov_pred (a 1×2 row).
|
||||
let fr0 = b * cov_pred[0][0] + cov_pred[1][0];
|
||||
let fr1 = b * cov_pred[0][1] + cov_pred[1][1];
|
||||
// Innovation variance S = F·cov_pred·Fᵀ + observation_var ≥ observation_var > 0.
|
||||
let innovation_var = fr0 * b + fr1 + self.observation_var;
|
||||
// Kalman gain = cov_pred·Fᵀ / S.
|
||||
let rft0 = cov_pred[0][0] * b + cov_pred[0][1];
|
||||
let rft1 = cov_pred[1][0] * b + cov_pred[1][1];
|
||||
let gain0 = rft0 / innovation_var;
|
||||
let gain1 = rft1 / innovation_var;
|
||||
// State update.
|
||||
self.beta += gain0 * innovation;
|
||||
self.alpha += gain1 * innovation;
|
||||
// Covariance update P = cov_pred − gain·(F·cov_pred).
|
||||
self.cov[0][0] = cov_pred[0][0] - gain0 * fr0;
|
||||
self.cov[0][1] = cov_pred[0][1] - gain0 * fr1;
|
||||
self.cov[1][0] = cov_pred[1][0] - gain1 * fr0;
|
||||
self.cov[1][1] = cov_pred[1][1] - gain1 * fr1;
|
||||
self.count += 1;
|
||||
Some(KalmanHedgeRatioOutput {
|
||||
hedge_ratio: self.beta,
|
||||
intercept: self.alpha,
|
||||
spread: innovation,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.beta = 0.0;
|
||||
self.alpha = 0.0;
|
||||
self.cov = [[0.0; 2]; 2];
|
||||
self.count = 0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.count >= 1
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KalmanHedgeRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_parameters() {
|
||||
assert!(KalmanHedgeRatio::new(0.0, 1.0).is_err());
|
||||
assert!(KalmanHedgeRatio::new(1.0, 1.0).is_err());
|
||||
assert!(KalmanHedgeRatio::new(-0.1, 1.0).is_err());
|
||||
assert!(KalmanHedgeRatio::new(f64::NAN, 1.0).is_err());
|
||||
assert!(KalmanHedgeRatio::new(0.001, 0.0).is_err());
|
||||
assert!(KalmanHedgeRatio::new(0.001, -1.0).is_err());
|
||||
assert!(KalmanHedgeRatio::new(0.001, f64::INFINITY).is_err());
|
||||
assert!(KalmanHedgeRatio::new(0.001, 0.001).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let k = KalmanHedgeRatio::new(0.001, 0.01).unwrap();
|
||||
assert_eq!(k.delta(), 0.001);
|
||||
assert_eq!(k.observation_var(), 0.01);
|
||||
assert_eq!(k.warmup_period(), 1);
|
||||
assert_eq!(k.name(), "KalmanHedgeRatio");
|
||||
assert!(!k.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_from_first_update() {
|
||||
let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
|
||||
let first = k.update((10.0, 5.0)).unwrap();
|
||||
// The diffuse prior leaves the first state at the origin.
|
||||
assert_eq!(first.hedge_ratio, 0.0);
|
||||
assert_eq!(first.intercept, 0.0);
|
||||
assert_eq!(first.spread, 10.0);
|
||||
assert!(k.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converges_to_static_relationship() {
|
||||
// a = 2·b + 5 ⇒ the filter should recover β ≈ 2, α ≈ 5, spread ≈ 0.
|
||||
// `b` sweeps a wide range so β and α are jointly identifiable.
|
||||
let pairs: Vec<(f64, f64)> = (0..500)
|
||||
.map(|t| {
|
||||
let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
|
||||
(2.0 * b + 5.0, b)
|
||||
})
|
||||
.collect();
|
||||
let out = KalmanHedgeRatio::new(1e-2, 1e-3)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(
|
||||
(out.hedge_ratio - 2.0).abs() < 0.05,
|
||||
"beta {}",
|
||||
out.hedge_ratio
|
||||
);
|
||||
assert!((out.intercept - 5.0).abs() < 1.0, "alpha {}", out.intercept);
|
||||
assert!(out.spread.abs() < 0.05, "spread {}", out.spread);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_a_changing_hedge_ratio() {
|
||||
// Hedge ratio steps from 2 to 3 partway through; the filter should move
|
||||
// toward the new ratio.
|
||||
let mut pairs: Vec<(f64, f64)> = (0..300)
|
||||
.map(|t| {
|
||||
let b = 100.0 + (f64::from(t) * 0.5).sin() * 95.0;
|
||||
(2.0 * b + 5.0, b)
|
||||
})
|
||||
.collect();
|
||||
pairs.extend((0..300).map(|t| {
|
||||
let b = 100.0 + (f64::from(t) * 0.5).cos() * 95.0;
|
||||
(3.0 * b + 5.0, b)
|
||||
}));
|
||||
let out = KalmanHedgeRatio::new(1e-2, 1e-3)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(out.hedge_ratio > 2.5, "beta {}", out.hedge_ratio);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut k = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
|
||||
for t in 0..50 {
|
||||
let b = 100.0 + f64::from(t);
|
||||
k.update((2.0 * b, b));
|
||||
}
|
||||
assert!(k.is_ready());
|
||||
k.reset();
|
||||
assert!(!k.is_ready());
|
||||
let first = k.update((10.0, 5.0)).unwrap();
|
||||
assert_eq!(first.hedge_ratio, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..120)
|
||||
.map(|t| {
|
||||
let b = 30.0 + 0.7 * f64::from(t);
|
||||
(1.8 * b + 2.0 + (f64::from(t) * 0.4).sin(), b)
|
||||
})
|
||||
.collect();
|
||||
let batch = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap().batch(&pairs);
|
||||
let mut k = KalmanHedgeRatio::new(1e-3, 1e-2).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| k.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod ad_oscillator;
|
||||
mod adaptive_cycle;
|
||||
mod adl;
|
||||
mod advance_block;
|
||||
mod advance_decline;
|
||||
mod adx;
|
||||
mod adxr;
|
||||
mod alligator;
|
||||
@@ -32,6 +33,7 @@ mod awesome_oscillator_histogram;
|
||||
mod balance_of_power;
|
||||
mod belt_hold;
|
||||
mod beta;
|
||||
mod beta_neutral_spread;
|
||||
mod bollinger;
|
||||
mod bollinger_bandwidth;
|
||||
mod breakaway;
|
||||
@@ -66,6 +68,7 @@ mod demand_index;
|
||||
mod demark_pivots;
|
||||
mod depth_slope;
|
||||
mod detrended_std_dev;
|
||||
mod distance_ssd;
|
||||
mod doji;
|
||||
mod doji_star;
|
||||
mod donchian;
|
||||
@@ -100,6 +103,7 @@ mod funding_rate_zscore;
|
||||
mod gain_loss_ratio;
|
||||
mod gap_side_by_side_white;
|
||||
mod garman_klass;
|
||||
mod granger_causality;
|
||||
mod gravestone_doji;
|
||||
mod hammer;
|
||||
mod hanging_man;
|
||||
@@ -129,6 +133,7 @@ mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
mod jma;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
@@ -186,6 +191,7 @@ mod omega_ratio;
|
||||
mod on_neck;
|
||||
mod opening_marubozu;
|
||||
mod opening_range;
|
||||
mod ou_half_life;
|
||||
mod pain_index;
|
||||
mod pair_spread_zscore;
|
||||
mod pairwise_beta;
|
||||
@@ -217,6 +223,8 @@ mod rocp;
|
||||
mod rocr;
|
||||
mod rocr100;
|
||||
mod rogers_satchell;
|
||||
mod rolling_correlation;
|
||||
mod rolling_covariance;
|
||||
mod roofing_filter;
|
||||
mod rsi;
|
||||
mod rvi;
|
||||
@@ -236,6 +244,8 @@ mod smma;
|
||||
mod sortino_ratio;
|
||||
mod spearman_correlation;
|
||||
mod spinning_top;
|
||||
mod spread_bollinger_bands;
|
||||
mod spread_hurst;
|
||||
mod stalled_pattern;
|
||||
mod standard_error;
|
||||
mod standard_error_bands;
|
||||
@@ -294,6 +304,7 @@ mod upside_gap_two_crows;
|
||||
mod value_area;
|
||||
mod value_at_risk;
|
||||
mod variance;
|
||||
mod variance_ratio;
|
||||
mod vertical_horizontal_filter;
|
||||
mod vidya;
|
||||
mod volty_stop;
|
||||
@@ -325,6 +336,7 @@ pub use ad_oscillator::AdOscillator;
|
||||
pub use adaptive_cycle::AdaptiveCycle;
|
||||
pub use adl::Adl;
|
||||
pub use advance_block::AdvanceBlock;
|
||||
pub use advance_decline::AdvanceDecline;
|
||||
pub use adx::{Adx, AdxOutput};
|
||||
pub use adxr::Adxr;
|
||||
pub use alligator::{Alligator, AlligatorOutput};
|
||||
@@ -346,6 +358,7 @@ pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
|
||||
pub use balance_of_power::BalanceOfPower;
|
||||
pub use belt_hold::BeltHold;
|
||||
pub use beta::Beta;
|
||||
pub use beta_neutral_spread::BetaNeutralSpread;
|
||||
pub use bollinger::{BollingerBands, BollingerOutput};
|
||||
pub use bollinger_bandwidth::BollingerBandwidth;
|
||||
pub use breakaway::Breakaway;
|
||||
@@ -380,6 +393,7 @@ pub use demand_index::DemandIndex;
|
||||
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
|
||||
pub use depth_slope::DepthSlope;
|
||||
pub use detrended_std_dev::DetrendedStdDev;
|
||||
pub use distance_ssd::DistanceSsd;
|
||||
pub use doji::Doji;
|
||||
pub use doji_star::DojiStar;
|
||||
pub use donchian::{Donchian, DonchianOutput};
|
||||
@@ -414,6 +428,7 @@ pub use funding_rate_zscore::FundingRateZScore;
|
||||
pub use gain_loss_ratio::GainLossRatio;
|
||||
pub use gap_side_by_side_white::GapSideBySideWhite;
|
||||
pub use garman_klass::GarmanKlassVolatility;
|
||||
pub use granger_causality::GrangerCausality;
|
||||
pub use gravestone_doji::GravestoneDoji;
|
||||
pub use hammer::Hammer;
|
||||
pub use hanging_man::HangingMan;
|
||||
@@ -443,6 +458,7 @@ pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
pub use jma::Jma;
|
||||
pub use kagi_bars::{KagiBar, KagiBars};
|
||||
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
|
||||
pub use kama::Kama;
|
||||
pub use kelly_criterion::KellyCriterion;
|
||||
pub use keltner::{Keltner, KeltnerOutput};
|
||||
@@ -500,6 +516,7 @@ pub use omega_ratio::OmegaRatio;
|
||||
pub use on_neck::OnNeck;
|
||||
pub use opening_marubozu::OpeningMarubozu;
|
||||
pub use opening_range::{OpeningRange, OpeningRangeOutput};
|
||||
pub use ou_half_life::OuHalfLife;
|
||||
pub use pain_index::PainIndex;
|
||||
pub use pair_spread_zscore::PairSpreadZScore;
|
||||
pub use pairwise_beta::PairwiseBeta;
|
||||
@@ -531,6 +548,8 @@ pub use rocp::Rocp;
|
||||
pub use rocr::Rocr;
|
||||
pub use rocr100::Rocr100;
|
||||
pub use rogers_satchell::RogersSatchellVolatility;
|
||||
pub use rolling_correlation::RollingCorrelation;
|
||||
pub use rolling_covariance::RollingCovariance;
|
||||
pub use roofing_filter::RoofingFilter;
|
||||
pub use rsi::Rsi;
|
||||
pub use rvi::Rvi;
|
||||
@@ -550,6 +569,8 @@ pub use smma::Smma;
|
||||
pub use sortino_ratio::SortinoRatio;
|
||||
pub use spearman_correlation::SpearmanCorrelation;
|
||||
pub use spinning_top::SpinningTop;
|
||||
pub use spread_bollinger_bands::{SpreadBollingerBands, SpreadBollingerBandsOutput};
|
||||
pub use spread_hurst::SpreadHurst;
|
||||
pub use stalled_pattern::StalledPattern;
|
||||
pub use standard_error::StandardError;
|
||||
pub use standard_error_bands::{StandardErrorBands, StandardErrorBandsOutput};
|
||||
@@ -608,6 +629,7 @@ pub use upside_gap_two_crows::UpsideGapTwoCrows;
|
||||
pub use value_area::{ValueArea, ValueAreaOutput};
|
||||
pub use value_at_risk::ValueAtRisk;
|
||||
pub use variance::Variance;
|
||||
pub use variance_ratio::VarianceRatio;
|
||||
pub use vertical_horizontal_filter::VerticalHorizontalFilter;
|
||||
pub use vidya::Vidya;
|
||||
pub use volty_stop::VoltyStop;
|
||||
@@ -836,11 +858,26 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"PearsonCorrelation",
|
||||
"Beta",
|
||||
"SpearmanCorrelation",
|
||||
"Cointegration",
|
||||
"LeadLagCrossCorrelation",
|
||||
"PairSpreadZScore",
|
||||
"PairwiseBeta",
|
||||
"RelativeStrengthAB",
|
||||
"MidPrice",
|
||||
"MidPoint",
|
||||
"AvgPrice",
|
||||
"LinRegIntercept",
|
||||
"Tsf",
|
||||
"RollingCorrelation",
|
||||
"RollingCovariance",
|
||||
"OuHalfLife",
|
||||
"SpreadHurst",
|
||||
"DistanceSsd",
|
||||
"BetaNeutralSpread",
|
||||
"VarianceRatio",
|
||||
"GrangerCausality",
|
||||
"KalmanHedgeRatio",
|
||||
"SpreadBollingerBands",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1033,6 +1070,7 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Alt-Chart Bars",
|
||||
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
|
||||
),
|
||||
("Market Breadth", &["AdvanceDecline"]),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1061,6 +1099,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, 309, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 325, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Ornstein–Uhlenbeck half-life of mean reversion for the spread of two series.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Half-life of mean reversion of the spread `a − b`, from an Ornstein–Uhlenbeck
|
||||
/// fit.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` price pair and forms the spread
|
||||
/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
|
||||
/// fits the discrete Ornstein–Uhlenbeck (mean-reverting AR(1)) model by
|
||||
/// ordinary least squares of the change on the level:
|
||||
///
|
||||
/// ```text
|
||||
/// Δsₜ = λ · sₜ₋₁ + c + εₜ
|
||||
/// half_life = −ln(2) / λ (only when λ < 0)
|
||||
/// ```
|
||||
///
|
||||
/// `λ` is the speed of mean reversion: a more negative `λ` pulls the spread back
|
||||
/// to its mean faster. The **half-life** is the number of bars for a deviation
|
||||
/// to decay by half — the single most useful number for sizing a pairs trade's
|
||||
/// holding period and look-back. When the spread is not mean-reverting
|
||||
/// (`λ ≥ 0`, a random walk or a trend) or the regression is degenerate (a flat
|
||||
/// spread), the indicator returns `0`, meaning "no finite half-life".
|
||||
///
|
||||
/// Each `update` is `O(period)`: the OLS slope is recomputed from the window's
|
||||
/// running geometry. Output is in bars and is always `≥ 0`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, OuHalfLife};
|
||||
///
|
||||
/// let mut hl = OuHalfLife::new(40).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..120 {
|
||||
/// let b = 100.0 + f64::from(t);
|
||||
/// // `a` hugs `b` with a fast mean-reverting wobble ⇒ short half-life.
|
||||
/// let a = b + 2.0 * (f64::from(t) * 0.9).sin();
|
||||
/// last = hl.update((a, b));
|
||||
/// }
|
||||
/// let half_life = last.unwrap();
|
||||
/// assert!(half_life > 0.0 && half_life < 40.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OuHalfLife {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl OuHalfLife {
|
||||
/// Construct a new Ornstein–Uhlenbeck half-life estimator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 3` — the AR(1) regression
|
||||
/// needs at least two observations (a slope and an intercept).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 3 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "OU half-life needs period >= 3",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured look-back window of spreads.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OuHalfLife {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(a - b);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
// OLS slope λ of Δsₜ on sₜ₋₁ over the window.
|
||||
let spreads: Vec<f64> = self.window.iter().copied().collect();
|
||||
let count = (spreads.len() - 1) as f64;
|
||||
let mut sum_level = 0.0;
|
||||
let mut sum_delta = 0.0;
|
||||
let mut sum_ll = 0.0;
|
||||
let mut sum_ld = 0.0;
|
||||
for pair in spreads.windows(2) {
|
||||
let level = pair[0];
|
||||
let delta = pair[1] - pair[0];
|
||||
sum_level += level;
|
||||
sum_delta += delta;
|
||||
sum_ll += level * level;
|
||||
sum_ld += level * delta;
|
||||
}
|
||||
let mean_level = sum_level / count;
|
||||
let mean_delta = sum_delta / count;
|
||||
let var_level = sum_ll / count - mean_level * mean_level;
|
||||
if var_level <= 0.0 {
|
||||
// Flat spread: the regression has no defined slope.
|
||||
return Some(0.0);
|
||||
}
|
||||
let cov = sum_ld / count - mean_level * mean_delta;
|
||||
let lambda = cov / var_level;
|
||||
if lambda >= 0.0 {
|
||||
// Not mean-reverting (random walk or diverging): no finite half-life.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(-std::f64::consts::LN_2 / lambda)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OuHalfLife"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_three() {
|
||||
assert!(OuHalfLife::new(2).is_err());
|
||||
assert!(OuHalfLife::new(3).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let hl = OuHalfLife::new(30).unwrap();
|
||||
assert_eq!(hl.period(), 30);
|
||||
assert_eq!(hl.warmup_period(), 30);
|
||||
assert_eq!(hl.name(), "OuHalfLife");
|
||||
assert!(!hl.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut hl = OuHalfLife::new(4).unwrap();
|
||||
assert_eq!(hl.update((1.0, 0.0)), None);
|
||||
assert_eq!(hl.update((2.0, 0.0)), None);
|
||||
assert_eq!(hl.update((3.0, 0.0)), None);
|
||||
assert!(hl.update((4.0, 0.0)).is_some());
|
||||
assert!(hl.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mean_reverting_spread_has_positive_half_life() {
|
||||
// Fast sinusoidal spread around zero ⇒ strong mean reversion.
|
||||
let pairs: Vec<(f64, f64)> = (0..120)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
let a = b + 2.0 * (f64::from(t) * 0.9).sin();
|
||||
(a, b)
|
||||
})
|
||||
.collect();
|
||||
let last = OuHalfLife::new(40)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 0.0 && last < 40.0, "half-life {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trending_spread_has_zero_half_life() {
|
||||
// Spread = a − b grows monotonically (λ ≥ 0) ⇒ no finite half-life.
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|t| (2.0 * f64::from(t), f64::from(t)))
|
||||
.collect();
|
||||
let last = OuHalfLife::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_spread_returns_zero() {
|
||||
// a − b is constant ⇒ var(level) = 0 ⇒ undefined ⇒ 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..30)
|
||||
.map(|t| (5.0 + f64::from(t), f64::from(t)))
|
||||
.collect();
|
||||
let last = OuHalfLife::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hl = OuHalfLife::new(5).unwrap();
|
||||
for t in 0..10 {
|
||||
hl.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
|
||||
}
|
||||
assert!(hl.is_ready());
|
||||
hl.reset();
|
||||
assert!(!hl.is_ready());
|
||||
assert_eq!(hl.update((1.0, 0.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|t| {
|
||||
let b = 50.0 + 0.5 * f64::from(t);
|
||||
(b + (f64::from(t) * 0.6).sin(), b)
|
||||
})
|
||||
.collect();
|
||||
let batch = OuHalfLife::new(25).unwrap().batch(&pairs);
|
||||
let mut hl = OuHalfLife::new(25).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| hl.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
//! Rolling Pearson correlation of the period-over-period *returns* of two series.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling correlation of the **returns** of two synchronised series.
|
||||
///
|
||||
/// Where [`crate::PearsonCorrelation`] correlates the raw *levels* `(x, y)`,
|
||||
/// this indicator first differences each channel into a one-step return and
|
||||
/// correlates those returns over the trailing window:
|
||||
///
|
||||
/// ```text
|
||||
/// rxₜ = xₜ − xₜ₋₁ ryₜ = yₜ − yₜ₋₁
|
||||
/// corr = cov(rx, ry) / √(var(rx) · var(ry))
|
||||
/// ```
|
||||
///
|
||||
/// Return correlation is the quantity that matters for hedging and portfolio
|
||||
/// risk: two assets can trend together (high level correlation) while their
|
||||
/// day-to-day moves are nearly independent (low return correlation). The output
|
||||
/// is in `[−1, +1]`; a flat return channel makes the ratio undefined and the
|
||||
/// indicator reports `0` rather than `NaN`. The value is clamped to `[−1, +1]`
|
||||
/// to absorb tiny floating-point overshoots near the boundaries.
|
||||
///
|
||||
/// Each `update` is O(1): the five running sums (`Σrx`, `Σry`, `Σrx²`, `Σry²`,
|
||||
/// `Σrxry`) are maintained as the window of returns slides. The first level in
|
||||
/// each channel produces no return, so a `period`-pair correlation needs
|
||||
/// `period + 1` updates of warmup.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, RollingCorrelation};
|
||||
///
|
||||
/// let mut rc = RollingCorrelation::new(10).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// // A varying path where y always moves with x ⇒ return correlation +1.
|
||||
/// let x = (f64::from(i) * 0.5).sin() * 10.0;
|
||||
/// last = rc.update((x, 2.0 * x));
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 1.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RollingCorrelation {
|
||||
period: usize,
|
||||
prev: Option<(f64, f64)>,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_x: f64,
|
||||
sum_y: f64,
|
||||
sum_xx: f64,
|
||||
sum_yy: f64,
|
||||
sum_xy: f64,
|
||||
}
|
||||
|
||||
impl RollingCorrelation {
|
||||
/// Construct a new rolling return-correlation.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — correlation is
|
||||
/// undefined for fewer than two return pairs.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "rolling correlation needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_x: 0.0,
|
||||
sum_y: 0.0,
|
||||
sum_xx: 0.0,
|
||||
sum_yy: 0.0,
|
||||
sum_xy: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RollingCorrelation {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (x, y) = input;
|
||||
let Some((px, py)) = self.prev else {
|
||||
// First level in each channel: store it, no return yet.
|
||||
self.prev = Some((x, y));
|
||||
return None;
|
||||
};
|
||||
self.prev = Some((x, y));
|
||||
let (rx, ry) = (x - px, y - py);
|
||||
if self.window.len() == self.period {
|
||||
let (ox, oy) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_x -= ox;
|
||||
self.sum_y -= oy;
|
||||
self.sum_xx -= ox * ox;
|
||||
self.sum_yy -= oy * oy;
|
||||
self.sum_xy -= ox * oy;
|
||||
}
|
||||
self.window.push_back((rx, ry));
|
||||
self.sum_x += rx;
|
||||
self.sum_y += ry;
|
||||
self.sum_xx += rx * rx;
|
||||
self.sum_yy += ry * ry;
|
||||
self.sum_xy += rx * ry;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean_x = self.sum_x / n;
|
||||
let mean_y = self.sum_y / n;
|
||||
let var_x = (self.sum_xx / n - mean_x * mean_x).max(0.0);
|
||||
let var_y = (self.sum_yy / n - mean_y * mean_y).max(0.0);
|
||||
let cov = self.sum_xy / n - mean_x * mean_y;
|
||||
let denom = (var_x * var_y).sqrt();
|
||||
if denom == 0.0 {
|
||||
// At least one return channel is flat: correlation is undefined.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some((cov / denom).clamp(-1.0, 1.0))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum_x = 0.0;
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xx = 0.0;
|
||||
self.sum_yy = 0.0;
|
||||
self.sum_xy = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RollingCorrelation"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(RollingCorrelation::new(0).is_err());
|
||||
assert!(RollingCorrelation::new(1).is_err());
|
||||
assert!(RollingCorrelation::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rc = RollingCorrelation::new(14).unwrap();
|
||||
assert_eq!(rc.period(), 14);
|
||||
assert_eq!(rc.warmup_period(), 15);
|
||||
assert_eq!(rc.name(), "RollingCorrelation");
|
||||
assert!(!rc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_needs_period_plus_one() {
|
||||
let mut rc = RollingCorrelation::new(3).unwrap();
|
||||
// First update only seeds the previous level ⇒ None.
|
||||
assert_eq!(rc.update((1.0, 1.0)), None);
|
||||
assert_eq!(rc.update((2.0, 3.0)), None); // 1 return
|
||||
assert_eq!(rc.update((3.0, 5.0)), None); // 2 returns
|
||||
assert!(rc.update((4.0, 7.0)).is_some()); // 3 returns ⇒ ready
|
||||
assert!(rc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comoving_returns_are_plus_one() {
|
||||
// y always moves by 2x x's move ⇒ perfectly correlated returns.
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| {
|
||||
let x = (f64::from(i) * 0.5).sin() * 10.0;
|
||||
(x, 2.0 * x + 100.0)
|
||||
})
|
||||
.collect();
|
||||
let last = RollingCorrelation::new(8)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opposing_returns_are_minus_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20)
|
||||
.map(|i| {
|
||||
let x = (f64::from(i) * 0.5).sin() * 10.0;
|
||||
(x, -1.5 * x + 50.0)
|
||||
})
|
||||
.collect();
|
||||
let last = RollingCorrelation::new(8)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_return_channel_yields_zero() {
|
||||
// y is constant ⇒ its returns are all zero ⇒ undefined ⇒ 0.
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
|
||||
let last = RollingCorrelation::new(6)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_range() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(100.0 + t.sin() * 5.0, 50.0 + (t * 0.3).cos() * 3.0)
|
||||
})
|
||||
.collect();
|
||||
let mut rc = RollingCorrelation::new(20).unwrap();
|
||||
for v in rc.batch(&pairs).into_iter().flatten() {
|
||||
assert!((-1.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rc = RollingCorrelation::new(4).unwrap();
|
||||
rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 6.0), (4.0, 8.0), (5.0, 10.0)]);
|
||||
assert!(rc.is_ready());
|
||||
rc.reset();
|
||||
assert!(!rc.is_ready());
|
||||
assert_eq!(rc.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin(), (t * 0.5).cos())
|
||||
})
|
||||
.collect();
|
||||
let batch = RollingCorrelation::new(14).unwrap().batch(&pairs);
|
||||
let mut rc = RollingCorrelation::new(14).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Rolling covariance of the period-over-period *returns* of two series.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rolling covariance of the **returns** of two synchronised series.
|
||||
///
|
||||
/// Each `update` takes one `(x, y)` level pair, differences each channel into a
|
||||
/// one-step return, and reports the population covariance of those returns over
|
||||
/// the trailing window of `period` return pairs:
|
||||
///
|
||||
/// ```text
|
||||
/// rxₜ = xₜ − xₜ₋₁ ryₜ = yₜ − yₜ₋₁
|
||||
/// cov = (1/n) · Σ rx·ry − r̄x · r̄y
|
||||
/// ```
|
||||
///
|
||||
/// Unlike [`crate::RollingCorrelation`] the result is **not** normalised to
|
||||
/// `[−1, 1]`: it carries the units of the two return streams multiplied
|
||||
/// together, so it scales with volatility. It is the raw building block behind
|
||||
/// correlation, beta and portfolio variance — positive when the two return
|
||||
/// streams tend to move the same way, negative when they offset.
|
||||
///
|
||||
/// Each `update` is O(1): three running sums (`Σrx`, `Σry`, `Σrxry`) are
|
||||
/// maintained as the window slides. The first level in each channel produces no
|
||||
/// return, so a `period`-pair covariance needs `period + 1` updates of warmup.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, RollingCovariance};
|
||||
///
|
||||
/// let mut rc = RollingCovariance::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let x = f64::from(i);
|
||||
/// last = rc.update((x, 3.0 * x)); // y's return is 3× x's return
|
||||
/// }
|
||||
/// // cov(rx, ry) = cov(1, 3) over constant unit returns = 3 · var(rx) = 0
|
||||
/// // for a constant return; use a varying path in practice. Here returns are
|
||||
/// // constant (1 and 3) ⇒ covariance 0.
|
||||
/// assert!(last.unwrap().abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RollingCovariance {
|
||||
period: usize,
|
||||
prev: Option<(f64, f64)>,
|
||||
window: VecDeque<(f64, f64)>,
|
||||
sum_x: f64,
|
||||
sum_y: f64,
|
||||
sum_xy: f64,
|
||||
}
|
||||
|
||||
impl RollingCovariance {
|
||||
/// Construct a new rolling return-covariance.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` — covariance is
|
||||
/// undefined for fewer than two return pairs.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "rolling covariance needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
prev: None,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_x: 0.0,
|
||||
sum_y: 0.0,
|
||||
sum_xy: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RollingCovariance {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (x, y) = input;
|
||||
let Some((px, py)) = self.prev else {
|
||||
self.prev = Some((x, y));
|
||||
return None;
|
||||
};
|
||||
self.prev = Some((x, y));
|
||||
let (rx, ry) = (x - px, y - py);
|
||||
if self.window.len() == self.period {
|
||||
let (ox, oy) = self.window.pop_front().expect("non-empty");
|
||||
self.sum_x -= ox;
|
||||
self.sum_y -= oy;
|
||||
self.sum_xy -= ox * oy;
|
||||
}
|
||||
self.window.push_back((rx, ry));
|
||||
self.sum_x += rx;
|
||||
self.sum_y += ry;
|
||||
self.sum_xy += rx * ry;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean_x = self.sum_x / n;
|
||||
let mean_y = self.sum_y / n;
|
||||
Some(self.sum_xy / n - mean_x * mean_y)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev = None;
|
||||
self.window.clear();
|
||||
self.sum_x = 0.0;
|
||||
self.sum_y = 0.0;
|
||||
self.sum_xy = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RollingCovariance"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_two() {
|
||||
assert!(RollingCovariance::new(0).is_err());
|
||||
assert!(RollingCovariance::new(1).is_err());
|
||||
assert!(RollingCovariance::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rc = RollingCovariance::new(14).unwrap();
|
||||
assert_eq!(rc.period(), 14);
|
||||
assert_eq!(rc.warmup_period(), 15);
|
||||
assert_eq!(rc.name(), "RollingCovariance");
|
||||
assert!(!rc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_needs_period_plus_one() {
|
||||
let mut rc = RollingCovariance::new(3).unwrap();
|
||||
assert_eq!(rc.update((1.0, 1.0)), None);
|
||||
assert_eq!(rc.update((2.0, 3.0)), None);
|
||||
assert_eq!(rc.update((3.0, 5.0)), None);
|
||||
assert!(rc.update((4.0, 7.0)).is_some());
|
||||
assert!(rc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_computed_value() {
|
||||
// Levels x = 0,1,3,6,10 ⇒ returns 1,2,3,4; y = 2x ⇒ returns 2,4,6,8.
|
||||
// With period = 3 the final window is rx = [2,3,4], ry = [4,6,8]:
|
||||
// Σrx·ry/3 = 58/3, r̄x·r̄y = 3·6 = 18 ⇒ cov = 58/3 − 18 = 4/3.
|
||||
let pairs = [
|
||||
(0.0, 0.0),
|
||||
(1.0, 2.0),
|
||||
(3.0, 6.0),
|
||||
(6.0, 12.0),
|
||||
(10.0, 20.0),
|
||||
];
|
||||
let last = RollingCovariance::new(3)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 4.0 / 3.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opposing_returns_give_negative_covariance() {
|
||||
let pairs: Vec<(f64, f64)> = (0..30)
|
||||
.map(|i| {
|
||||
let x = (f64::from(i) * 0.4).sin() * 10.0;
|
||||
(x, -x)
|
||||
})
|
||||
.collect();
|
||||
let last = RollingCovariance::new(10)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0, "cov {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_channel_gives_zero() {
|
||||
let pairs: Vec<(f64, f64)> = (0..20).map(|i| (f64::from(i), 7.0)).collect();
|
||||
let last = RollingCovariance::new(6)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rc = RollingCovariance::new(4).unwrap();
|
||||
rc.batch(&[(1.0, 2.0), (2.0, 4.0), (3.0, 1.0), (4.0, 9.0), (5.0, 2.0)]);
|
||||
assert!(rc.is_ready());
|
||||
rc.reset();
|
||||
assert!(!rc.is_ready());
|
||||
assert_eq!(rc.update((1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|i| {
|
||||
let t = f64::from(i);
|
||||
(t.sin() * 4.0, (t * 0.5).cos() * 2.0)
|
||||
})
|
||||
.collect();
|
||||
let batch = RollingCovariance::new(12).unwrap().batch(&pairs);
|
||||
let mut rc = RollingCovariance::new(12).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| rc.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Bollinger bands on the spread of two series, for pairs mean-reversion trading.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`SpreadBollingerBands`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SpreadBollingerBandsOutput {
|
||||
/// Middle band: the rolling mean of the spread.
|
||||
pub middle: f64,
|
||||
/// Upper band: `middle + num_std · σ`.
|
||||
pub upper: f64,
|
||||
/// Lower band: `middle − num_std · σ`.
|
||||
pub lower: f64,
|
||||
/// `%b`: where the current spread sits across the band, `(s − lower) /
|
||||
/// (upper − lower)`. `0` is the lower band, `1` the upper, `0.5` the middle.
|
||||
/// Reported as `0.5` when the band has zero width (a flat spread).
|
||||
pub percent_b: f64,
|
||||
}
|
||||
|
||||
/// Bollinger bands on the spread `a − b` of two series.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` price pair and forms the spread
|
||||
/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads it builds a
|
||||
/// classic Bollinger envelope:
|
||||
///
|
||||
/// ```text
|
||||
/// middle = mean(s) σ = stddev(s)
|
||||
/// upper = middle + num_std · σ
|
||||
/// lower = middle − num_std · σ
|
||||
/// %b = (s_now − lower) / (upper − lower)
|
||||
/// ```
|
||||
///
|
||||
/// Applied to a spread rather than a price, the bands are a ready-made pairs
|
||||
/// mean-reversion signal: the spread riding the **upper** band is stretched
|
||||
/// rich (a short-the-spread setup), the **lower** band stretched cheap, and a
|
||||
/// return to the **middle** is the exit. `%b` compresses the location into one
|
||||
/// number for thresholding. The spread is the raw difference `a − b`, so feed
|
||||
/// already-comparable legs (e.g. a hedged pair, two yields, or log prices); pair
|
||||
/// this with [`crate::BetaNeutralSpread`] when the legs need a hedge ratio first.
|
||||
///
|
||||
/// A flat spread yields a zero-width band; `%b` is then reported as the neutral
|
||||
/// `0.5`. Each `update` is `O(1)`: the mean and variance come from two running
|
||||
/// sums maintained as the window slides.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, SpreadBollingerBands};
|
||||
///
|
||||
/// let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..60 {
|
||||
/// let b = 100.0 + f64::from(t);
|
||||
/// let a = b + 2.0 * (f64::from(t) * 0.5).sin();
|
||||
/// last = bb.update((a, b));
|
||||
/// }
|
||||
/// let out = last.unwrap();
|
||||
/// assert!(out.lower <= out.middle && out.middle <= out.upper);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpreadBollingerBands {
|
||||
period: usize,
|
||||
num_std: f64,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
}
|
||||
|
||||
impl SpreadBollingerBands {
|
||||
/// Construct new spread Bollinger bands.
|
||||
///
|
||||
/// `period` is the look-back window; `num_std` is the band width in standard
|
||||
/// deviations.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
|
||||
/// [`Error::InvalidParameter`] if `num_std` is not strictly positive (and
|
||||
/// finite).
|
||||
pub fn new(period: usize, num_std: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "spread bollinger bands needs period >= 2",
|
||||
});
|
||||
}
|
||||
if !num_std.is_finite() || num_std <= 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "spread bollinger bands needs num_std > 0",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
num_std,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured look-back window.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured band width in standard deviations.
|
||||
pub const fn num_std(&self) -> f64 {
|
||||
self.num_std
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SpreadBollingerBands {
|
||||
type Input = (f64, f64);
|
||||
type Output = SpreadBollingerBandsOutput;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<SpreadBollingerBandsOutput> {
|
||||
let (a, b) = input;
|
||||
let spread = a - b;
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
}
|
||||
self.window.push_back(spread);
|
||||
self.sum += spread;
|
||||
self.sum_sq += spread * spread;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let middle = self.sum / n;
|
||||
let variance = (self.sum_sq / n - middle * middle).max(0.0);
|
||||
let sigma = variance.sqrt();
|
||||
let half_width = self.num_std * sigma;
|
||||
let upper = middle + half_width;
|
||||
let lower = middle - half_width;
|
||||
let percent_b = if half_width == 0.0 {
|
||||
0.5
|
||||
} else {
|
||||
(spread - lower) / (upper - lower)
|
||||
};
|
||||
Some(SpreadBollingerBandsOutput {
|
||||
middle,
|
||||
upper,
|
||||
lower,
|
||||
percent_b,
|
||||
})
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SpreadBollingerBands"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_parameters() {
|
||||
assert!(SpreadBollingerBands::new(1, 2.0).is_err());
|
||||
assert!(SpreadBollingerBands::new(20, 0.0).is_err());
|
||||
assert!(SpreadBollingerBands::new(20, -1.0).is_err());
|
||||
assert!(SpreadBollingerBands::new(20, f64::NAN).is_err());
|
||||
assert!(SpreadBollingerBands::new(2, 2.0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bb = SpreadBollingerBands::new(20, 2.5).unwrap();
|
||||
assert_eq!(bb.period(), 20);
|
||||
assert_eq!(bb.num_std(), 2.5);
|
||||
assert_eq!(bb.warmup_period(), 20);
|
||||
assert_eq!(bb.name(), "SpreadBollingerBands");
|
||||
assert!(!bb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut bb = SpreadBollingerBands::new(3, 2.0).unwrap();
|
||||
assert_eq!(bb.update((1.0, 0.0)), None);
|
||||
assert_eq!(bb.update((2.0, 0.0)), None);
|
||||
assert!(bb.update((3.0, 0.0)).is_some());
|
||||
assert!(bb.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_computed_value() {
|
||||
// Spreads 1,2,3,4 (b = 0), period 4, num_std 2:
|
||||
// mean = 2.5, σ = √1.25, upper = 2.5 + 2√1.25, lower = 2.5 − 2√1.25,
|
||||
// %b at s = 4 ⇒ 0.8354102.
|
||||
let pairs = [(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0)];
|
||||
let out = SpreadBollingerBands::new(4, 2.0)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.middle, 2.5, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.upper, 4.736_067_977_499_79, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.lower, 0.263_932_022_500_21, epsilon = 1e-9);
|
||||
assert_relative_eq!(out.percent_b, 0.835_410_196_624_97, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_spread_collapses_band() {
|
||||
// a − b constant ⇒ σ = 0 ⇒ upper = middle = lower, %b = 0.5.
|
||||
let pairs: Vec<(f64, f64)> = (0..10)
|
||||
.map(|t| (5.0 + f64::from(t), f64::from(t)))
|
||||
.collect();
|
||||
let out = SpreadBollingerBands::new(5, 2.0)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(out.upper, out.middle, epsilon = 1e-12);
|
||||
assert_relative_eq!(out.lower, out.middle, epsilon = 1e-12);
|
||||
assert_relative_eq!(out.percent_b, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_are_ordered() {
|
||||
let pairs: Vec<(f64, f64)> = (0..80)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
(b + 3.0 * (f64::from(t) * 0.4).sin(), b)
|
||||
})
|
||||
.collect();
|
||||
let mut bb = SpreadBollingerBands::new(20, 2.0).unwrap();
|
||||
for out in bb.batch(&pairs).into_iter().flatten() {
|
||||
assert!(out.lower <= out.middle && out.middle <= out.upper);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bb = SpreadBollingerBands::new(4, 2.0).unwrap();
|
||||
bb.batch(&[(1.0, 0.0), (2.0, 0.0), (3.0, 0.0), (4.0, 0.0), (5.0, 0.0)]);
|
||||
assert!(bb.is_ready());
|
||||
bb.reset();
|
||||
assert!(!bb.is_ready());
|
||||
assert_eq!(bb.update((1.0, 0.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..60)
|
||||
.map(|t| {
|
||||
let b = 30.0 + 0.7 * f64::from(t);
|
||||
(b + (f64::from(t) * 0.4).sin() * 1.5, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = SpreadBollingerBands::new(15, 2.0).unwrap().batch(&pairs);
|
||||
let mut bb = SpreadBollingerBands::new(15, 2.0).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| bb.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Hurst exponent of the spread of two series, for pairs-trading regime detection.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Hurst exponent of the spread `a − b` over a rolling window.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` price pair and forms the spread
|
||||
/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
|
||||
/// estimates the Hurst exponent `H` from how the variance of `τ`-lagged
|
||||
/// differences grows with the lag `τ`:
|
||||
///
|
||||
/// ```text
|
||||
/// V(τ) = mean_t (s_{t+τ} − s_t)² ∝ τ^(2H)
|
||||
/// H = slope of log V(τ) on log τ, divided by two
|
||||
/// ```
|
||||
///
|
||||
/// `H` classifies the spread's regime:
|
||||
///
|
||||
/// * `H < 0.5` — **mean-reverting** (anti-persistent): the spread snaps back,
|
||||
/// the regime pairs traders want.
|
||||
/// * `H ≈ 0.5` — a **random walk**: no exploitable structure.
|
||||
/// * `H > 0.5` — **trending** (persistent): the spread keeps diverging.
|
||||
///
|
||||
/// The fit uses lags `1..=period/4` (at least two). When the spread is flat —
|
||||
/// every lagged difference is zero, so the log-regression has fewer than two
|
||||
/// usable points — the indicator returns the neutral `0.5`. The output is
|
||||
/// clamped to `[0, 1]`.
|
||||
///
|
||||
/// Each `update` is `O(period · period/4)`, bounded by the fixed window.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, SpreadHurst};
|
||||
///
|
||||
/// let mut h = SpreadHurst::new(60).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..200 {
|
||||
/// let b = 100.0 + f64::from(t);
|
||||
/// // A tight oscillating spread is anti-persistent ⇒ H < 0.5.
|
||||
/// let a = b + 3.0 * (f64::from(t) * 0.8).sin();
|
||||
/// last = h.update((a, b));
|
||||
/// }
|
||||
/// assert!(last.unwrap() < 0.5);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpreadHurst {
|
||||
period: usize,
|
||||
max_lag: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl SpreadHurst {
|
||||
/// Construct a new spread Hurst estimator.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 8` — fewer than eight
|
||||
/// spreads cannot support a two-lag log–log regression.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 8 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "spread Hurst needs period >= 8",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
max_lag: (period / 4).max(2),
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured look-back window of spreads.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SpreadHurst {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(a - b);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let spreads: Vec<f64> = self.window.iter().copied().collect();
|
||||
// Collect (log τ, log V(τ)) for every lag whose variance is positive.
|
||||
let mut log_lag = Vec::with_capacity(self.max_lag);
|
||||
let mut log_var = Vec::with_capacity(self.max_lag);
|
||||
for lag in 1..=self.max_lag {
|
||||
let mut sum_sq = 0.0;
|
||||
let mut count = 0.0;
|
||||
for pair in spreads.windows(lag + 1) {
|
||||
let diff = pair[lag] - pair[0];
|
||||
sum_sq += diff * diff;
|
||||
count += 1.0;
|
||||
}
|
||||
let var = sum_sq / count;
|
||||
if var > 0.0 {
|
||||
log_lag.push((lag as f64).ln());
|
||||
log_var.push(var.ln());
|
||||
}
|
||||
}
|
||||
if log_lag.len() < 2 {
|
||||
// Degenerate (flat) spread: report the random-walk midpoint.
|
||||
return Some(0.5);
|
||||
}
|
||||
let n = log_lag.len() as f64;
|
||||
let mean_lag = log_lag.iter().sum::<f64>() / n;
|
||||
let mean_var = log_var.iter().sum::<f64>() / n;
|
||||
let mut cov = 0.0;
|
||||
let mut var_lag = 0.0;
|
||||
for (lx, lv) in log_lag.iter().zip(&log_var) {
|
||||
cov += (lx - mean_lag) * (lv - mean_var);
|
||||
var_lag += (lx - mean_lag) * (lx - mean_lag);
|
||||
}
|
||||
// `log_lag` holds at least two *distinct* lag logarithms, so the lag
|
||||
// variance is strictly positive — no degenerate-slope guard is needed.
|
||||
let slope = cov / var_lag;
|
||||
Some((slope / 2.0).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SpreadHurst"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_below_eight() {
|
||||
assert!(SpreadHurst::new(7).is_err());
|
||||
assert!(SpreadHurst::new(8).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = SpreadHurst::new(40).unwrap();
|
||||
assert_eq!(h.period(), 40);
|
||||
assert_eq!(h.warmup_period(), 40);
|
||||
assert_eq!(h.name(), "SpreadHurst");
|
||||
assert!(!h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut h = SpreadHurst::new(8).unwrap();
|
||||
for t in 0..7 {
|
||||
assert_eq!(h.update((f64::from(t), 0.0)), None);
|
||||
}
|
||||
assert!(h.update((7.0, 0.0)).is_some());
|
||||
assert!(h.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oscillating_spread_is_anti_persistent() {
|
||||
let pairs: Vec<(f64, f64)> = (0..200)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
(b + 3.0 * (f64::from(t) * 0.8).sin(), b)
|
||||
})
|
||||
.collect();
|
||||
let last = SpreadHurst::new(60)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.5, "H {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_trend_spread_is_persistent() {
|
||||
// Spread = a − b = t ⇒ τ-lagged differences are all τ ⇒ V(τ) = τ² ⇒ H = 1.
|
||||
let pairs: Vec<(f64, f64)> = (0..40)
|
||||
.map(|t| (2.0 * f64::from(t), f64::from(t)))
|
||||
.collect();
|
||||
let last = SpreadHurst::new(20)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_spread_returns_midpoint() {
|
||||
// a − b is constant ⇒ all lagged differences zero ⇒ neutral 0.5.
|
||||
let pairs: Vec<(f64, f64)> = (0..30)
|
||||
.map(|t| (5.0 + f64::from(t), f64::from(t)))
|
||||
.collect();
|
||||
let last = SpreadHurst::new(16)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_in_unit_range() {
|
||||
let pairs: Vec<(f64, f64)> = (0..150)
|
||||
.map(|t| {
|
||||
let b = 50.0 + 0.3 * f64::from(t);
|
||||
(
|
||||
b + (f64::from(t) * 0.5).sin() * 2.0 + (f64::from(t) * 0.13).cos(),
|
||||
b,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut h = SpreadHurst::new(48).unwrap();
|
||||
for v in h.batch(&pairs).into_iter().flatten() {
|
||||
assert!((0.0..=1.0).contains(&v));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = SpreadHurst::new(8).unwrap();
|
||||
for t in 0..12 {
|
||||
h.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
|
||||
}
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.update((1.0, 0.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..100)
|
||||
.map(|t| {
|
||||
let b = 30.0 + 0.7 * f64::from(t);
|
||||
(b + (f64::from(t) * 0.4).sin() * 1.5, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = SpreadHurst::new(32).unwrap().batch(&pairs);
|
||||
let mut h = SpreadHurst::new(32).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| h.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
//! Lo–MacKinlay variance-ratio test on the spread of two series.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Lo–MacKinlay variance ratio of the spread `a − b` at horizon `q`.
|
||||
///
|
||||
/// Each `update` takes one `(a, b)` price pair and forms the spread
|
||||
/// `sₜ = aₜ − bₜ`. Over the trailing window of `period` spreads the indicator
|
||||
/// compares the variance of `q`-step changes against `q` times the variance of
|
||||
/// one-step changes:
|
||||
///
|
||||
/// ```text
|
||||
/// rₜ = sₜ − sₜ₋₁ (one-step changes)
|
||||
/// VR(q) = Var(Σ of q consecutive r) / (q · Var(r))
|
||||
/// ```
|
||||
///
|
||||
/// Under a random walk the variance of returns grows linearly with the horizon,
|
||||
/// so `VR(q) = 1`. Departures reveal autocorrelation structure:
|
||||
///
|
||||
/// * `VR(q) < 1` — **mean reversion** (negatively autocorrelated changes): the
|
||||
/// spread's moves partly cancel, the regime pairs traders exploit.
|
||||
/// * `VR(q) ≈ 1` — a **random walk**: no exploitable structure.
|
||||
/// * `VR(q) > 1` — **momentum / trending** (positively autocorrelated changes).
|
||||
///
|
||||
/// The estimator uses overlapping `q`-step windows. When the one-step changes
|
||||
/// have zero variance (a flat spread) the ratio is undefined and the indicator
|
||||
/// returns the null value `1`. The output is always `≥ 0`.
|
||||
///
|
||||
/// Each `update` is `O(period)`, bounded by the fixed window.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, VarianceRatio};
|
||||
///
|
||||
/// let mut vr = VarianceRatio::new(60, 2).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for t in 0..200 {
|
||||
/// let b = 100.0 + f64::from(t);
|
||||
/// // A fast, choppy spread mean-reverts (negatively autocorrelated
|
||||
/// // changes) ⇒ VR(2) < 1.
|
||||
/// let a = b + 2.0 * (f64::from(t) * 2.5).sin();
|
||||
/// last = vr.update((a, b));
|
||||
/// }
|
||||
/// assert!(last.unwrap() < 1.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VarianceRatio {
|
||||
period: usize,
|
||||
q: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl VarianceRatio {
|
||||
/// Construct a new variance-ratio test.
|
||||
///
|
||||
/// `period` is the look-back window of spreads; `q` is the aggregation
|
||||
/// horizon (number of one-step changes summed per long-horizon change).
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`Error::InvalidPeriod`] if `q < 2` or if `period < q + 2`
|
||||
/// (which would leave fewer than two long-horizon observations).
|
||||
pub fn new(period: usize, q: usize) -> Result<Self> {
|
||||
if q < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "variance ratio needs q >= 2",
|
||||
});
|
||||
}
|
||||
if period < q + 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "variance ratio needs period >= q + 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
q,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured look-back window of spreads.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured aggregation horizon `q`.
|
||||
pub const fn q(&self) -> usize {
|
||||
self.q
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for VarianceRatio {
|
||||
type Input = (f64, f64);
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, input: (f64, f64)) -> Option<f64> {
|
||||
let (a, b) = input;
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(a - b);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let spreads: Vec<f64> = self.window.iter().copied().collect();
|
||||
// One-step changes.
|
||||
let returns: Vec<f64> = spreads.windows(2).map(|w| w[1] - w[0]).collect();
|
||||
let m = returns.len() as f64;
|
||||
let mean = returns.iter().sum::<f64>() / m;
|
||||
let var_one = returns.iter().map(|r| (r - mean) * (r - mean)).sum::<f64>() / m;
|
||||
if var_one <= 0.0 {
|
||||
// Flat spread: the random-walk null value.
|
||||
return Some(1.0);
|
||||
}
|
||||
// Overlapping q-step changes; their mean is q·mean by construction.
|
||||
let q_mean = self.q as f64 * mean;
|
||||
let long: Vec<f64> = returns.windows(self.q).map(|w| w.iter().sum()).collect();
|
||||
let count = long.len() as f64;
|
||||
let var_q = long
|
||||
.iter()
|
||||
.map(|y| (y - q_mean) * (y - q_mean))
|
||||
.sum::<f64>()
|
||||
/ count;
|
||||
Some(var_q / (self.q as f64 * var_one))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"VarianceRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_parameters() {
|
||||
assert!(VarianceRatio::new(10, 1).is_err()); // q must be >= 2
|
||||
assert!(VarianceRatio::new(3, 2).is_err()); // period must be >= q + 2
|
||||
assert!(VarianceRatio::new(4, 2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let vr = VarianceRatio::new(60, 4).unwrap();
|
||||
assert_eq!(vr.period(), 60);
|
||||
assert_eq!(vr.q(), 4);
|
||||
assert_eq!(vr.warmup_period(), 60);
|
||||
assert_eq!(vr.name(), "VarianceRatio");
|
||||
assert!(!vr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmup_returns_none() {
|
||||
let mut vr = VarianceRatio::new(4, 2).unwrap();
|
||||
assert_eq!(vr.update((1.0, 0.0)), None);
|
||||
assert_eq!(vr.update((2.0, 0.0)), None);
|
||||
assert_eq!(vr.update((3.0, 0.0)), None);
|
||||
assert!(vr.update((4.0, 0.0)).is_some());
|
||||
assert!(vr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternating_changes_give_zero_ratio() {
|
||||
// Spreads 0,2,1,3,2 ⇒ changes 2,-1,2,-1; q = 2 overlapping sums are all
|
||||
// 1 (constant) ⇒ Var(q) = 0 ⇒ VR = 0 (perfect mean reversion).
|
||||
let pairs = [(0.0, 0.0), (2.0, 0.0), (1.0, 0.0), (3.0, 0.0), (2.0, 0.0)];
|
||||
let last = VarianceRatio::new(5, 2)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oscillating_spread_is_below_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..200)
|
||||
.map(|t| {
|
||||
let b = 100.0 + f64::from(t);
|
||||
(b + 2.0 * (f64::from(t) * 2.5).sin(), b)
|
||||
})
|
||||
.collect();
|
||||
let last = VarianceRatio::new(60, 2)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 1.0, "VR {last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_spread_returns_one() {
|
||||
let pairs: Vec<(f64, f64)> = (0..30)
|
||||
.map(|t| (5.0 + f64::from(t), f64::from(t)))
|
||||
.collect();
|
||||
let last = VarianceRatio::new(10, 3)
|
||||
.unwrap()
|
||||
.batch(&pairs)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_non_negative() {
|
||||
let pairs: Vec<(f64, f64)> = (0..150)
|
||||
.map(|t| {
|
||||
let b = 50.0 + 0.3 * f64::from(t);
|
||||
(b + (f64::from(t) * 0.5).sin() * 2.0, b)
|
||||
})
|
||||
.collect();
|
||||
let mut vr = VarianceRatio::new(40, 4).unwrap();
|
||||
for v in vr.batch(&pairs).into_iter().flatten() {
|
||||
assert!(v >= 0.0, "VR {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut vr = VarianceRatio::new(6, 2).unwrap();
|
||||
for t in 0..12 {
|
||||
vr.update((f64::from(t) + (f64::from(t) * 0.7).sin(), f64::from(t)));
|
||||
}
|
||||
assert!(vr.is_ready());
|
||||
vr.reset();
|
||||
assert!(!vr.is_ready());
|
||||
assert_eq!(vr.update((1.0, 0.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let pairs: Vec<(f64, f64)> = (0..100)
|
||||
.map(|t| {
|
||||
let b = 30.0 + 0.7 * f64::from(t);
|
||||
(b + (f64::from(t) * 0.4).sin() * 1.5, b)
|
||||
})
|
||||
.collect();
|
||||
let batch = VarianceRatio::new(32, 3).unwrap().batch(&pairs);
|
||||
let mut vr = VarianceRatio::new(32, 3).unwrap();
|
||||
let streamed: Vec<_> = pairs.iter().map(|p| vr.update(*p)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
// builds — library code is still linted for genuinely large stack arrays.
|
||||
#![cfg_attr(test, allow(clippy::large_stack_arrays))]
|
||||
|
||||
mod cross_section;
|
||||
mod derivatives;
|
||||
mod error;
|
||||
mod microstructure;
|
||||
@@ -50,21 +51,23 @@ mod traits;
|
||||
|
||||
pub mod indicators;
|
||||
|
||||
pub use cross_section::{CrossSection, Member};
|
||||
pub use derivatives::DerivativesTick;
|
||||
pub use error::{Error, Result};
|
||||
pub use indicators::{
|
||||
AbandonedBaby, AccelerationBands, AccelerationBandsOutput, AcceleratorOscillator, AdOscillator,
|
||||
AdaptiveCycle, Adl, AdvanceBlock, 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, BollingerBands, BollingerBandwidth,
|
||||
BollingerOutput, Breakaway, 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, CyberneticCycle, Decycler, DecyclerOscillator, Dema,
|
||||
DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, DojiStar,
|
||||
AdaptiveCycle, Adl, AdvanceBlock, AdvanceDecline, 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, Breakaway,
|
||||
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, CyberneticCycle, 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,
|
||||
@@ -72,30 +75,32 @@ pub use indicators::{
|
||||
FibonacciPivots, FibonacciPivotsOutput, FisherTransform, Footprint, FootprintOutput,
|
||||
ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, FundingBasis, FundingRate,
|
||||
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, GarmanKlassVolatility,
|
||||
GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
|
||||
HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
|
||||
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput,
|
||||
HiLoActivator, HighWave, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility,
|
||||
Hma, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
|
||||
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
|
||||
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
InverseFisherTransform, InvertedHammer, Jma, KagiBars, 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,
|
||||
McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi, Microprice, MidPoint, MidPrice,
|
||||
MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar, Natr, Nvi, OIPriceDivergence,
|
||||
OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu, OpeningRange,
|
||||
OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN,
|
||||
PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, 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, RollingVwap,
|
||||
RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SarExt, SeparatingLines, SharpeRatio,
|
||||
ShootingStar, ShortLine, SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio,
|
||||
SpearmanCorrelation, SpinningTop, StalledPattern, StandardError, StandardErrorBands,
|
||||
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, McGinleyDynamic, MedianAbsoluteDeviation, MedianPrice, Mfi,
|
||||
Microprice, MidPoint, MidPrice, MinusDi, MinusDm, Mom, MorningDojiStar, MorningEveningStar,
|
||||
Natr, Nvi, OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta,
|
||||
OpeningMarubozu, OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull,
|
||||
OrderBookImbalanceTop1, OrderBookImbalanceTopN, OuHalfLife, PainIndex, PairSpreadZScore,
|
||||
PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, 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,
|
||||
@@ -106,12 +111,12 @@ pub use indicators::{
|
||||
TpoProfileOutput, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsf, Tsi, Tsv,
|
||||
TtmSqueeze, TtmSqueezeOutput, Tweezer, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput,
|
||||
ValueAtRisk, Variance, 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,
|
||||
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,
|
||||
};
|
||||
// `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
@@ -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 **314 indicators** across
|
||||
- A per-indicator deep dive for every one of the **325 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 &
|
||||
|
||||
Generated
+7
-7
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -26,12 +26,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.4.6",
|
||||
"wickra-darwin-x64": "0.4.6",
|
||||
"wickra-linux-arm64-gnu": "0.4.6",
|
||||
"wickra-linux-x64-gnu": "0.4.6",
|
||||
"wickra-win32-arm64-msvc": "0.4.6",
|
||||
"wickra-win32-x64-msvc": "0.4.6"
|
||||
"wickra-darwin-arm64": "0.4.7",
|
||||
"wickra-darwin-x64": "0.4.7",
|
||||
"wickra-linux-arm64-gnu": "0.4.7",
|
||||
"wickra-linux-x64-gnu": "0.4.7",
|
||||
"wickra-win32-arm64-msvc": "0.4.7",
|
||||
"wickra-win32-x64-msvc": "0.4.7"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
|
||||
@@ -80,6 +80,13 @@ test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "indicator_update_crosssection"
|
||||
path = "fuzz_targets/indicator_update_crosssection.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "tick_aggregator"
|
||||
path = "fuzz_targets/tick_aggregator.rs"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#![no_main]
|
||||
//! Fuzz market-breadth `Indicator<Input = CrossSection>` implementations with
|
||||
//! arbitrary cross-section streams.
|
||||
//!
|
||||
//! Each iteration consumes a byte stream, interprets it as a sequence of `f64`
|
||||
//! values (8 bytes each), packs consecutive pairs into [`Member`]s (a `change`
|
||||
//! and a `volume`, with the high/low flags taken from the value bit parity), and
|
||||
//! groups the members into bounded-size [`CrossSection`] ticks. Cross-sections
|
||||
//! are built with `new_unchecked` so the fuzzer can explore degenerate values
|
||||
//! (non-finite changes, negative volumes, empty-adjacent groups) that the
|
||||
//! validating constructor would reject — the indicators must never panic,
|
||||
//! streaming or batched.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AdvanceDecline, BatchExt, CrossSection, Indicator, Member};
|
||||
|
||||
#[inline(never)]
|
||||
fn drive<I>(make: impl Fn() -> I, sections: &[CrossSection])
|
||||
where
|
||||
I: Indicator<Input = CrossSection, Output = f64> + BatchExt,
|
||||
{
|
||||
let mut streaming = make();
|
||||
for section in sections {
|
||||
let _ = streaming.update(section.clone());
|
||||
}
|
||||
let _ = make().batch(sections);
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let floats: Vec<f64> = data
|
||||
.chunks_exact(8)
|
||||
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
|
||||
.collect();
|
||||
let members: Vec<Member> = floats
|
||||
.chunks_exact(2)
|
||||
.map(|c| Member::new(c[0], c[1], c[0].to_bits() & 1 == 1, c[1].to_bits() & 1 == 1))
|
||||
.collect();
|
||||
// Group members into cross-sections of up to eight symbols each so a single
|
||||
// input yields a stream of ragged universes.
|
||||
let sections: Vec<CrossSection> = members
|
||||
.chunks(8)
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.map(|chunk| CrossSection::new_unchecked(chunk.to_vec(), 0))
|
||||
.collect();
|
||||
|
||||
drive(AdvanceDecline::new, §ions);
|
||||
});
|
||||
@@ -8,10 +8,7 @@
|
||||
//! panic.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{
|
||||
Alpha, BatchExt, Cointegration, Indicator, InformationRatio, LeadLagCrossCorrelation,
|
||||
PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, TreynorRatio,
|
||||
};
|
||||
use wickra_core::{Alpha, BatchExt, BetaNeutralSpread, Cointegration, DistanceSsd, GrangerCausality, Indicator, InformationRatio, KalmanHedgeRatio, LeadLagCrossCorrelation, OuHalfLife, PairSpreadZScore, PairwiseBeta, RelativeStrengthAB, RollingCorrelation, RollingCovariance, SpreadBollingerBands, SpreadHurst, TreynorRatio, VarianceRatio};
|
||||
|
||||
#[inline(never)]
|
||||
fn drive<I>(make: impl Fn() -> I, data: &[(f64, f64)])
|
||||
@@ -41,6 +38,14 @@ fuzz_target!(|data: &[u8]| {
|
||||
drive(|| Alpha::new(10, 0.0).unwrap(), &pairs);
|
||||
drive(|| PairwiseBeta::new(10).unwrap(), &pairs);
|
||||
drive(|| PairSpreadZScore::new(10, 10).unwrap(), &pairs);
|
||||
drive(|| RollingCorrelation::new(20).unwrap(), &pairs);
|
||||
drive(|| RollingCovariance::new(20).unwrap(), &pairs);
|
||||
drive(|| OuHalfLife::new(60).unwrap(), &pairs);
|
||||
drive(|| SpreadHurst::new(60).unwrap(), &pairs);
|
||||
drive(|| DistanceSsd::new(20).unwrap(), &pairs);
|
||||
drive(|| BetaNeutralSpread::new(20).unwrap(), &pairs);
|
||||
drive(|| VarianceRatio::new(60, 2).unwrap(), &pairs);
|
||||
drive(|| GrangerCausality::new(60, 1).unwrap(), &pairs);
|
||||
|
||||
// Struct-output pair indicator: drive update + batch directly (the generic
|
||||
// `drive` above only covers `Output = f64`).
|
||||
@@ -61,4 +66,16 @@ fuzz_target!(|data: &[u8]| {
|
||||
let _ = rs.update(x);
|
||||
}
|
||||
let _ = RelativeStrengthAB::new(10, 14).unwrap().batch(&pairs);
|
||||
let mut kalman_hedge_ratio = KalmanHedgeRatio::new(0.001, 0.001).unwrap();
|
||||
for &x in &pairs {
|
||||
let _ = kalman_hedge_ratio.update(x);
|
||||
}
|
||||
let _ = KalmanHedgeRatio::new(0.001, 0.001).unwrap().batch(&pairs);
|
||||
|
||||
let mut spread_bollinger_bands = SpreadBollingerBands::new(20, 2.0).unwrap();
|
||||
for &x in &pairs {
|
||||
let _ = spread_bollinger_bands.update(x);
|
||||
}
|
||||
let _ = SpreadBollingerBands::new(20, 2.0).unwrap().batch(&pairs);
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user