Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a93af60796 | |||
| 5a1d607807 | |||
| ea9da12d86 | |||
| 716eb40206 | |||
| 8115d3b33d | |||
| 4250ed99f4 | |||
| 995f119010 | |||
| 05d2e5dc61 | |||
| 404bcb040c | |||
| 00ce899cc3 | |||
| b6ead740e8 | |||
| 755f4aa0f6 | |||
| 4d602df8a3 | |||
| 3ab2d6ec2d | |||
| 5e96d41916 | |||
| 099ae66b57 | |||
| 11dd659b5f | |||
| c096943bdf | |||
| c44f625e69 | |||
| 46dc8f5a00 | |||
| a3a1ae4dba | |||
| 53941b7b07 | |||
| 72ec65bbde |
@@ -60,7 +60,7 @@ Closes #
|
||||
- [ ] Public API changes are reflected in `CHANGELOG.md`
|
||||
- [ ] Public API changes are reflected in rustdoc / README / examples
|
||||
- [ ] No `todo*.md` or other local-only notes are staged
|
||||
- [ ] License header / `LICENSE` reference unchanged (PolyForm-NC-1.0.0)
|
||||
- [ ] License header / `LICENSE` reference unchanged (MIT OR Apache-2.0)
|
||||
|
||||
## Notes for reviewers
|
||||
|
||||
|
||||
@@ -536,7 +536,7 @@ jobs:
|
||||
pkg.repository = { type: 'git', url: 'https://github.com/wickra-lib/wickra' };
|
||||
pkg.homepage = 'https://github.com/wickra-lib/wickra';
|
||||
pkg.bugs = { url: 'https://github.com/wickra-lib/wickra/issues' };
|
||||
pkg.license = 'PolyForm-Noncommercial-1.0.0';
|
||||
pkg.license = 'MIT OR Apache-2.0';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ jobs:
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
# The default GITHUB_TOKEN cannot read classic branch-protection
|
||||
# rules, so the Branch-Protection check fails with an internal error
|
||||
# and scores -1. A read-only fine-grained PAT (Administration: read,
|
||||
# Contents: read, Metadata: read) supplied as SCORECARD_TOKEN lets the
|
||||
# check read the protection settings. See
|
||||
# https://github.com/ossf/scorecard-action/blob/main/docs/authentication/fine-grained-auth-token.md
|
||||
repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||
# Publish to the public OpenSSF endpoint that backs the README badge.
|
||||
publish_results: true
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
+99
-1
@@ -7,6 +7,99 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.3] - 2026-06-04
|
||||
- **Fibonacci Time Zones** — vertical markers at Fibonacci bar-distances (1/2/3/5/8/...) from the latest swing pivot (`FIB_TIME_ZONES`).
|
||||
- **Fibonacci Channel** — a sloped base trendline plus parallel lines at Fibonacci multiples of the channel width (`FIB_CHANNEL`).
|
||||
- **Fibonacci Arcs** — semicircular retracement levels centred on the swing end, normalised by leg bar-width (`FIB_ARCS`).
|
||||
- **Fibonacci Fan** — three trendlines fanning from a swing start through its 38.2/50/61.8% retracement levels (`FIB_FAN`).
|
||||
- **Fibonacci Confluence** — densest cluster of retracement levels across recent swing legs (price + strength) (`FIB_CONFLUENCE`).
|
||||
- **Golden Pocket** — the 0.618-0.65 optimal-trade-entry band of the most recent swing leg (`GOLDEN_POCKET`).
|
||||
- **Auto-Fibonacci** — retracement anchored on the dominant (largest-magnitude) leg among recent swings (`AUTO_FIB`).
|
||||
- **Fibonacci Projection** — measured-move target zone from the last three pivots (A-B-C), projecting A->B from C (`FIB_PROJECTION`).
|
||||
- **Fibonacci Extension** — projects the latest swing leg to the canonical extension ratios (127.2/141.4/161.8/200/261.8%) (`FIB_EXTENSION`).
|
||||
- **Fibonacci Retracement** — seven retracement levels (0/23.6/38.2/50/61.8/78.6/100%) of the most recent confirmed swing leg (`FIB_RETRACEMENT`).
|
||||
|
||||
## [0.5.2] - 2026-06-03
|
||||
|
||||
### Added
|
||||
- **Three Drives** — three symmetric drives with extension legs; bullish +1, bearish -1 (`THREE_DRIVES`).
|
||||
- **Cypher** — five-point harmonic whose D retraces XC by 0.786; bullish +1, bearish -1 (`CYPHER`).
|
||||
- **Shark** — five-point harmonic with an expansion leg and 0.886-1.13 D; bullish +1, bearish -1 (`SHARK`).
|
||||
- **Crab** — five-point harmonic with the deepest (1.618 XA) D completion; bullish +1, bearish -1 (`CRAB`).
|
||||
- **Bat** — five-point harmonic with a shallow B and 0.886 D completion; bullish +1, bearish -1 (`BAT`).
|
||||
- **Butterfly** — five-point harmonic with an extended (1.27-1.618 XA) D; bullish +1, bearish -1 (`BUTTERFLY`).
|
||||
- **Gartley** — five-point harmonic with a 0.786 D completion; bullish +1, bearish -1 (`GARTLEY`).
|
||||
- **AB=CD** — four-point AB=CD harmonic: BC retraces AB, CD mirrors AB; bullish +1, bearish -1 (`ABCD`).
|
||||
- **Cup and Handle** — rounded base with a shallow handle near the rim; bullish +1, inverse -1 (`CUP_AND_HANDLE`).
|
||||
- **Rectangle / Range** — flat support and resistance; mean-reversion signal off the just-touched boundary; support +1, resistance -1 (`RECTANGLE_RANGE`).
|
||||
- **Flag / Pennant** — shallow consolidation against a sharp pole; continuation in the pole direction; bull +1, bear -1 (`FLAG_PENNANT`).
|
||||
- **Wedge (rising/falling)** — both trendlines slope the same way but converge; rising wedge -1, falling wedge +1 (`WEDGE`).
|
||||
- **Triangle (asc/desc/sym)** — converging trendlines; ascending +1, descending -1, symmetrical follows the last swing (`TRIANGLE`).
|
||||
- **Head and Shoulders** — central head flanked by two matching shoulders over a flat neckline; top -1, inverse +1 (`HEAD_AND_SHOULDERS`).
|
||||
- **Triple Top / Bottom** — three matching peaks / troughs; a stronger reversal than the double; bearish -1, bullish +1 (`TRIPLE_TOP_BOTTOM`).
|
||||
- **Double Top / Bottom** — twin-peak / twin-trough reversal confirmed on the second matching swing extreme; bearish -1, bullish +1 (`DOUBLE_TOP_BOTTOM`).
|
||||
|
||||
## [0.5.1] - 2026-06-03
|
||||
|
||||
### Added — Seasonality & Session family (12 indicators)
|
||||
|
||||
- **Volume-by-Time Profile** — mean traded volume bucketed by intraday time (`VOLUME_BY_TIME_PROFILE`).
|
||||
- **Intraday Volatility Profile** — return standard deviation bucketed by intraday time (`INTRADAY_VOLATILITY_PROFILE`).
|
||||
- **Day-of-Week Profile** — mean bar return bucketed by weekday (`DAY_OF_WEEK_PROFILE`).
|
||||
- **Time-of-Day Return Profile** — mean bar return bucketed by intraday time (`TIME_OF_DAY_RETURN_PROFILE`).
|
||||
- **Seasonal Z-Score** — z-score of the current return versus the same hour-of-day history (`SEASONAL_Z_SCORE`).
|
||||
- **Turn-of-Month** — mean daily return inside the turn-of-month window (`TURN_OF_MONTH`).
|
||||
- **Overnight/Intraday Return** — decomposition of session return into overnight and intraday legs (`OVERNIGHT_INTRADAY_RETURN`).
|
||||
- **Overnight Gap** — close-to-open return across the session boundary (`OVERNIGHT_GAP`).
|
||||
- **Average Daily Range** — mean high-low range of the last N completed sessions (`AVERAGE_DAILY_RANGE`).
|
||||
- **Session Range** — per-session (Asia/EU/US) high-low range (`SESSION_RANGE`).
|
||||
- **Session High/Low** — running high and low of the current session (`SESSION_HIGH_LOW`).
|
||||
- **Session VWAP** — session-anchored volume-weighted average price (`SESSION_VWAP`).
|
||||
|
||||
## [0.5.0] - 2026-06-03
|
||||
|
||||
### Added
|
||||
- **TICK Index** — instantaneous net advancing-minus-declining issues (`TICK_INDEX`).
|
||||
- **Absolute Breadth Index** — absolute value of net advancing-minus-declining issues (`ABSOLUTE_BREADTH_INDEX`).
|
||||
- **Cumulative Volume Index** — running total of volume-normalised net advancing volume (`CUMULATIVE_VOLUME_INDEX`).
|
||||
- **Bullish Percent Index** — percentage of the universe on a point-and-figure buy signal (`BULLISH_PERCENT_INDEX`).
|
||||
- **Up/Down Volume Ratio** — advancing volume divided by declining volume (`UP_DOWN_VOLUME_RATIO`).
|
||||
- **Percent Above Moving Average** — percentage of the universe trading above its reference moving average (`PERCENT_ABOVE_MA`).
|
||||
- **High-Low Index** — moving average of the record-high percentage (`HIGH_LOW_INDEX`).
|
||||
- **New Highs - New Lows** — net count of new period highs minus new period lows (`NEW_HIGHS_NEW_LOWS`).
|
||||
- **Breadth Thrust** — moving average of the advancing-issues share (Zweig) (`BREADTH_THRUST`).
|
||||
- **TRIN / Arms Index** — advance-decline ratio divided by the up-down volume ratio (`TRIN`).
|
||||
- **McClellan Summation Index** — running cumulative total of the McClellan Oscillator (`MCCLELLAN_SUMMATION_INDEX`).
|
||||
- **McClellan Oscillator** — spread between a 19- and 39-period EMA of ratio-adjusted net advances (`MCCLELLAN_OSCILLATOR`).
|
||||
- **Advance/Decline Volume Line** — cumulative net advancing-minus-declining volume across the universe (`AD_VOLUME_LINE`).
|
||||
- **Advance/Decline Ratio** — advancing issues divided by declining issues across the universe (`ADVANCE_DECLINE_RATIO`).
|
||||
|
||||
### Changed
|
||||
- **Relicensed** from PolyForm Noncommercial 1.0.0 to dual **MIT OR Apache-2.0**. Wickra is now OSI-approved, permissive open source; commercial use is permitted under either license. See [`LICENSE-MIT`](LICENSE-MIT) and [`LICENSE-APACHE`](LICENSE-APACHE).
|
||||
|
||||
## [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 +1217,12 @@ 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.5.3...HEAD
|
||||
[0.5.3]: https://github.com/wickra-lib/wickra/compare/v0.5.2...v0.5.3
|
||||
[0.5.2]: https://github.com/wickra-lib/wickra/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/wickra-lib/wickra/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/wickra-lib/wickra/compare/v0.4.7...v0.5.0
|
||||
[0.4.7]: https://github.com/wickra-lib/wickra/compare/v0.4.6...v0.4.7
|
||||
[0.4.6]: https://github.com/wickra-lib/wickra/compare/v0.4.5...v0.4.6
|
||||
[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
|
||||
|
||||
+3
-1
@@ -26,4 +26,6 @@ keywords:
|
||||
- quantitative-finance
|
||||
- rust
|
||||
- time-series
|
||||
license: PolyForm-Noncommercial-1.0.0
|
||||
license:
|
||||
- MIT
|
||||
- Apache-2.0
|
||||
|
||||
+35
-5
@@ -5,11 +5,11 @@ build the project, the standards a change must meet, and how to get it merged.
|
||||
|
||||
## License of contributions
|
||||
|
||||
Wickra is licensed under the **PolyForm Noncommercial License 1.0.0** (see
|
||||
[`LICENSE`](LICENSE)). By submitting a contribution you agree that it is
|
||||
licensed to the project under those same terms. The Noncommercial license
|
||||
permits use for any purpose **other than** a commercial one; keep that in mind
|
||||
when proposing features or depending on Wickra elsewhere.
|
||||
Wickra is dual-licensed under the [MIT](LICENSE-MIT) and
|
||||
[Apache-2.0](LICENSE-APACHE) licenses; users may choose either. Unless you
|
||||
explicitly state otherwise, any contribution you intentionally submit for
|
||||
inclusion in the work, as defined in the Apache-2.0 license, shall be dual
|
||||
licensed as above, without any additional terms or conditions.
|
||||
|
||||
## Project layout
|
||||
|
||||
@@ -122,3 +122,33 @@ installed. Dependabot also keeps the `.github/requirements` pins current.
|
||||
Use the issue templates under
|
||||
[`.github/ISSUE_TEMPLATE`](.github/ISSUE_TEMPLATE). For security-sensitive
|
||||
reports, follow [`SECURITY.md`](SECURITY.md) instead of opening a public issue.
|
||||
|
||||
## Developer Certificate of Origin (DCO)
|
||||
|
||||
All contributions to Wickra are made under the [Developer Certificate of
|
||||
Origin (DCO) 1.1](DCO). By signing off on your commits you certify that you
|
||||
wrote the patch, or otherwise have the right to submit it under the project's
|
||||
`MIT OR Apache-2.0` license.
|
||||
|
||||
Sign off every commit by adding a `Signed-off-by` trailer with your real name
|
||||
and email — Git adds it automatically with the `-s` flag:
|
||||
|
||||
```bash
|
||||
git commit -s -m "your message"
|
||||
```
|
||||
|
||||
This produces a trailer of the form:
|
||||
|
||||
```
|
||||
Signed-off-by: Your Name <you@example.com>
|
||||
```
|
||||
|
||||
The name and email must match the commit author. Commits without a valid
|
||||
sign-off line cannot be merged. To sign off a commit you already made, amend it
|
||||
with `git commit -s --amend`, or sign off a range with an interactive rebase.
|
||||
|
||||
## Governance
|
||||
|
||||
Wickra's decision-making and maintainership are described in
|
||||
[`GOVERNANCE.md`](GOVERNANCE.md); the current maintainers are listed in
|
||||
[`MAINTAINERS.md`](MAINTAINERS.md).
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+3
-3
@@ -12,11 +12,11 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
license-file = "LICENSE"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/wickra-lib/wickra"
|
||||
homepage = "https://github.com/wickra-lib/wickra"
|
||||
readme = "README.md"
|
||||
@@ -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.5.3" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Governance
|
||||
|
||||
Wickra is an open-source project maintained under a **single-maintainer
|
||||
("BDFL") model**. This document describes how decisions are made and how the
|
||||
project is run, so contributors know what to expect.
|
||||
|
||||
## Roles
|
||||
|
||||
- **Maintainer.** The maintainer (see [`MAINTAINERS.md`](MAINTAINERS.md)) is
|
||||
responsible for the project's direction, reviews and merges changes, cuts
|
||||
releases, and has final say on all technical and project decisions.
|
||||
- **Contributors.** Anyone who proposes changes via pull requests, files
|
||||
issues, improves documentation, or otherwise participates. Contributors do
|
||||
not need any special status to take part.
|
||||
|
||||
## Decision-making
|
||||
|
||||
- Day-to-day technical decisions (APIs, indicator implementations, refactors)
|
||||
are made by the maintainer, informed by discussion on issues and pull
|
||||
requests.
|
||||
- Proposals are raised as GitHub issues or pull requests. Significant or
|
||||
breaking changes should be opened as an issue first to agree on the approach
|
||||
before implementation.
|
||||
- The maintainer aims to act transparently: rationale for non-trivial decisions
|
||||
is recorded in the relevant issue, pull request, or commit message.
|
||||
|
||||
## Contribution flow
|
||||
|
||||
All changes — including the maintainer's own — go through pull requests so that
|
||||
CI (tests, linting, static analysis) runs against them, and so the change
|
||||
history is reviewable. Contribution requirements are documented in
|
||||
[`CONTRIBUTING.md`](CONTRIBUTING.md), including the Developer Certificate of
|
||||
Origin sign-off that every commit must carry.
|
||||
|
||||
## Becoming a maintainer
|
||||
|
||||
The project currently has one maintainer. Maintainership may be extended to
|
||||
contributors who have demonstrated sustained, high-quality involvement, at the
|
||||
current maintainer's discretion. If the project grows to multiple maintainers,
|
||||
this document will be updated to describe shared decision-making.
|
||||
|
||||
## Continuity and succession
|
||||
|
||||
The project is designed to survive the loss of any single individual, so that
|
||||
issues can be triaged, proposed changes accepted, and releases published within
|
||||
one week of confirmed loss of the maintainer:
|
||||
|
||||
- **Credentials.** All credentials required to operate the project — the
|
||||
`wickra-lib` GitHub organization, the publishing tokens for crates.io, PyPI
|
||||
and npm, and the `wickra.org` domain registrar — are stored in a password
|
||||
manager. A trusted contact (a family member) holds **emergency access** to
|
||||
that password manager and can obtain these credentials if the maintainer can
|
||||
no longer continue.
|
||||
- **Continuity actions.** With that access, the trusted contact (or a delegate
|
||||
they appoint) can create and close issues, accept pull requests, and publish
|
||||
releases through the existing CI/CD workflows.
|
||||
- **Account recovery.** The maintainer's GitHub account has recovery configured,
|
||||
and ownership of the `wickra-lib` organization can be transferred to a new
|
||||
maintainer.
|
||||
- **Legal rights.** Legal rights to the project name and DNS are covered by the
|
||||
maintainer's estate arrangements.
|
||||
|
||||
## Code of conduct
|
||||
|
||||
All participants are expected to follow the
|
||||
[Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Changes to this document
|
||||
|
||||
This governance model may evolve as the project grows. Changes are made via
|
||||
pull request and take effect once merged.
|
||||
@@ -1,161 +0,0 @@
|
||||
# PolyForm Noncommercial License 1.0.0
|
||||
|
||||
<https://polyformproject.org/licenses/noncommercial/1.0.0>
|
||||
|
||||
## Acceptance
|
||||
|
||||
In order to get any license under these terms, you must agree
|
||||
to them as both strict obligations and conditions to all
|
||||
your licenses.
|
||||
|
||||
## Copyright License
|
||||
|
||||
The licensor grants you a copyright license for the
|
||||
software to do everything you might do with the software
|
||||
that would otherwise infringe the licensor's copyright
|
||||
in it for any permitted purpose. However, you may
|
||||
only distribute the software according to [Distribution
|
||||
License](#distribution-license) and make changes or new works
|
||||
based on the software according to [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Distribution License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to distribute copies of the software. Your license to
|
||||
distribute covers distributing the software with changes
|
||||
and new works permitted by [Changes and New Works
|
||||
License](#changes-and-new-works-license).
|
||||
|
||||
## Notices
|
||||
|
||||
You must ensure that anyone who gets a copy of any part of
|
||||
the software from you also gets a copy of these terms or the
|
||||
URL for them above, as well as copies of any plain-text lines
|
||||
beginning with `Required Notice:` that the licensor provided
|
||||
with the software. For example:
|
||||
|
||||
> Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
|
||||
## Changes and New Works License
|
||||
|
||||
The licensor grants you an additional copyright license
|
||||
to make changes and new works based on the software for any
|
||||
permitted purpose.
|
||||
|
||||
## Patent License
|
||||
|
||||
The licensor grants you a patent license for the software that
|
||||
covers patent claims the licensor can license, or becomes able
|
||||
to license, that you would infringe by using the software.
|
||||
|
||||
## Noncommercial Purposes
|
||||
|
||||
Any noncommercial purpose is a permitted purpose.
|
||||
|
||||
## Personal Uses
|
||||
|
||||
Personal use for research, experiment, and testing for
|
||||
the benefit of public knowledge, personal study, private
|
||||
entertainment, hobby projects, amateur pursuits, or religious
|
||||
observance, without any anticipated commercial application,
|
||||
is use for a permitted purpose.
|
||||
|
||||
## Noncommercial Organizations
|
||||
|
||||
Use by any charitable organization, educational institution,
|
||||
public research organization, public safety or health
|
||||
organization, environmental protection organization, or
|
||||
government institution is use for a permitted purpose regardless
|
||||
of the source of funding or obligations resulting from the
|
||||
funding.
|
||||
|
||||
## Fair Use
|
||||
|
||||
You may have "fair use" rights for the software under the
|
||||
law. These terms do not limit them.
|
||||
|
||||
## No Other Rights
|
||||
|
||||
These terms do not allow you to sublicense or transfer any of
|
||||
your licenses to anyone else, or prevent the licensor from
|
||||
granting licenses to anyone else. These terms do not imply
|
||||
any other licenses.
|
||||
|
||||
## Patent Defense
|
||||
|
||||
If you make any written claim that the software infringes or
|
||||
contributes to infringement of any patent, your patent license
|
||||
for the software granted under these terms ends immediately. If
|
||||
your company makes such a claim, your patent license ends
|
||||
immediately for work on behalf of your company.
|
||||
|
||||
## Violations
|
||||
|
||||
The first time you are notified in writing that you have
|
||||
violated any of these terms, or done anything with the software
|
||||
not covered by your licenses, your licenses can nonetheless
|
||||
continue if you come into full compliance with these terms,
|
||||
and take practical steps to correct past violations, within 32
|
||||
days of receiving notice. Otherwise, all your licenses end
|
||||
immediately.
|
||||
|
||||
## No Liability
|
||||
|
||||
***As far as the law allows, the software comes as is, without
|
||||
any warranty or condition, and the licensor will not be liable
|
||||
to you for any damages arising out of these terms or the use
|
||||
or nature of the software, under any kind of legal claim.***
|
||||
|
||||
## Definitions
|
||||
|
||||
The **licensor** is the individual or entity offering these
|
||||
terms, and the **software** is the software the licensor makes
|
||||
available under these terms.
|
||||
|
||||
**You** refers to the individual or entity agreeing to these
|
||||
terms.
|
||||
|
||||
**Your company** is any legal entity, sole proprietorship,
|
||||
or other kind of organization that you work for, plus all
|
||||
organizations that have control over, are under the control
|
||||
of, or are under common control with that organization.
|
||||
**Control** means ownership of substantially all the assets
|
||||
of an entity, or the power to direct its management and
|
||||
policies by vote, contract, or otherwise. Control can be
|
||||
direct or indirect.
|
||||
|
||||
**Your licenses** are all the licenses granted to you for the
|
||||
software under these terms.
|
||||
|
||||
**Use** means anything you do with the software requiring one
|
||||
of your licenses.
|
||||
|
||||
## Additional Permissions Granted by the Licensor
|
||||
|
||||
These additional permissions supplement the PolyForm Noncommercial
|
||||
License 1.0.0 above. They only broaden, and never narrow, the
|
||||
licenses granted to you. The text of the PolyForm Noncommercial
|
||||
License 1.0.0 above is unmodified.
|
||||
|
||||
Use by a natural person, acting for their own personal account and
|
||||
not on behalf of any third party, is use for a permitted purpose.
|
||||
This includes operating an automated trading bot or trading strategy
|
||||
on that person's own capital, whether or not it earns that person
|
||||
money.
|
||||
|
||||
For the avoidance of doubt, the licenses above already let you use,
|
||||
fork, modify, and redistribute the software, and file issues and
|
||||
contribute changes, for any permitted purpose. Personal projects,
|
||||
research, education, nonprofit organizations, government use, and
|
||||
hobby trading bots are permitted purposes.
|
||||
|
||||
Any other commercial use — in particular the commercial sale of the
|
||||
software itself, or the commercial sale of services built around it —
|
||||
requires a separate commercial license from the licensor. If you want
|
||||
to use Wickra commercially, get in touch about a license at
|
||||
<https://github.com/wickra-lib/wickra>.
|
||||
|
||||
---
|
||||
|
||||
Required Notice: Copyright 2026 kingchenc (https://github.com/wickra-lib/wickra)
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative
|
||||
Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 kingchenc and the Wickra contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Maintainers
|
||||
|
||||
This file lists the current maintainers of Wickra. See
|
||||
[`GOVERNANCE.md`](GOVERNANCE.md) for what the role entails and how the project
|
||||
is run.
|
||||
|
||||
| Maintainer | GitHub | Areas |
|
||||
| --- | --- | --- |
|
||||
| kingchenc | [@kingchenc](https://github.com/kingchenc) | All (core, bindings, CI/release, docs) |
|
||||
|
||||
## Contacting the maintainers
|
||||
|
||||
- General questions and support: see [`SUPPORT.md`](SUPPORT.md).
|
||||
- Bug reports and feature requests: open an issue using the
|
||||
[issue templates](.github/ISSUE_TEMPLATE).
|
||||
- Security reports: follow [`SECURITY.md`](SECURITY.md) — do **not** open a
|
||||
public issue.
|
||||
@@ -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=377" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
@@ -9,8 +9,9 @@
|
||||
[](https://crates.io/crates/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](LICENSE)
|
||||
[](#license)
|
||||
[](https://scorecard.dev/viewer/?uri=github.com/wickra-lib/wickra)
|
||||
[](https://www.bestpractices.dev/projects/13094)
|
||||
[](https://github.com/wickra-lib/wickra/attestations)
|
||||
[](https://docs.wickra.org)
|
||||
|
||||
@@ -47,7 +48,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 377 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 +136,7 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
314 streaming-first indicators across nineteen families. Every one passes the
|
||||
377 streaming-first indicators across twenty-four 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,17 +151,22 @@ 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 |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Alt-Chart Bars | Renko (box-size bricks), Kagi (reversal-amount lines), Point & Figure (X/O columns) |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down, Two Crows, Upside Gap Two Crows, Identical Three Crows, Three Line Strike, Three Stars in the South, Abandoned Baby, Advance Block, Belt-hold, Breakaway, Counterattack, Doji Star, Dragonfly Doji, Gravestone Doji, Long-Legged Doji, Rickshaw Man, Evening Doji Star, Morning Doji Star, Gap Side-by-Side White, High-Wave, Hikkake, Modified Hikkake, Homing Pigeon, On-Neck, In-Neck, Thrusting, Separating Lines, Kicking, Kicking by Length, Ladder Bottom, Mat Hold, Matching Low, Long Line, Short Line, Rising Three Methods, Falling Three Methods, Upside Gap Three Methods, Downside Gap Three Methods, Stalled Pattern, Stick Sandwich, Takuri, Closing Marubozu, Opening Marubozu, Tasuki Gap, Unique Three River, Concealing Baby Swallow |
|
||||
| Chart Patterns | Double Top / Bottom, Triple Top / Bottom, Head and Shoulders, Triangle (asc/desc/sym), Wedge (rising/falling), Flag / Pennant, Rectangle / Range, Cup and Handle |
|
||||
| Harmonic Patterns | AB=CD, Gartley, Butterfly, Bat, Crab, Shark, Cypher, Three Drives |
|
||||
| Fibonacci | Fibonacci Retracement, Fibonacci Extension, Fibonacci Projection, Auto-Fibonacci, Golden Pocket, Fibonacci Confluence, Fibonacci Fan, Fibonacci Arcs, Fibonacci Channel, Fibonacci Time Zones |
|
||||
| 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, Advance/Decline Ratio, Advance/Decline Volume Line, McClellan Oscillator, McClellan Summation Index, TRIN / Arms Index, Breadth Thrust, New Highs - New Lows, High-Low Index, Percent Above Moving Average, Up/Down Volume Ratio, Bullish Percent Index, Cumulative Volume Index, Absolute Breadth Index, TICK Index |
|
||||
| Risk / Performance | Sharpe Ratio, Sortino Ratio, Calmar Ratio, Omega Ratio, Max Drawdown, Average Drawdown, Drawdown Duration, Pain Index, Value at Risk, Conditional Value at Risk (CVaR), Profit Factor, Gain/Loss Ratio, Recovery Factor, Kelly Criterion, Treynor Ratio, Information Ratio, Alpha (Jensen) |
|
||||
| Seasonality & Session | Session VWAP, Session High/Low, Session Range, Average Daily Range, Overnight Gap, Overnight/Intraday Return, Turn-of-Month, Seasonal Z-Score, Time-of-Day Return Profile, Day-of-Week Profile, Intraday Volatility Profile, Volume-by-Time Profile |
|
||||
|
||||
Every candlestick pattern emits a signed per-bar value — `+1.0` bullish,
|
||||
`−1.0` bearish, `0.0` none — so the family drops straight into a feature matrix
|
||||
@@ -239,7 +245,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 377 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
@@ -323,13 +329,20 @@ shape together before you invest the time.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. See [LICENSE](LICENSE).
|
||||
Licensed under either of
|
||||
|
||||
In plain English: use it, fork it, modify it, redistribute it, file issues, send
|
||||
pull requests — all welcome. Personal projects, research, education, non-profits,
|
||||
government, hobby trading bots: all fine. The one thing that's not allowed is
|
||||
commercial sale of the software or of services built around it. If you want to
|
||||
use Wickra commercially, get in touch about a license.
|
||||
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or
|
||||
<http://www.apache.org/licenses/LICENSE-2.0>)
|
||||
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
|
||||
|
||||
at your option. Use it, fork it, modify it, redistribute it — commercially or
|
||||
not — file issues, send pull requests; all welcome.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally submitted
|
||||
for inclusion in the work by you, as defined in the Apache-2.0 license, shall be
|
||||
dual licensed as above, without any additional terms or conditions.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Roadmap
|
||||
|
||||
This roadmap describes the project's direction at a high level. It is
|
||||
intentionally non-binding: priorities shift with feedback and available time,
|
||||
and the authoritative, up-to-date view of planned work is the
|
||||
[issue tracker](https://github.com/wickra-lib/wickra/issues). Shipped changes
|
||||
are recorded in [`CHANGELOG.md`](CHANGELOG.md).
|
||||
|
||||
## Status
|
||||
|
||||
Wickra is **pre-1.0**. The public API is largely stable but may still change in
|
||||
minor releases; breaking changes are called out in the changelog.
|
||||
|
||||
## Themes
|
||||
|
||||
- **Indicator coverage.** Continue broadening the indicator catalogue across
|
||||
families (trend, momentum, volatility, volume, statistics, market profile,
|
||||
and more), each with the same streaming/batch parity and test guarantees.
|
||||
- **API stabilization toward 1.0.** Settle the public `Indicator` and
|
||||
`BarBuilder` traits and the binding surfaces, then commit to semantic
|
||||
versioning stability for a 1.0 release.
|
||||
- **Performance.** Keep per-tick updates O(1) and maintain the benchmark suite;
|
||||
investigate further allocation and cache improvements.
|
||||
- **Bindings parity.** Keep the Python, Node.js and WebAssembly bindings in
|
||||
lockstep with the Rust core, including type stubs and platform coverage.
|
||||
- **Documentation.** Maintain a deep-dive page per indicator on
|
||||
<https://docs.wickra.org>, plus quickstarts and cookbook material.
|
||||
- **Project health.** Maintain test coverage, static and dynamic analysis,
|
||||
signed releases, and supply-chain monitoring.
|
||||
|
||||
## How to influence the roadmap
|
||||
|
||||
Open or comment on an issue, or start with the
|
||||
[feature-request template](.github/ISSUE_TEMPLATE/feature_request.md).
|
||||
Well-scoped proposals and pull requests are the most effective way to move an
|
||||
item forward.
|
||||
+99
-3
@@ -2,13 +2,13 @@
|
||||
|
||||
## Supported versions
|
||||
|
||||
Wickra is pre-1.0. Security fixes are applied to the latest released `0.1.x`
|
||||
Wickra is pre-1.0. Security fixes are applied to the latest released `0.5.x`
|
||||
version only; please upgrade to the newest release before reporting an issue.
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| 0.1.x (latest) | :white_check_mark: |
|
||||
| older 0.1.x | :x: |
|
||||
| 0.5.x (latest) | :white_check_mark: |
|
||||
| older 0.5.x | :x: |
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
@@ -41,3 +41,99 @@ PyPI/npm packages, and the build/release workflows in `.github/workflows/`.
|
||||
|
||||
Out of scope: vulnerabilities in third-party dependencies (report those
|
||||
upstream; we track them via Dependabot and `cargo-deny`).
|
||||
|
||||
## Security assurance case
|
||||
|
||||
This is a short, evidence-backed argument for why Wickra can be used safely.
|
||||
|
||||
**Security requirements.** Wickra is a computational library: it ingests
|
||||
numeric market data and produces indicator values. It stores no user
|
||||
credentials, authenticates no external users, and implements no cryptography of
|
||||
its own. The requirements are therefore: (1) memory safety and freedom from
|
||||
undefined behaviour, (2) robust handling of untrusted/degenerate numeric input
|
||||
without panics or unbounded resource use, (3) integrity of the published
|
||||
artifacts, and (4) a healthy dependency supply chain.
|
||||
|
||||
**How the requirements are met.**
|
||||
|
||||
- *Memory safety* — the core and all bindings are written in Rust. The crates
|
||||
forbid or minimise `unsafe`, so the compiler guarantees memory and thread
|
||||
safety for the indicator logic.
|
||||
- *Input robustness* — every indicator validates its parameters and rejects
|
||||
non-finite inputs at construction; behaviour on edge cases (flat markets,
|
||||
warmup, reset) is pinned by unit tests, and the public update paths are
|
||||
exercised by coverage-guided fuzzing (`cargo-fuzz` / libFuzzer) in CI.
|
||||
- *Static and dynamic analysis* — every push and pull request runs Clippy
|
||||
(`clippy::pedantic`, warnings-as-errors), CodeQL, fuzzing, and the full test
|
||||
suite, with 100% line coverage on the core crate tracked by Codecov.
|
||||
- *Artifact integrity* — releases are built in CI, commits and tags are signed,
|
||||
the `main` branch requires signed commits, and release artifacts carry build
|
||||
provenance attestations.
|
||||
- *Supply chain* — dependencies are pinned and monitored with Dependabot and
|
||||
audited with `cargo-deny` (license + advisory checks) on every change.
|
||||
|
||||
**Residual risk.** The optional `live-binance` feature opens a TLS WebSocket to
|
||||
an exchange using the platform TLS library; transport security therefore
|
||||
depends on that library, not on Wickra. Wickra is not a trading system and is
|
||||
provided "as is" — see the disclaimers in `README.md` and the licenses.
|
||||
|
||||
## Secrets management
|
||||
|
||||
The project stores **no** secrets or credentials in the version control system.
|
||||
Secrets required by automation (publishing tokens, the about-sync PAT) are kept
|
||||
exclusively as **GitHub Actions encrypted secrets** and referenced via the
|
||||
`secrets.*` context; they are never written to the repository, logs, or build
|
||||
artifacts. GitHub **secret scanning with push protection** is enabled to block
|
||||
accidental commits of credentials. Secrets follow least privilege (the narrowest
|
||||
scope that works) and are rotated when a holder changes or on suspected
|
||||
exposure.
|
||||
|
||||
## Verifying releases
|
||||
|
||||
Released artifacts can be verified for integrity and authenticity:
|
||||
|
||||
- **Build provenance.** Release assets carry GitHub build provenance
|
||||
attestations. Verify a downloaded asset with the GitHub CLI:
|
||||
`gh attestation verify <file> --repo wickra-lib/wickra`.
|
||||
- **Signed tags.** Each release corresponds to a signed git tag (`vX.Y.Z`);
|
||||
the tag signature identifies the maintainer who authorised the release.
|
||||
- **Registry integrity.** Packages are distributed over HTTPS from crates.io,
|
||||
PyPI and npm, which serve package checksums that package managers verify on
|
||||
install.
|
||||
|
||||
The release is published only by the maintainer through the tag-triggered
|
||||
release workflow, so a verified tag signature establishes the expected
|
||||
publisher identity.
|
||||
|
||||
## Support timeline and end of support
|
||||
|
||||
Wickra is **pre-1.0**: only the **latest released `0.y.z`** version receives
|
||||
security fixes. When a newer release is published, the previous version
|
||||
**immediately reaches end of support** and will not receive further fixes;
|
||||
users should upgrade to the latest release. The supported-versions table above
|
||||
is authoritative. After the `1.0.0` release this policy will be revised to
|
||||
support a defined window of releases.
|
||||
|
||||
## Remediation policy (dependencies and code scanning)
|
||||
|
||||
- **Severity threshold.** Vulnerabilities of **medium severity or higher** in
|
||||
the project's own code or its dependencies are remediated promptly and before
|
||||
the next release; lower-severity findings are addressed on a best-effort
|
||||
basis.
|
||||
- **Automated enforcement (SCA).** Every change is evaluated by `cargo-deny`
|
||||
(RUSTSEC advisories + license policy) and Dependabot; a known-vulnerable
|
||||
dependency fails CI and **blocks the change** until resolved or explicitly
|
||||
waived with justification.
|
||||
- **Automated enforcement (SAST).** Every change is evaluated by CodeQL and
|
||||
Clippy (`-D warnings`); findings **block the change** in CI until fixed.
|
||||
- **Pre-release gate.** A release is not cut while an unresolved medium-or-higher
|
||||
SCA/SAST finding is outstanding.
|
||||
|
||||
## Vulnerability exploitability (VEX)
|
||||
|
||||
Advisories reported by `cargo-deny`/Dependabot for third-party dependencies that
|
||||
do **not** affect Wickra (e.g. the vulnerable code path is not reachable, or the
|
||||
affected feature is not enabled) are triaged and recorded — with the
|
||||
not-affected justification — in the `cargo-deny` configuration (`deny.toml`) and
|
||||
the relevant pull request, rather than forcing an unnecessary dependency bump.
|
||||
This serves as the project's exploitability (VEX) record.
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Support
|
||||
|
||||
Thanks for using Wickra! Here is where to get help, depending on what you need.
|
||||
|
||||
## Documentation first
|
||||
|
||||
Most questions are answered in the documentation:
|
||||
|
||||
- **Docs site:** <https://docs.wickra.org> — quickstarts for Rust, Python,
|
||||
Node.js and WebAssembly, a per-indicator reference, warmup periods, the data
|
||||
layer, and an FAQ.
|
||||
- **README:** <https://github.com/wickra-lib/wickra#readme> — installation and a
|
||||
quick overview.
|
||||
- **API docs (Rust):** <https://docs.rs/wickra>.
|
||||
|
||||
## Questions and help
|
||||
|
||||
- Ask a question with the
|
||||
[question issue template](.github/ISSUE_TEMPLATE/question.md).
|
||||
- Browse [existing issues](https://github.com/wickra-lib/wickra/issues) — your
|
||||
question may already be answered.
|
||||
|
||||
## Bugs and feature requests
|
||||
|
||||
- **Bugs:** use the bug-report issue template.
|
||||
- **Feature requests / new indicators:** use the feature-request template.
|
||||
|
||||
## Security issues
|
||||
|
||||
Please do **not** report security vulnerabilities through public issues. Follow
|
||||
the process in [`SECURITY.md`](SECURITY.md) (private GitHub advisory or email).
|
||||
|
||||
## Support expectations
|
||||
|
||||
Wickra is maintained by a single maintainer on a best-effort basis. Issues are
|
||||
triaged and acknowledged as time allows; there is no commercial support or SLA.
|
||||
Clear, reproducible reports get help fastest.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Threat model
|
||||
|
||||
This document describes Wickra's attack surface and the threats considered,
|
||||
together with their mitigations. It complements the security assurance case in
|
||||
[`SECURITY.md`](SECURITY.md). Wickra is a computational technical-analysis
|
||||
library (a Rust core with Python, Node.js and WebAssembly bindings), not a
|
||||
network service or trading system; the attack surface is correspondingly small.
|
||||
|
||||
## Assets
|
||||
|
||||
- **Integrity of computed indicator values** — consumers may use them in
|
||||
automated decisions, so silently wrong output is the primary concern.
|
||||
- **Availability of the calling process** — a library must not crash or hang
|
||||
its host on malformed input.
|
||||
- **Integrity of published artifacts** — the crates, wheels and npm packages
|
||||
users install.
|
||||
- **The build and release pipeline** and its secrets (publishing tokens).
|
||||
|
||||
## Actors / trust boundaries
|
||||
|
||||
- **Library consumer** (trusted) — calls the API with numeric data. Data may
|
||||
originate from untrusted sources (e.g. a market feed), so *input values* are
|
||||
treated as untrusted even though the caller is trusted.
|
||||
- **Optional live feed** — with the `live-binance` feature, data crosses a
|
||||
network boundary from an exchange over TLS.
|
||||
- **Contributors** (semi-trusted) — propose changes via pull requests.
|
||||
- **Supply chain** — upstream dependencies and the CI/CD platform.
|
||||
|
||||
## Threats and mitigations
|
||||
|
||||
| Threat | Mitigation |
|
||||
| --- | --- |
|
||||
| Memory-safety exploit (buffer overflow, UAF) via crafted input | Pure safe Rust; `unsafe` is forbidden/minimised, so the compiler precludes these classes. |
|
||||
| Denial of service via malformed/degenerate input (NaN, infinities, extreme magnitudes) | Indicators reject non-finite inputs and validate parameters at construction; update paths are exercised by coverage-guided fuzzing and unit tests for edge cases. |
|
||||
| Silently incorrect results | 100% line coverage on the core crate; reference-value tests against known-good sources; streaming/batch parity tests. |
|
||||
| Integer overflow / panics | `clippy::pedantic` with `-D warnings`; debug assertions and overflow checks enabled in test/fuzz builds. |
|
||||
| Adversary-in-the-middle on the optional live feed | Connection uses TLS via the platform library; transport security is delegated to that reviewed implementation. |
|
||||
| Compromised dependency (supply chain) | Dependencies pinned (`Cargo.lock`, hash-locked CI requirements), monitored by Dependabot, audited by `cargo-deny` (advisories + licenses) on every change. |
|
||||
| Malicious or accidental change to `main` | Branch protection requires signed commits and blocks force-push and deletion; all changes flow through pull requests with required CI; static analysis (CodeQL, Clippy) and fuzzing run on every change. |
|
||||
| Compromised CI / leaked secrets | Workflows use least-privilege `permissions:`; secrets live only as encrypted GitHub Actions secrets; secret scanning with push protection is enabled; workflows are linted by `zizmor`. |
|
||||
| Tampered release artifact | Releases are built in CI, tags are signed, and assets carry build provenance attestations (verifiable with `gh attestation verify`). |
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Wickra implements no authentication, authorization or cryptography of its own,
|
||||
stores no user data, and exposes no network listener; those threat classes do
|
||||
not apply.
|
||||
- Vulnerabilities in third-party dependencies that do not affect Wickra are
|
||||
tracked as exploitability (VEX) records (see [`SECURITY.md`](SECURITY.md)).
|
||||
|
||||
## Maintenance
|
||||
|
||||
This threat model is reviewed when the architecture changes materially (for
|
||||
example, a new input family, a new network feature, or a new release channel).
|
||||
@@ -9,7 +9,7 @@ edition.workspace = true
|
||||
# also emits `cargo::` directives that require >= 1.77 — that older floor is
|
||||
# subsumed by the 1.88 requirement now.
|
||||
rust-version = "1.88"
|
||||
license-file.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://www.npmjs.com/package/wickra)
|
||||
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for Node.js. `npm install wickra` —
|
||||
prebuilt native binary, no system dependencies.**
|
||||
@@ -67,7 +67,5 @@ risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
|
||||
@@ -297,6 +297,22 @@ const candleScalar = {
|
||||
TasukiGap: { make: () => new wickra.TasukiGap(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
UniqueThreeRiver: { make: () => new wickra.UniqueThreeRiver(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ConcealingBabySwallow: { make: () => new wickra.ConcealingBabySwallow(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
DoubleTopBottom: { make: () => new wickra.DoubleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
TripleTopBottom: { make: () => new wickra.TripleTopBottom(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
HeadAndShoulders: { make: () => new wickra.HeadAndShoulders(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Triangle: { make: () => new wickra.Triangle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Wedge: { make: () => new wickra.Wedge(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
FlagPennant: { make: () => new wickra.FlagPennant(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
RectangleRange: { make: () => new wickra.RectangleRange(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
CupAndHandle: { make: () => new wickra.CupAndHandle(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Abcd: { make: () => new wickra.Abcd(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Gartley: { make: () => new wickra.Gartley(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Butterfly: { make: () => new wickra.Butterfly(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Bat: { make: () => new wickra.Bat(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Crab: { make: () => new wickra.Crab(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Shark: { make: () => new wickra.Shark(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
Cypher: { make: () => new wickra.Cypher(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
ThreeDrives: { make: () => new wickra.ThreeDrives(), step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -369,6 +385,16 @@ const multi = {
|
||||
// Family 13: Ichimoku & alternative charts
|
||||
Ichimoku: { make: () => new wickra.Ichimoku(9, 26, 52, 26), fields: ['tenkan', 'kijun', 'senkouA', 'senkouB', 'chikou'], step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
HeikinAshi: { make: () => new wickra.HeikinAshi(), fields: ['open', 'high', 'low', 'close'], step: (ind, i) => ind.update(open[i], high[i], low[i], close[i]), batch: (ind) => ind.batch(open, high, low, close) },
|
||||
FibRetracement: { make: () => new wickra.FibRetracement(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibExtension: { make: () => new wickra.FibExtension(), fields: ['level1272', 'level1414', 'level1618', 'level2000', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibProjection: { make: () => new wickra.FibProjection(), fields: ['level618', 'level1000', 'level1618', 'level2618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
AutoFib: { make: () => new wickra.AutoFib(), fields: ['level0', 'level236', 'level382', 'level500', 'level618', 'level786', 'level1000'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
GoldenPocket: { make: () => new wickra.GoldenPocket(), fields: ['low', 'mid', 'high'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibConfluence: { make: () => new wickra.FibConfluence(), fields: ['price', 'strength'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibFan: { make: () => new wickra.FibFan(), fields: ['fan382', 'fan500', 'fan618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibArcs: { make: () => new wickra.FibArcs(), fields: ['arc382', 'arc500', 'arc618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibChannel: { make: () => new wickra.FibChannel(), fields: ['base', 'level618', 'level1000', 'level1618'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
FibTimeZones: { make: () => new wickra.FibTimeZones(), fields: ['onZone', 'barsToNext'], step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
@@ -529,6 +555,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 +653,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 +1277,145 @@ 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('market breadth: 14 indicators reference values + batch parity', () => {
|
||||
const flags4 = [false, false, false, false];
|
||||
|
||||
// Advance/Decline Ratio: 3/1 = 3 ; 0 advancers -> 0.
|
||||
const adr = new wickra.AdvanceDeclineRatio();
|
||||
assert.equal(adr.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4), 3.0);
|
||||
assert.equal(adr.update([-1, -1, -1, -1], [10, 10, 10, 10], flags4, flags4), 0.0);
|
||||
assert.deepEqual(
|
||||
Array.from(
|
||||
new wickra.AdvanceDeclineRatio().batch(
|
||||
[[1, 1, 1, -1], [-1, -1, -1, -1]],
|
||||
[[10, 10, 10, 10], [10, 10, 10, 10]],
|
||||
[flags4, flags4],
|
||||
[flags4, flags4],
|
||||
),
|
||||
),
|
||||
[3.0, 0.0],
|
||||
);
|
||||
|
||||
// AD Volume Line: cumulative net advancing volume.
|
||||
const adv = new wickra.AdVolumeLine();
|
||||
assert.equal(adv.update([1, -1], [150, 50], [false, false], [false, false]), 100.0);
|
||||
assert.equal(adv.update([1, -1], [60, 60], [false, false], [false, false]), 100.0);
|
||||
|
||||
// McClellan Oscillator + Summation: seed 0, then -50.
|
||||
const osc = new wickra.McClellanOscillator();
|
||||
assert.ok(Math.abs(osc.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9);
|
||||
assert.ok(Math.abs(osc.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9);
|
||||
const msi = new wickra.McClellanSummationIndex();
|
||||
assert.ok(Math.abs(msi.update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4)) < 1e-9);
|
||||
assert.ok(Math.abs(msi.update([-1, -1, -1, 1], [10, 10, 10, 10], flags4, flags4) - -50.0) < 1e-9);
|
||||
|
||||
// TRIN: balanced breadth -> 1.
|
||||
assert.ok(
|
||||
Math.abs(new wickra.Trin().update([1, 1, 1, -1], [50, 50, 50, 50], flags4, flags4) - 1.0) < 1e-9,
|
||||
);
|
||||
|
||||
// Breadth Thrust(2): warmup null, then SMA(2) of [0.8, 0.6] = 0.7.
|
||||
const bt = new wickra.BreadthThrust(2);
|
||||
const up10 = Array(10).fill(false);
|
||||
assert.equal(bt.update([...Array(8).fill(1), -1, -1], Array(10).fill(10), up10, up10), null);
|
||||
assert.ok(
|
||||
Math.abs(bt.update([...Array(6).fill(1), -1, -1, -1, -1], Array(10).fill(10), up10, up10) - 0.7) < 1e-9,
|
||||
);
|
||||
|
||||
// New Highs - New Lows: 2 - 1 = 1.
|
||||
assert.equal(
|
||||
new wickra.NewHighsNewLows().update([1, 1, -1], [10, 10, 10], [true, true, false], [false, false, true]),
|
||||
1.0,
|
||||
);
|
||||
|
||||
// High-Low Index(2): warmup null, then SMA(2) of [80, 60] = 70.
|
||||
const hli = new wickra.HighLowIndex(2);
|
||||
assert.equal(
|
||||
hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(8).fill(true), false, false], [...Array(8).fill(false), true, true]),
|
||||
null,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
hli.update(Array(10).fill(1), Array(10).fill(10), [...Array(6).fill(true), false, false, false, false], [...Array(6).fill(false), true, true, true, true]) - 70.0,
|
||||
) < 1e-9,
|
||||
);
|
||||
|
||||
// Percent Above MA: 3/4 -> 75 (5-array update with aboveMa).
|
||||
assert.equal(
|
||||
new wickra.PercentAboveMa().update([1, 1, 1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, true, false]),
|
||||
75.0,
|
||||
);
|
||||
|
||||
// Up/Down Volume Ratio: 150/50 = 3.
|
||||
assert.equal(
|
||||
new wickra.UpDownVolumeRatio().update([1, -1], [150, 50], [false, false], [false, false]),
|
||||
3.0,
|
||||
);
|
||||
|
||||
// Bullish Percent Index: 2/4 -> 50 (5-array update with onBuySignal).
|
||||
assert.equal(
|
||||
new wickra.BullishPercentIndex().update([1, 1, -1, -1], [10, 10, 10, 10], flags4, flags4, [true, true, false, false]),
|
||||
50.0,
|
||||
);
|
||||
|
||||
// Cumulative Volume Index: (100/200) -> 0.5.
|
||||
assert.ok(
|
||||
Math.abs(new wickra.CumulativeVolumeIndex().update([1, -1], [150, 50], [false, false], [false, false]) - 0.5) < 1e-9,
|
||||
);
|
||||
|
||||
// Absolute Breadth Index: |2 - 3| = 1.
|
||||
assert.equal(
|
||||
new wickra.AbsoluteBreadthIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)),
|
||||
1.0,
|
||||
);
|
||||
|
||||
// TICK Index: 2 - 3 = -1.
|
||||
assert.equal(
|
||||
new wickra.TickIndex().update([1, 1, -1, -1, -1], Array(5).fill(10), Array(5).fill(false), Array(5).fill(false)),
|
||||
-1.0,
|
||||
);
|
||||
});
|
||||
|
||||
test('market breadth: rejects ragged universe', () => {
|
||||
assert.throws(() => new wickra.Trin().update([1, -1], [10], [false, false], [false, false]));
|
||||
assert.throws(() =>
|
||||
new wickra.PercentAboveMa().update([1, -1], [10, 10], [false, false], [false, false], [true]),
|
||||
);
|
||||
});
|
||||
|
||||
test('OI / flow / liquidation indicators reference values', () => {
|
||||
// OI +10% while price flat -> divergence +0.1.
|
||||
const div = new wickra.OIPriceDivergence(1);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Streaming-vs-batch equivalence and reference values for the Seasonality &
|
||||
// Session family. These indicators consume the full candle (open, high, low,
|
||||
// close, volume, timestamp), so they have a dedicated suite.
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const wickra = require('..');
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const N = 240;
|
||||
const close = Array.from({ length: N }, (_, i) => 100 + Math.sin(i * 0.3) * 5 + Math.cos(i * 0.1) * 3);
|
||||
const open = close.map((c, i) => c + Math.sin(i * 0.5) * 0.5);
|
||||
const high = close.map((c, i) => Math.max(open[i], c) + 1);
|
||||
const low = close.map((c, i) => Math.min(open[i], c) - 1);
|
||||
const volume = Array.from({ length: N }, (_, i) => 1000 + (i % 24) * 50);
|
||||
const ts = Array.from({ length: N }, (_, i) => i * HOUR);
|
||||
|
||||
function eq(a, b) {
|
||||
if (Number.isNaN(a)) return Number.isNaN(b);
|
||||
return Math.abs(a - b) < 1e-9;
|
||||
}
|
||||
|
||||
function streamScalar(ind, i) {
|
||||
const v = ind.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
|
||||
return v === null || v === undefined ? NaN : v;
|
||||
}
|
||||
|
||||
function checkScalar(name, make) {
|
||||
test(`${name} streaming equals batch`, () => {
|
||||
const a = make();
|
||||
const b = make();
|
||||
const batch = b.batch(open, high, low, close, volume, ts);
|
||||
for (let i = 0; i < N; i += 1) {
|
||||
assert.ok(eq(streamScalar(a, i), batch[i]), `${name} row ${i}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkMatrix(name, make, k, pick) {
|
||||
test(`${name} streaming equals batch`, () => {
|
||||
const a = make();
|
||||
const b = make();
|
||||
const batch = b.batch(open, high, low, close, volume, ts);
|
||||
for (let i = 0; i < N; i += 1) {
|
||||
const out = a.update(open[i], high[i], low[i], close[i], volume[i], ts[i]);
|
||||
for (let j = 0; j < k; j += 1) {
|
||||
const s = out === null || out === undefined ? NaN : pick(out, j);
|
||||
assert.ok(eq(s, batch[i * k + j]), `${name} row ${i} col ${j}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
checkScalar('SessionVwap', () => new wickra.SessionVwap(0));
|
||||
checkScalar('OvernightGap', () => new wickra.OvernightGap(0));
|
||||
checkScalar('SeasonalZScore', () => new wickra.SeasonalZScore(0));
|
||||
checkScalar('AverageDailyRange', () => new wickra.AverageDailyRange(3, 0));
|
||||
checkScalar('TurnOfMonth', () => new wickra.TurnOfMonth(3, 1, 0));
|
||||
|
||||
checkMatrix('SessionHighLow', () => new wickra.SessionHighLow(0), 2, (o, j) => (j === 0 ? o.high : o.low));
|
||||
checkMatrix('SessionRange', () => new wickra.SessionRange(0), 3, (o, j) => [o.asia, o.eu, o.us][j]);
|
||||
checkMatrix(
|
||||
'OvernightIntradayReturn',
|
||||
() => new wickra.OvernightIntradayReturn(0),
|
||||
2,
|
||||
(o, j) => (j === 0 ? o.overnight : o.intraday),
|
||||
);
|
||||
checkMatrix('TimeOfDayReturnProfile', () => new wickra.TimeOfDayReturnProfile(24, 0), 24, (o, j) => o[j]);
|
||||
checkMatrix('IntradayVolatilityProfile', () => new wickra.IntradayVolatilityProfile(12, 0), 12, (o, j) => o[j]);
|
||||
checkMatrix('VolumeByTimeProfile', () => new wickra.VolumeByTimeProfile(24, 0), 24, (o, j) => o[j]);
|
||||
checkMatrix('DayOfWeekProfile', () => new wickra.DayOfWeekProfile(0), 7, (o, j) => o[j]);
|
||||
|
||||
test('SessionVwap reference value', () => {
|
||||
const vwap = new wickra.SessionVwap(0);
|
||||
assert.ok(eq(vwap.update(100, 100, 100, 100, 10, 0), 100));
|
||||
assert.ok(eq(vwap.update(110, 110, 110, 110, 30, HOUR), 107.5));
|
||||
assert.ok(eq(vwap.update(200, 200, 200, 200, 5, 24 * HOUR), 200));
|
||||
});
|
||||
|
||||
test('OvernightGap reference value', () => {
|
||||
const gap = new wickra.OvernightGap(0);
|
||||
assert.equal(gap.update(99, 101, 98, 100, 1, 0), null);
|
||||
assert.ok(eq(gap.update(105, 106, 104, 105.5, 1, 24 * HOUR), 0.05));
|
||||
});
|
||||
|
||||
test('SessionHighLow reference object', () => {
|
||||
const shl = new wickra.SessionHighLow(0);
|
||||
shl.update(100, 105, 99, 101, 1, 0);
|
||||
const out = shl.update(101, 108, 100, 107, 1, HOUR);
|
||||
assert.ok(eq(out.high, 108));
|
||||
assert.ok(eq(out.low, 99));
|
||||
});
|
||||
|
||||
test('AverageDailyRange rejects zero period', () => {
|
||||
assert.throws(() => new wickra.AverageDailyRange(0, 0));
|
||||
});
|
||||
Vendored
+720
@@ -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
|
||||
@@ -329,6 +349,79 @@ export interface PnfColumnValue {
|
||||
high: number
|
||||
low: number
|
||||
}
|
||||
export interface SessionHighLowValue {
|
||||
high: number
|
||||
low: number
|
||||
}
|
||||
export interface SessionRangeValue {
|
||||
asia: number
|
||||
eu: number
|
||||
us: number
|
||||
}
|
||||
export interface OvernightIntradayReturnValue {
|
||||
overnight: number
|
||||
intraday: number
|
||||
}
|
||||
export interface FibRetracementValue {
|
||||
level0: number
|
||||
level236: number
|
||||
level382: number
|
||||
level500: number
|
||||
level618: number
|
||||
level786: number
|
||||
level1000: number
|
||||
}
|
||||
export interface FibExtensionValue {
|
||||
level1272: number
|
||||
level1414: number
|
||||
level1618: number
|
||||
level2000: number
|
||||
level2618: number
|
||||
}
|
||||
export interface FibProjectionValue {
|
||||
level618: number
|
||||
level1000: number
|
||||
level1618: number
|
||||
level2618: number
|
||||
}
|
||||
export interface AutoFibValue {
|
||||
level0: number
|
||||
level236: number
|
||||
level382: number
|
||||
level500: number
|
||||
level618: number
|
||||
level786: number
|
||||
level1000: number
|
||||
}
|
||||
export interface GoldenPocketValue {
|
||||
low: number
|
||||
mid: number
|
||||
high: number
|
||||
}
|
||||
export interface FibConfluenceValue {
|
||||
price: number
|
||||
strength: number
|
||||
}
|
||||
export interface FibFanValue {
|
||||
fan382: number
|
||||
fan500: number
|
||||
fan618: number
|
||||
}
|
||||
export interface FibArcsValue {
|
||||
arc382: number
|
||||
arc500: number
|
||||
arc618: number
|
||||
}
|
||||
export interface FibChannelValue {
|
||||
base: number
|
||||
level618: number
|
||||
level1000: number
|
||||
level1618: number
|
||||
}
|
||||
export interface FibTimeZonesValue {
|
||||
onZone: number
|
||||
barsToNext: number
|
||||
}
|
||||
export type SmaNode = SMA
|
||||
export declare class SMA {
|
||||
constructor(period: number)
|
||||
@@ -786,6 +879,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 +1016,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)
|
||||
@@ -2859,6 +3092,150 @@ export declare class ConcealingBabySwallow {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DoubleTopBottomNode = DoubleTopBottom
|
||||
export declare class DoubleTopBottom {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TripleTopBottomNode = TripleTopBottom
|
||||
export declare class TripleTopBottom {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HeadAndShouldersNode = HeadAndShoulders
|
||||
export declare class HeadAndShoulders {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TriangleNode = Triangle
|
||||
export declare class Triangle {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type WedgeNode = Wedge
|
||||
export declare class Wedge {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FlagPennantNode = FlagPennant
|
||||
export declare class FlagPennant {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RectangleRangeNode = RectangleRange
|
||||
export declare class RectangleRange {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CupAndHandleNode = CupAndHandle
|
||||
export declare class CupAndHandle {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AbcdNode = Abcd
|
||||
export declare class Abcd {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type GartleyNode = Gartley
|
||||
export declare class Gartley {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ButterflyNode = Butterfly
|
||||
export declare class Butterfly {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BatNode = Bat
|
||||
export declare class Bat {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CrabNode = Crab
|
||||
export declare class Crab {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SharkNode = Shark
|
||||
export declare class Shark {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CypherNode = Cypher
|
||||
export declare class Cypher {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ThreeDrivesNode = ThreeDrives
|
||||
export declare class ThreeDrives {
|
||||
constructor()
|
||||
update(open: number, high: number, low: number, close: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OrderBookImbalanceTop1Node = OrderBookImbalanceTop1
|
||||
export declare class OrderBookImbalanceTop1 {
|
||||
constructor()
|
||||
@@ -3084,6 +3461,141 @@ 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 AdvanceDeclineRatioNode = AdvanceDeclineRatio
|
||||
export declare class AdvanceDeclineRatio {
|
||||
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 AdVolumeLineNode = AdVolumeLine
|
||||
export declare class AdVolumeLine {
|
||||
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 McClellanOscillatorNode = McClellanOscillator
|
||||
export declare class McClellanOscillator {
|
||||
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 McClellanSummationIndexNode = McClellanSummationIndex
|
||||
export declare class McClellanSummationIndex {
|
||||
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 TrinNode = Trin
|
||||
export declare class Trin {
|
||||
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 BreadthThrustNode = BreadthThrust
|
||||
export declare class BreadthThrust {
|
||||
constructor(period: number)
|
||||
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 NewHighsNewLowsNode = NewHighsNewLows
|
||||
export declare class NewHighsNewLows {
|
||||
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 HighLowIndexNode = HighLowIndex
|
||||
export declare class HighLowIndex {
|
||||
constructor(period: number)
|
||||
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 PercentAboveMaNode = PercentAboveMa
|
||||
export declare class PercentAboveMa {
|
||||
constructor()
|
||||
update(change: Array<number>, volume: Array<number>, newHigh: Array<boolean>, newLow: Array<boolean>, aboveMa: Array<boolean>): number | null
|
||||
batch(change: Array<Array<number>>, volume: Array<Array<number>>, newHigh: Array<Array<boolean>>, newLow: Array<Array<boolean>>, aboveMa: Array<Array<boolean>>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type UpDownVolumeRatioNode = UpDownVolumeRatio
|
||||
export declare class UpDownVolumeRatio {
|
||||
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 BullishPercentIndexNode = BullishPercentIndex
|
||||
export declare class BullishPercentIndex {
|
||||
constructor()
|
||||
update(change: Array<number>, volume: Array<number>, newHigh: Array<boolean>, newLow: Array<boolean>, onBuySignal: Array<boolean>): number | null
|
||||
batch(change: Array<Array<number>>, volume: Array<Array<number>>, newHigh: Array<Array<boolean>>, newLow: Array<Array<boolean>>, onBuySignal: Array<Array<boolean>>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CumulativeVolumeIndexNode = CumulativeVolumeIndex
|
||||
export declare class CumulativeVolumeIndex {
|
||||
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 AbsoluteBreadthIndexNode = AbsoluteBreadthIndex
|
||||
export declare class AbsoluteBreadthIndex {
|
||||
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 TickIndexNode = TickIndex
|
||||
export declare class TickIndex {
|
||||
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)
|
||||
@@ -3262,3 +3774,211 @@ export declare class Alpha {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SessionVwapNode = SessionVwap
|
||||
export declare class SessionVwap {
|
||||
constructor(utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
utcOffsetMinutes(): number
|
||||
}
|
||||
export type OvernightGapNode = OvernightGap
|
||||
export declare class OvernightGap {
|
||||
constructor(utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
utcOffsetMinutes(): number
|
||||
}
|
||||
export type SeasonalZScoreNode = SeasonalZScore
|
||||
export declare class SeasonalZScore {
|
||||
constructor(utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
utcOffsetMinutes(): number
|
||||
}
|
||||
export type TimeOfDayReturnProfileNode = TimeOfDayReturnProfile
|
||||
export declare class TimeOfDayReturnProfile {
|
||||
constructor(buckets: number, utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
buckets(): number
|
||||
utcOffsetMinutes(): number
|
||||
}
|
||||
export type IntradayVolatilityProfileNode = IntradayVolatilityProfile
|
||||
export declare class IntradayVolatilityProfile {
|
||||
constructor(buckets: number, utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
buckets(): number
|
||||
utcOffsetMinutes(): number
|
||||
}
|
||||
export type VolumeByTimeProfileNode = VolumeByTimeProfile
|
||||
export declare class VolumeByTimeProfile {
|
||||
constructor(buckets: number, utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
buckets(): number
|
||||
utcOffsetMinutes(): number
|
||||
}
|
||||
export type DayOfWeekProfileNode = DayOfWeekProfile
|
||||
export declare class DayOfWeekProfile {
|
||||
constructor(utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): Array<number> | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
utcOffsetMinutes(): number
|
||||
}
|
||||
export type AverageDailyRangeNode = AverageDailyRange
|
||||
export declare class AverageDailyRange {
|
||||
constructor(period: number, utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TurnOfMonthNode = TurnOfMonth
|
||||
export declare class TurnOfMonth {
|
||||
constructor(nFirst: number, nLast: number, utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): number | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SessionHighLowNode = SessionHighLow
|
||||
export declare class SessionHighLow {
|
||||
constructor(utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionHighLowValue | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SessionRangeNode = SessionRange
|
||||
export declare class SessionRange {
|
||||
constructor(utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): SessionRangeValue | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OvernightIntradayReturnNode = OvernightIntradayReturn
|
||||
export declare class OvernightIntradayReturn {
|
||||
constructor(utcOffsetMinutes: number)
|
||||
update(open: number, high: number, low: number, close: number, volume: number, timestamp: number): OvernightIntradayReturnValue | null
|
||||
batch(open: Array<number>, high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>, timestamp: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibRetracementNode = FibRetracement
|
||||
export declare class FibRetracement {
|
||||
constructor()
|
||||
update(high: number, low: number): FibRetracementValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibExtensionNode = FibExtension
|
||||
export declare class FibExtension {
|
||||
constructor()
|
||||
update(high: number, low: number): FibExtensionValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibProjectionNode = FibProjection
|
||||
export declare class FibProjection {
|
||||
constructor()
|
||||
update(high: number, low: number): FibProjectionValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AutoFibNode = AutoFib
|
||||
export declare class AutoFib {
|
||||
constructor()
|
||||
update(high: number, low: number): AutoFibValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type GoldenPocketNode = GoldenPocket
|
||||
export declare class GoldenPocket {
|
||||
constructor()
|
||||
update(high: number, low: number): GoldenPocketValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibConfluenceNode = FibConfluence
|
||||
export declare class FibConfluence {
|
||||
constructor()
|
||||
update(high: number, low: number): FibConfluenceValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibFanNode = FibFan
|
||||
export declare class FibFan {
|
||||
constructor()
|
||||
update(high: number, low: number): FibFanValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibArcsNode = FibArcs
|
||||
export declare class FibArcs {
|
||||
constructor()
|
||||
update(high: number, low: number): FibArcsValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibChannelNode = FibChannel
|
||||
export declare class FibChannel {
|
||||
constructor()
|
||||
update(high: number, low: number): FibChannelValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FibTimeZonesNode = FibTimeZones
|
||||
export declare class FibTimeZones {
|
||||
constructor()
|
||||
update(high: number, low: number): FibTimeZonesValue | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
|
||||
+64
-1
File diff suppressed because one or more lines are too long
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"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": [
|
||||
"wickra.darwin-arm64.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"description": "Native binding for wickra (macOS Intel). Installed automatically as an optional dependency of wickra on matching platforms.",
|
||||
"main": "wickra.darwin-x64.node",
|
||||
"files": [
|
||||
"wickra.darwin-x64.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"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": [
|
||||
"wickra.linux-arm64-gnu.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"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": [
|
||||
"wickra.linux-x64-gnu.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"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": [
|
||||
"wickra.win32-arm64-msvc.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"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": [
|
||||
"wickra.win32-x64-msvc.node"
|
||||
],
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
|
||||
Generated
+27
-27
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"version": "0.5.3",
|
||||
"license": "MIT OR Apache-2.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.5.3",
|
||||
"wickra-darwin-x64": "0.5.3",
|
||||
"wickra-linux-arm64-gnu": "0.5.3",
|
||||
"wickra-linux-x64-gnu": "0.5.3",
|
||||
"wickra-win32-arm64-msvc": "0.5.3",
|
||||
"wickra-win32-x64-msvc": "0.5.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,13 +41,13 @@
|
||||
}
|
||||
},
|
||||
"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.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.5.3.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -57,13 +57,13 @@
|
||||
}
|
||||
},
|
||||
"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.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.5.3.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -73,13 +73,13 @@
|
||||
}
|
||||
},
|
||||
"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.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.5.3.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -89,13 +89,13 @@
|
||||
}
|
||||
},
|
||||
"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.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.5.3.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -105,13 +105,13 @@
|
||||
}
|
||||
},
|
||||
"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.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.5.3.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -121,13 +121,13 @@
|
||||
}
|
||||
},
|
||||
"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.5.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.5.3.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.4.6",
|
||||
"version": "0.5.3",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "LicenseRef-Wickra-Noncommercial-1.0.0",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"keywords": [
|
||||
"trading",
|
||||
"indicators",
|
||||
@@ -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.5.3",
|
||||
"wickra-linux-arm64-gnu": "0.5.3",
|
||||
"wickra-darwin-x64": "0.5.3",
|
||||
"wickra-darwin-arm64": "0.5.3",
|
||||
"wickra-win32-x64-msvc": "0.5.3",
|
||||
"wickra-win32-arm64-msvc": "0.5.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://pypi.org/project/wickra/)
|
||||
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators for Python. `pip install wickra` — no
|
||||
system dependencies, no C build tooling.**
|
||||
@@ -66,7 +66,5 @@ risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
|
||||
@@ -4,17 +4,16 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.4.6"
|
||||
version = "0.5.3"
|
||||
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" }
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors = [{ name = "kingchenc", email = "support@wickra.org" }]
|
||||
requires-python = ">=3.9"
|
||||
keywords = ["finance", "trading", "indicators", "technical-analysis", "ta-lib"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Financial and Insurance Industry",
|
||||
"License :: Free for non-commercial use",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
|
||||
@@ -159,6 +159,16 @@ from ._wickra import (
|
||||
MarketFacilitationIndex,
|
||||
EaseOfMovement,
|
||||
# Statistics
|
||||
SpreadBollingerBands,
|
||||
KalmanHedgeRatio,
|
||||
GrangerCausality,
|
||||
VarianceRatio,
|
||||
BetaNeutralSpread,
|
||||
DistanceSsd,
|
||||
SpreadHurst,
|
||||
OuHalfLife,
|
||||
RollingCovariance,
|
||||
RollingCorrelation,
|
||||
TypicalPrice,
|
||||
MedianPrice,
|
||||
WeightedClose,
|
||||
@@ -311,6 +321,35 @@ from ._wickra import (
|
||||
TasukiGap,
|
||||
UniqueThreeRiver,
|
||||
ConcealingBabySwallow,
|
||||
# Chart patterns
|
||||
CupAndHandle,
|
||||
RectangleRange,
|
||||
FlagPennant,
|
||||
Wedge,
|
||||
Triangle,
|
||||
HeadAndShoulders,
|
||||
TripleTopBottom,
|
||||
DoubleTopBottom,
|
||||
# Harmonic patterns
|
||||
ThreeDrives,
|
||||
Cypher,
|
||||
Shark,
|
||||
Crab,
|
||||
Bat,
|
||||
Butterfly,
|
||||
Gartley,
|
||||
Abcd,
|
||||
# Fibonacci
|
||||
FibTimeZones,
|
||||
FibChannel,
|
||||
FibArcs,
|
||||
FibFan,
|
||||
FibConfluence,
|
||||
GoldenPocket,
|
||||
AutoFib,
|
||||
FibProjection,
|
||||
FibExtension,
|
||||
FibRetracement,
|
||||
# Microstructure: order book
|
||||
OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN,
|
||||
@@ -341,6 +380,22 @@ from ._wickra import (
|
||||
LiquidationFeatures,
|
||||
TermStructureBasis,
|
||||
CalendarSpread,
|
||||
# Market Breadth
|
||||
TickIndex,
|
||||
AbsoluteBreadthIndex,
|
||||
CumulativeVolumeIndex,
|
||||
BullishPercentIndex,
|
||||
UpDownVolumeRatio,
|
||||
PercentAboveMa,
|
||||
HighLowIndex,
|
||||
NewHighsNewLows,
|
||||
BreadthThrust,
|
||||
Trin,
|
||||
McClellanSummationIndex,
|
||||
McClellanOscillator,
|
||||
AdVolumeLine,
|
||||
AdvanceDeclineRatio,
|
||||
AdvanceDecline,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
@@ -359,6 +414,19 @@ from ._wickra import (
|
||||
TreynorRatio,
|
||||
InformationRatio,
|
||||
Alpha,
|
||||
# Seasonality & Session
|
||||
SessionVwap,
|
||||
SessionHighLow,
|
||||
SessionRange,
|
||||
AverageDailyRange,
|
||||
OvernightGap,
|
||||
OvernightIntradayReturn,
|
||||
TurnOfMonth,
|
||||
SeasonalZScore,
|
||||
TimeOfDayReturnProfile,
|
||||
DayOfWeekProfile,
|
||||
IntradayVolatilityProfile,
|
||||
VolumeByTimeProfile,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -497,6 +565,16 @@ __all__ = [
|
||||
"MarketFacilitationIndex",
|
||||
"EaseOfMovement",
|
||||
# Statistics
|
||||
"SpreadBollingerBands",
|
||||
"KalmanHedgeRatio",
|
||||
"GrangerCausality",
|
||||
"VarianceRatio",
|
||||
"BetaNeutralSpread",
|
||||
"DistanceSsd",
|
||||
"SpreadHurst",
|
||||
"OuHalfLife",
|
||||
"RollingCovariance",
|
||||
"RollingCorrelation",
|
||||
"TypicalPrice",
|
||||
"MedianPrice",
|
||||
"WeightedClose",
|
||||
@@ -649,6 +727,35 @@ __all__ = [
|
||||
"TasukiGap",
|
||||
"UniqueThreeRiver",
|
||||
"ConcealingBabySwallow",
|
||||
# Chart patterns
|
||||
"CupAndHandle",
|
||||
"RectangleRange",
|
||||
"FlagPennant",
|
||||
"Wedge",
|
||||
"Triangle",
|
||||
"HeadAndShoulders",
|
||||
"TripleTopBottom",
|
||||
"DoubleTopBottom",
|
||||
# Harmonic patterns
|
||||
"ThreeDrives",
|
||||
"Cypher",
|
||||
"Shark",
|
||||
"Crab",
|
||||
"Bat",
|
||||
"Butterfly",
|
||||
"Gartley",
|
||||
"Abcd",
|
||||
# Fibonacci
|
||||
"FibTimeZones",
|
||||
"FibChannel",
|
||||
"FibArcs",
|
||||
"FibFan",
|
||||
"FibConfluence",
|
||||
"GoldenPocket",
|
||||
"AutoFib",
|
||||
"FibProjection",
|
||||
"FibExtension",
|
||||
"FibRetracement",
|
||||
# Microstructure: order book
|
||||
"OrderBookImbalanceTop1",
|
||||
"OrderBookImbalanceTopN",
|
||||
@@ -679,6 +786,22 @@ __all__ = [
|
||||
"LiquidationFeatures",
|
||||
"TermStructureBasis",
|
||||
"CalendarSpread",
|
||||
# Market Breadth
|
||||
"TickIndex",
|
||||
"AbsoluteBreadthIndex",
|
||||
"CumulativeVolumeIndex",
|
||||
"BullishPercentIndex",
|
||||
"UpDownVolumeRatio",
|
||||
"PercentAboveMa",
|
||||
"HighLowIndex",
|
||||
"NewHighsNewLows",
|
||||
"BreadthThrust",
|
||||
"Trin",
|
||||
"McClellanSummationIndex",
|
||||
"McClellanOscillator",
|
||||
"AdVolumeLine",
|
||||
"AdvanceDeclineRatio",
|
||||
"AdvanceDecline",
|
||||
# Risk / Performance
|
||||
"SharpeRatio",
|
||||
"SortinoRatio",
|
||||
@@ -697,4 +820,17 @@ __all__ = [
|
||||
"TreynorRatio",
|
||||
"InformationRatio",
|
||||
"Alpha",
|
||||
# Seasonality & Session
|
||||
"SessionVwap",
|
||||
"SessionHighLow",
|
||||
"SessionRange",
|
||||
"AverageDailyRange",
|
||||
"OvernightGap",
|
||||
"OvernightIntradayReturn",
|
||||
"TurnOfMonth",
|
||||
"SeasonalZScore",
|
||||
"TimeOfDayReturnProfile",
|
||||
"DayOfWeekProfile",
|
||||
"IntradayVolatilityProfile",
|
||||
"VolumeByTimeProfile",
|
||||
]
|
||||
|
||||
+3151
-1
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -286,6 +330,70 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"ThreeDrives": (
|
||||
lambda: ta.ThreeDrives(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Cypher": (
|
||||
lambda: ta.Cypher(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Shark": (
|
||||
lambda: ta.Shark(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Crab": (
|
||||
lambda: ta.Crab(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Bat": (
|
||||
lambda: ta.Bat(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Butterfly": (
|
||||
lambda: ta.Butterfly(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Gartley": (
|
||||
lambda: ta.Gartley(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Abcd": (
|
||||
lambda: ta.Abcd(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"CupAndHandle": (
|
||||
lambda: ta.CupAndHandle(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"RectangleRange": (
|
||||
lambda: ta.RectangleRange(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"FlagPennant": (
|
||||
lambda: ta.FlagPennant(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Wedge": (
|
||||
lambda: ta.Wedge(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"Triangle": (
|
||||
lambda: ta.Triangle(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"HeadAndShoulders": (
|
||||
lambda: ta.HeadAndShoulders(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"TripleTopBottom": (
|
||||
lambda: ta.TripleTopBottom(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"DoubleTopBottom": (
|
||||
lambda: ta.DoubleTopBottom(),
|
||||
lambda ind, h, l, c, v: ind.batch(c, h, l, c),
|
||||
),
|
||||
"MIDPRICE": (lambda: ta.MIDPRICE(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
"AVGPRICE": (lambda: ta.AVGPRICE(), lambda ind, h, l, c, v: ind.batch(c, h, l, c)),
|
||||
"DX": (lambda: ta.DX(14), lambda ind, h, l, c, v: ind.batch(h, l, c)),
|
||||
@@ -752,6 +860,56 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"FibFan": (
|
||||
lambda: ta.FibFan(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
3,
|
||||
),
|
||||
"FibArcs": (
|
||||
lambda: ta.FibArcs(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
3,
|
||||
),
|
||||
"FibChannel": (
|
||||
lambda: ta.FibChannel(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
4,
|
||||
),
|
||||
"FibTimeZones": (
|
||||
lambda: ta.FibTimeZones(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
"FibRetracement": (
|
||||
lambda: ta.FibRetracement(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
7,
|
||||
),
|
||||
"FibExtension": (
|
||||
lambda: ta.FibExtension(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
5,
|
||||
),
|
||||
"FibProjection": (
|
||||
lambda: ta.FibProjection(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
4,
|
||||
),
|
||||
"AutoFib": (
|
||||
lambda: ta.AutoFib(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
7,
|
||||
),
|
||||
"GoldenPocket": (
|
||||
lambda: ta.GoldenPocket(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
3,
|
||||
),
|
||||
"FibConfluence": (
|
||||
lambda: ta.FibConfluence(),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
2,
|
||||
),
|
||||
"Vortex": (
|
||||
lambda: ta.Vortex(14),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
@@ -2262,6 +2420,293 @@ 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
|
||||
|
||||
|
||||
def test_double_top_bottom_reference():
|
||||
t = ta.DoubleTopBottom()
|
||||
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 3)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_triple_top_bottom_reference():
|
||||
t = ta.TripleTopBottom()
|
||||
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((99.99, 119.0, 99.99, 99.99, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((107.1, 117.81, 107.1, 107.1, 1.0, 5)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_head_and_shoulders_reference():
|
||||
t = ta.HeadAndShoulders()
|
||||
assert t.update((99.9, 100.0, 99.9, 99.9, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((90.0, 99.0, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((90.9, 120.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((92.0, 118.8, 92.0, 92.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((92.92, 101.0, 92.92, 92.92, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((90.9, 99.99, 90.9, 90.9, 1.0, 5)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_triangle_reference():
|
||||
t = ta.Triangle()
|
||||
assert t.update((129.87, 130.0, 129.87, 129.87, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 128.7, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 120.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((110.0, 118.8, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((111.1, 120.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0)
|
||||
assert t.update((108.0, 118.8, 108.0, 108.0, 1.0, 5)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_wedge_reference():
|
||||
t = ta.Wedge()
|
||||
assert t.update((109.89, 110.0, 109.89, 109.89, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((90.0, 108.9, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((90.9, 100.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((94.0, 99.0, 94.0, 94.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((94.94, 103.0, 94.94, 94.94, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((92.7, 101.97, 92.7, 92.7, 1.0, 5)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_flag_pennant_reference():
|
||||
t = ta.FlagPennant()
|
||||
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((130.0, 138.6, 130.0, 130.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((131.3, 143.0, 131.3, 131.3, 1.0, 4)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_rectangle_range_reference():
|
||||
t = ta.RectangleRange()
|
||||
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 121.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((99.0, 119.79, 99.0, 99.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((99.99, 108.9, 99.99, 99.99, 1.0, 4)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_cup_and_handle_reference():
|
||||
t = ta.CupAndHandle()
|
||||
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((90.0, 118.8, 90.0, 90.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((90.9, 121.0, 90.9, 90.9, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((110.0, 119.79, 110.0, 110.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((111.1, 121.0, 111.1, 111.1, 1.0, 4)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_abcd_reference():
|
||||
t = ta.Abcd()
|
||||
assert t.update((139.86, 140.0, 139.86, 139.86, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 138.6, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 124.7, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((84.7, 123.453, 84.7, 84.7, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((85.547, 93.17, 85.547, 85.547, 1.0, 4)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_gartley_reference():
|
||||
t = ta.Gartley()
|
||||
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((115.3, 138.6, 115.3, 115.3, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((116.453, 127.65, 116.453, 116.453, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((108.56, 126.3735, 108.56, 108.56, 1.0, 5)) == pytest.approx(0.0)
|
||||
assert t.update((109.6456, 119.416, 109.6456, 109.6456, 1.0, 6)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_butterfly_reference():
|
||||
t = ta.Butterfly()
|
||||
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((108.6, 138.6, 108.6, 108.6, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((109.686, 128.0, 109.686, 109.686, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((79.8, 126.72, 79.8, 79.8, 1.0, 5)) == pytest.approx(0.0)
|
||||
assert t.update((80.598, 87.78, 80.598, 80.598, 1.0, 6)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_bat_reference():
|
||||
t = ta.Bat()
|
||||
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((122.0, 138.6, 122.0, 122.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((123.22, 137.0, 123.22, 123.22, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((104.56, 135.63, 104.56, 104.56, 1.0, 5)) == pytest.approx(0.0)
|
||||
assert t.update((105.6056, 115.016, 105.6056, 105.6056, 1.0, 6)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_crab_reference():
|
||||
t = ta.Crab()
|
||||
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((120.0, 138.6, 120.0, 120.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((121.2, 137.5, 121.2, 121.2, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((75.3, 136.125, 75.3, 75.3, 1.0, 5)) == pytest.approx(0.0)
|
||||
assert t.update((76.053, 82.83, 76.053, 76.053, 1.0, 6)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_shark_reference():
|
||||
t = ta.Shark()
|
||||
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((88.0, 138.6, 88.0, 88.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((88.88, 186.8, 88.88, 88.88, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 184.932, 100.0, 100.0, 1.0, 5)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 6)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_cypher_reference():
|
||||
t = ta.Cypher()
|
||||
assert t.update((149.85, 150.0, 149.85, 149.85, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 148.5, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 140.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((120.0, 138.6, 120.0, 120.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((121.2, 168.0, 121.2, 121.2, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((114.55, 166.32, 114.55, 114.55, 1.0, 5)) == pytest.approx(0.0)
|
||||
assert t.update((115.6955, 126.005, 115.6955, 115.6955, 1.0, 6)) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_three_drives_reference():
|
||||
t = ta.ThreeDrives()
|
||||
assert t.update((119.88, 120.0, 119.88, 119.88, 1.0, 0)) == pytest.approx(0.0)
|
||||
assert t.update((100.0, 118.8, 100.0, 100.0, 1.0, 1)) == pytest.approx(0.0)
|
||||
assert t.update((101.0, 128.0, 101.0, 101.0, 1.0, 2)) == pytest.approx(0.0)
|
||||
assert t.update((108.0, 126.72, 108.0, 108.0, 1.0, 3)) == pytest.approx(0.0)
|
||||
assert t.update((109.08, 136.0, 109.08, 109.08, 1.0, 4)) == pytest.approx(0.0)
|
||||
assert t.update((122.4, 134.64, 122.4, 122.4, 1.0, 5)) == pytest.approx(-1.0)
|
||||
|
||||
|
||||
def test_fib_retracement_reference():
|
||||
t = ta.FibRetracement()
|
||||
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
|
||||
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
|
||||
|
||||
|
||||
def test_fib_extension_reference():
|
||||
t = ta.FibExtension()
|
||||
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
|
||||
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((72.8, 58.6, 38.2, 0.0, -61.8))
|
||||
|
||||
|
||||
def test_fib_projection_reference():
|
||||
t = ta.FibProjection()
|
||||
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
|
||||
assert t.update((160.0, 198.0, 160.0, 160.0, 1.0, 1)) is None
|
||||
assert t.update((161.6, 190.0, 161.6, 161.6, 1.0, 2)) is None
|
||||
assert t.update((171.0, 188.1, 171.0, 171.0, 1.0, 3)) == pytest.approx((165.28, 150.0, 125.28, 85.28))
|
||||
|
||||
|
||||
def test_auto_fib_reference():
|
||||
t = ta.AutoFib()
|
||||
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
|
||||
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((100.0, 123.6, 138.2, 150.0, 161.8, 178.6, 200.0))
|
||||
|
||||
|
||||
def test_golden_pocket_reference():
|
||||
t = ta.GoldenPocket()
|
||||
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
|
||||
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((101.0, 110.0, 101.0, 101.0, 1.0, 2)) == pytest.approx((161.8, 163.4, 165.0))
|
||||
|
||||
|
||||
def test_fib_confluence_reference():
|
||||
t = ta.FibConfluence()
|
||||
assert t.update((199.8, 200.0, 199.8, 199.8, 1.0, 0)) is None
|
||||
assert t.update((100.0, 198.0, 100.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((101.0, 160.0, 101.0, 101.0, 1.0, 2)) is None
|
||||
assert t.update((144.0, 158.4, 144.0, 144.0, 1.0, 3)) == pytest.approx((137.64, 2.0))
|
||||
|
||||
|
||||
def test_fib_fan_reference():
|
||||
t = ta.FibFan()
|
||||
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
|
||||
assert t.update((160.0, 190.0, 160.0, 160.0, 1.0, 1)) is None
|
||||
assert t.update((100.0, 150.0, 100.0, 100.0, 1.0, 2)) is None
|
||||
assert t.update((105.0, 110.0, 105.0, 105.0, 1.0, 3)) == pytest.approx((142.7, 125.0, 107.3))
|
||||
|
||||
|
||||
def test_fib_arcs_reference():
|
||||
t = ta.FibArcs()
|
||||
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
|
||||
assert t.update((160.0, 190.0, 160.0, 160.0, 1.0, 1)) is None
|
||||
assert t.update((100.0, 150.0, 100.0, 100.0, 1.0, 2)) is None
|
||||
assert t.update((105.0, 110.0, 105.0, 105.0, 1.0, 3)) == pytest.approx((133.082181, 143.30127, 153.52037))
|
||||
|
||||
|
||||
def test_fib_channel_reference():
|
||||
t = ta.FibChannel()
|
||||
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
|
||||
assert t.update((100.0, 190.0, 100.0, 100.0, 1.0, 1)) is None
|
||||
assert t.update((108.0, 110.0, 108.0, 108.0, 1.0, 2)) is None
|
||||
assert t.update((210.0, 220.0, 210.0, 210.0, 1.0, 3)) is None
|
||||
assert t.update((150.0, 200.0, 150.0, 150.0, 1.0, 4)) == pytest.approx((226.666667, 160.746667, 120.0, 54.08))
|
||||
|
||||
|
||||
def test_fib_time_zones_reference():
|
||||
t = ta.FibTimeZones()
|
||||
assert t.update((199.0, 200.0, 199.0, 199.0, 1.0, 0)) is None
|
||||
assert t.update((150.0, 190.0, 150.0, 150.0, 1.0, 1)) == pytest.approx((1.0, 1.0))
|
||||
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 2)) == pytest.approx((1.0, 1.0))
|
||||
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 3)) == pytest.approx((1.0, 2.0))
|
||||
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 4)) == pytest.approx((0.0, 1.0))
|
||||
assert t.update((151.0, 155.0, 151.0, 151.0, 1.0, 5)) == pytest.approx((1.0, 3.0))
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -2659,6 +3104,222 @@ 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 _breadth_streaming_equals_batch(indicator, change, volume, new_high, new_low):
|
||||
"""Assert a 4-array breadth indicator's batch matches its streaming output."""
|
||||
batch = indicator().batch(change, volume, new_high, new_low)
|
||||
streamer = indicator()
|
||||
streamed = np.array(
|
||||
[
|
||||
streamer.update(change[i], volume[i], new_high[i], new_low[i])
|
||||
for i in range(len(change))
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert batch.shape == (len(change),)
|
||||
assert _eq_nan(batch, streamed)
|
||||
return batch
|
||||
|
||||
|
||||
def test_advance_decline_ratio_breadth():
|
||||
change = [[1.0, 1.0, 1.0, -1.0], [1.0, 0.0, 0.0, 0.0], [-1.0, -1.0, -1.0, -1.0]]
|
||||
volume = [[10.0] * 4 for _ in range(3)]
|
||||
flags = [[False] * 4 for _ in range(3)]
|
||||
batch = _breadth_streaming_equals_batch(ta.AdvanceDeclineRatio, change, volume, flags, flags)
|
||||
# 3/1 = 3 ; 1/max(0,1) = 1 ; 0/3 = 0.
|
||||
assert list(batch) == [3.0, 1.0, 0.0]
|
||||
|
||||
|
||||
def test_ad_volume_line_breadth():
|
||||
change = [[1.0, -1.0], [1.0, -1.0], [1.0, 0.0]]
|
||||
volume = [[150.0, 50.0], [60.0, 60.0], [30.0, 0.0]]
|
||||
flags = [[False] * 2 for _ in range(3)]
|
||||
batch = _breadth_streaming_equals_batch(ta.AdVolumeLine, change, volume, flags, flags)
|
||||
# net +100 -> 100 ; net 0 -> 100 ; net +30 -> 130.
|
||||
assert list(batch) == [100.0, 100.0, 130.0]
|
||||
|
||||
|
||||
def test_mcclellan_oscillator_breadth():
|
||||
change = [[1.0, 1.0, 1.0, -1.0], [-1.0, -1.0, -1.0, 1.0], [1.0, 1.0, -1.0, -1.0]]
|
||||
volume = [[10.0] * 4 for _ in range(3)]
|
||||
flags = [[False] * 4 for _ in range(3)]
|
||||
batch = _breadth_streaming_equals_batch(ta.McClellanOscillator, change, volume, flags, flags)
|
||||
# seed 0 ; -50 ; -67.5.
|
||||
assert abs(batch[0]) < 1e-9
|
||||
assert abs(batch[1] - (-50.0)) < 1e-9
|
||||
assert abs(batch[2] - (-67.5)) < 1e-9
|
||||
|
||||
|
||||
def test_mcclellan_summation_index_breadth():
|
||||
change = [[1.0, 1.0, 1.0, -1.0], [-1.0, -1.0, -1.0, 1.0], [1.0, 1.0, -1.0, -1.0]]
|
||||
volume = [[10.0] * 4 for _ in range(3)]
|
||||
flags = [[False] * 4 for _ in range(3)]
|
||||
batch = _breadth_streaming_equals_batch(ta.McClellanSummationIndex, change, volume, flags, flags)
|
||||
# 0 ; -50 ; -117.5.
|
||||
assert abs(batch[0]) < 1e-9
|
||||
assert abs(batch[1] - (-50.0)) < 1e-9
|
||||
assert abs(batch[2] - (-117.5)) < 1e-9
|
||||
|
||||
|
||||
def test_trin_breadth():
|
||||
change = [[1.0, 1.0, 1.0, -1.0], [1.0, 1.0, -1.0, -1.0]]
|
||||
volume = [[50.0, 50.0, 50.0, 50.0], [10.0, 10.0, 40.0, 40.0]]
|
||||
flags = [[False] * 4 for _ in range(2)]
|
||||
batch = _breadth_streaming_equals_batch(ta.Trin, change, volume, flags, flags)
|
||||
# (3/1)/(150/50) = 1 ; (2/2)/(20/80) = 4.
|
||||
assert abs(batch[0] - 1.0) < 1e-9
|
||||
assert abs(batch[1] - 4.0) < 1e-9
|
||||
|
||||
|
||||
def test_breadth_thrust_breadth():
|
||||
change = [[1.0] * 8 + [-1.0] * 2, [1.0] * 6 + [-1.0] * 4]
|
||||
volume = [[10.0] * 10 for _ in range(2)]
|
||||
flags = [[False] * 10 for _ in range(2)]
|
||||
batch = ta.BreadthThrust(2).batch(change, volume, flags, flags)
|
||||
streamer = ta.BreadthThrust(2)
|
||||
streamed = np.array(
|
||||
[streamer.update(change[i], volume[i], flags[i], flags[i]) for i in range(2)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
# 0.8 (warmup -> NaN) ; SMA(2) of [0.8, 0.6] = 0.7.
|
||||
assert math.isnan(batch[0])
|
||||
assert abs(batch[1] - 0.7) < 1e-9
|
||||
|
||||
|
||||
def test_new_highs_new_lows_breadth():
|
||||
change = [[1.0, 1.0, -1.0], [1.0, -1.0, -1.0]]
|
||||
volume = [[10.0] * 3 for _ in range(2)]
|
||||
new_high = [[True, True, False], [True, False, False]]
|
||||
new_low = [[False, False, True], [False, True, True]]
|
||||
batch = _breadth_streaming_equals_batch(ta.NewHighsNewLows, change, volume, new_high, new_low)
|
||||
# 2 - 1 = 1 ; 1 - 2 = -1.
|
||||
assert list(batch) == [1.0, -1.0]
|
||||
|
||||
|
||||
def test_high_low_index_breadth():
|
||||
change = [[1.0] * 10, [1.0] * 10]
|
||||
volume = [[10.0] * 10 for _ in range(2)]
|
||||
new_high = [[True] * 8 + [False] * 2, [True] * 6 + [False] * 4]
|
||||
new_low = [[False] * 8 + [True] * 2, [False] * 6 + [True] * 4]
|
||||
batch = ta.HighLowIndex(2).batch(change, volume, new_high, new_low)
|
||||
streamer = ta.HighLowIndex(2)
|
||||
streamed = np.array(
|
||||
[streamer.update(change[i], volume[i], new_high[i], new_low[i]) for i in range(2)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
# 80% (warmup) ; SMA(2) of [80, 60] = 70.
|
||||
assert math.isnan(batch[0])
|
||||
assert abs(batch[1] - 70.0) < 1e-9
|
||||
|
||||
|
||||
def test_percent_above_ma_breadth():
|
||||
change = [[1.0, 1.0, 1.0, -1.0], [1.0, 1.0, -1.0, -1.0]]
|
||||
volume = [[10.0] * 4 for _ in range(2)]
|
||||
flags = [[False] * 4 for _ in range(2)]
|
||||
above_ma = [[True, True, True, False], [True, False, False, False]]
|
||||
batch = ta.PercentAboveMa().batch(change, volume, flags, flags, above_ma)
|
||||
streamer = ta.PercentAboveMa()
|
||||
streamed = np.array(
|
||||
[streamer.update(change[i], volume[i], flags[i], flags[i], above_ma[i]) for i in range(2)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
# 3/4 -> 75 ; 1/4 -> 25.
|
||||
assert list(batch) == [75.0, 25.0]
|
||||
|
||||
|
||||
def test_up_down_volume_ratio_breadth():
|
||||
change = [[1.0, -1.0], [1.0, 0.0]]
|
||||
volume = [[150.0, 50.0], [100.0, 0.0]]
|
||||
flags = [[False] * 2 for _ in range(2)]
|
||||
batch = _breadth_streaming_equals_batch(ta.UpDownVolumeRatio, change, volume, flags, flags)
|
||||
# 150/50 = 3 ; 100/max(0,1) = 100.
|
||||
assert list(batch) == [3.0, 100.0]
|
||||
|
||||
|
||||
def test_bullish_percent_index_breadth():
|
||||
change = [[1.0, 1.0, -1.0, -1.0], [1.0, 1.0, 1.0, 1.0]]
|
||||
volume = [[10.0] * 4 for _ in range(2)]
|
||||
flags = [[False] * 4 for _ in range(2)]
|
||||
on_buy = [[True, True, False, False], [True, True, True, True]]
|
||||
batch = ta.BullishPercentIndex().batch(change, volume, flags, flags, on_buy)
|
||||
streamer = ta.BullishPercentIndex()
|
||||
streamed = np.array(
|
||||
[streamer.update(change[i], volume[i], flags[i], flags[i], on_buy[i]) for i in range(2)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
# 2/4 -> 50 ; 4/4 -> 100.
|
||||
assert list(batch) == [50.0, 100.0]
|
||||
|
||||
|
||||
def test_cumulative_volume_index_breadth():
|
||||
change = [[1.0, -1.0], [1.0, -1.0], [0.0]]
|
||||
volume = [[150.0, 50.0], [60.0, 60.0], [0.0]]
|
||||
new_high = [[False, False], [False, False], [False]]
|
||||
new_low = [[False, False], [False, False], [False]]
|
||||
batch = ta.CumulativeVolumeIndex().batch(change, volume, new_high, new_low)
|
||||
streamer = ta.CumulativeVolumeIndex()
|
||||
streamed = np.array(
|
||||
[streamer.update(change[i], volume[i], new_high[i], new_low[i]) for i in range(3)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
# (100/200) -> 0.5 ; net 0 -> 0.5 ; zero-volume tick -> 0.5.
|
||||
assert list(batch) == [0.5, 0.5, 0.5]
|
||||
|
||||
|
||||
def test_absolute_breadth_index_breadth():
|
||||
change = [[1.0, 1.0, -1.0, -1.0, -1.0], [1.0, 1.0, 1.0, -1.0, -1.0]]
|
||||
volume = [[10.0] * 5 for _ in range(2)]
|
||||
flags = [[False] * 5 for _ in range(2)]
|
||||
batch = _breadth_streaming_equals_batch(ta.AbsoluteBreadthIndex, change, volume, flags, flags)
|
||||
# |2 - 3| = 1 ; |3 - 2| = 1.
|
||||
assert list(batch) == [1.0, 1.0]
|
||||
|
||||
|
||||
def test_tick_index_breadth():
|
||||
change = [[1.0, 1.0, -1.0, -1.0, -1.0], [1.0, 1.0, 1.0, -1.0, -1.0]]
|
||||
volume = [[10.0] * 5 for _ in range(2)]
|
||||
flags = [[False] * 5 for _ in range(2)]
|
||||
batch = _breadth_streaming_equals_batch(ta.TickIndex, change, volume, flags, flags)
|
||||
# 2 - 3 = -1 ; 3 - 2 = 1.
|
||||
assert list(batch) == [-1.0, 1.0]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Streaming-vs-batch equivalence and reference values for the Seasonality &
|
||||
Session family.
|
||||
|
||||
These indicators read the full candle (including ``timestamp``), so they have a
|
||||
dedicated test rather than joining the timestamp-less parametrize harness in
|
||||
``test_new_indicators.py``.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import wickra as ta
|
||||
|
||||
HOUR_MS = 3_600_000
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def candle_columns():
|
||||
"""240 hourly candles (10 days) with valid OHLCV and epoch-ms timestamps."""
|
||||
n = 240
|
||||
t = np.arange(n, dtype=np.float64)
|
||||
close = 100.0 + np.sin(t * 0.3) * 5.0 + np.cos(t * 0.1) * 3.0
|
||||
open_ = close + np.sin(t * 0.5) * 0.5
|
||||
high = np.maximum(open_, close) + 1.0
|
||||
low = np.minimum(open_, close) - 1.0
|
||||
volume = 1000.0 + (t % 24) * 50.0
|
||||
timestamp = (np.arange(n, dtype=np.int64)) * HOUR_MS
|
||||
return open_, high, low, close, volume, timestamp
|
||||
|
||||
|
||||
def _candles(cols):
|
||||
open_, high, low, close, volume, timestamp = cols
|
||||
return [
|
||||
(open_[i], high[i], low[i], close[i], volume[i], int(timestamp[i]))
|
||||
for i in range(len(close))
|
||||
]
|
||||
|
||||
|
||||
def _check_scalar(make, cols):
|
||||
candles = _candles(cols)
|
||||
a, b = make(), make()
|
||||
stream = np.array(
|
||||
[np.nan if (v := a.update(c)) is None else v for c in candles],
|
||||
dtype=np.float64,
|
||||
)
|
||||
batch = np.asarray(b.batch(*cols))
|
||||
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
|
||||
|
||||
|
||||
def _check_matrix(make, k, cols):
|
||||
candles = _candles(cols)
|
||||
a, b = make(), make()
|
||||
rows = []
|
||||
for c in candles:
|
||||
out = a.update(c)
|
||||
rows.append(np.full(k, np.nan) if out is None else np.asarray(out, dtype=float))
|
||||
stream = np.vstack(rows)
|
||||
batch = np.asarray(b.batch(*cols))
|
||||
assert batch.shape == (len(candles), k)
|
||||
np.testing.assert_allclose(stream, batch, equal_nan=True, rtol=1e-9, atol=1e-9)
|
||||
|
||||
|
||||
SCALAR = [
|
||||
lambda: ta.SessionVwap(0),
|
||||
lambda: ta.OvernightGap(0),
|
||||
lambda: ta.SeasonalZScore(0),
|
||||
lambda: ta.AverageDailyRange(3, 0),
|
||||
lambda: ta.TurnOfMonth(3, 1, 0),
|
||||
]
|
||||
|
||||
MATRIX = [
|
||||
(lambda: ta.SessionHighLow(0), 2),
|
||||
(lambda: ta.SessionRange(0), 3),
|
||||
(lambda: ta.OvernightIntradayReturn(0), 2),
|
||||
(lambda: ta.TimeOfDayReturnProfile(24, 0), 24),
|
||||
(lambda: ta.IntradayVolatilityProfile(12, 0), 12),
|
||||
(lambda: ta.VolumeByTimeProfile(24, 0), 24),
|
||||
(lambda: ta.DayOfWeekProfile(0), 7),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make", SCALAR)
|
||||
def test_scalar_streaming_equals_batch(make, candle_columns):
|
||||
_check_scalar(make, candle_columns)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make,k", MATRIX)
|
||||
def test_matrix_streaming_equals_batch(make, k, candle_columns):
|
||||
_check_matrix(make, k, candle_columns)
|
||||
|
||||
|
||||
def test_session_vwap_reference():
|
||||
vwap = ta.SessionVwap(0)
|
||||
# typical = close for a flat candle; volume-weighted within the day.
|
||||
v1 = vwap.update((100.0, 100.0, 100.0, 100.0, 10.0, 0))
|
||||
assert v1 == pytest.approx(100.0)
|
||||
v2 = vwap.update((110.0, 110.0, 110.0, 110.0, 30.0, HOUR_MS))
|
||||
assert v2 == pytest.approx(107.5)
|
||||
# New day re-anchors.
|
||||
v3 = vwap.update((200.0, 200.0, 200.0, 200.0, 5.0, 24 * HOUR_MS))
|
||||
assert v3 == pytest.approx(200.0)
|
||||
|
||||
|
||||
def test_overnight_gap_reference():
|
||||
gap = ta.OvernightGap(0)
|
||||
assert gap.update((99.0, 101.0, 98.0, 100.0, 1.0, 0)) is None
|
||||
g = gap.update((105.0, 106.0, 104.0, 105.5, 1.0, 24 * HOUR_MS))
|
||||
assert g == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_session_high_low_reference():
|
||||
shl = ta.SessionHighLow(0)
|
||||
shl.update((100.0, 105.0, 99.0, 101.0, 1.0, 0))
|
||||
out = shl.update((101.0, 108.0, 100.0, 107.0, 1.0, HOUR_MS))
|
||||
assert out == (108.0, 99.0)
|
||||
|
||||
|
||||
def test_volume_by_time_profile_reference():
|
||||
prof = ta.VolumeByTimeProfile(24, 0)
|
||||
out = prof.update((100.0, 100.0, 100.0, 100.0, 500.0, HOUR_MS)) # 01:00 -> bucket 1
|
||||
assert out[1] == pytest.approx(500.0)
|
||||
assert out[0] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_rejects_zero_buckets():
|
||||
with pytest.raises(ValueError):
|
||||
ta.TimeOfDayReturnProfile(0, 0)
|
||||
|
||||
|
||||
def test_average_daily_range_rejects_zero_period():
|
||||
with pytest.raises(ValueError):
|
||||
ta.AverageDailyRange(0, 0)
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/wickra-lib/wickra)
|
||||
[](https://www.npmjs.com/package/wickra-wasm)
|
||||
[](https://github.com/wickra-lib/wickra/blob/main/LICENSE)
|
||||
[](https://github.com/wickra-lib/wickra#license)
|
||||
|
||||
**Streaming-first technical indicators in the browser. `npm install
|
||||
wickra-wasm` — pure WebAssembly, runs anywhere a modern JS engine does.**
|
||||
@@ -66,7 +66,5 @@ risk. The library is provided **as is**, without warranty of any kind.
|
||||
|
||||
## License
|
||||
|
||||
Licensed under the **PolyForm Noncommercial License 1.0.0**. Personal projects,
|
||||
research, education, non-profits, and hobby trading bots are all fine; the one
|
||||
thing not allowed is commercial sale of the software or of services built
|
||||
around it. See [LICENSE](https://github.com/wickra-lib/wickra/blob/main/LICENSE).
|
||||
Licensed under either of [Apache-2.0](https://github.com/wickra-lib/wickra/blob/main/LICENSE-APACHE)
|
||||
or [MIT](https://github.com/wickra-lib/wickra/blob/main/LICENSE-MIT) at your option.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Proper nouns that appear in indicator documentation. They are real names,
|
||||
# not code identifiers, so `clippy::doc_markdown` must not demand backticks.
|
||||
# `..` keeps clippy's built-in default identifier list in addition to these.
|
||||
doc-valid-idents = ["LeBeau", ".."]
|
||||
doc-valid-idents = ["LeBeau", "McClellan", ".."]
|
||||
|
||||
@@ -5,7 +5,7 @@ version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
readme.workspace = true
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Pure calendar arithmetic for the timestamp-driven seasonality indicators.
|
||||
//!
|
||||
//! Every indicator in the *Seasonality & Session* family keys off the wall-clock
|
||||
//! fields of [`Candle::timestamp`](crate::Candle) (epoch milliseconds), shifted
|
||||
//! by a caller-supplied `utc_offset_minutes` so the buckets line up with the
|
||||
//! relevant exchange session rather than UTC. This module turns an epoch
|
||||
//! millisecond instant into its civil fields using Howard Hinnant's
|
||||
//! branch-light `civil_from_days` algorithm (the same one libc++ ships).
|
||||
//!
|
||||
//! All arithmetic is floor-based (`div_euclid`/`rem_euclid`) so instants before
|
||||
//! the Unix epoch decompose correctly without a dedicated negative-input branch.
|
||||
|
||||
/// Civil (wall-clock) decomposition of an epoch-millisecond instant.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct CivilTime {
|
||||
/// Proleptic Gregorian year (can be negative for instants before year 1).
|
||||
pub(crate) year: i64,
|
||||
/// Month of year, `1..=12`.
|
||||
pub(crate) month: u32,
|
||||
/// Day of month, `1..=31`.
|
||||
pub(crate) day: u32,
|
||||
/// Hour of day, `0..=23`.
|
||||
pub(crate) hour: u32,
|
||||
/// Minute of hour, `0..=59`.
|
||||
pub(crate) minute: u32,
|
||||
/// Day of week with Monday as `0` through Sunday as `6`.
|
||||
pub(crate) weekday: u32,
|
||||
}
|
||||
|
||||
impl CivilTime {
|
||||
/// Minute of day, `0..=1439`.
|
||||
pub(crate) const fn minute_of_day(&self) -> u32 {
|
||||
self.hour * 60 + self.minute
|
||||
}
|
||||
}
|
||||
|
||||
/// Decompose an epoch-millisecond instant into local civil fields.
|
||||
///
|
||||
/// `utc_offset_minutes` shifts the instant before decomposition: `0` yields
|
||||
/// UTC, `-300` U.S. Eastern standard time, `60` Central European time, etc.
|
||||
pub(crate) fn civil_from_timestamp(millis: i64, utc_offset_minutes: i32) -> CivilTime {
|
||||
let local_secs = millis.div_euclid(1000) + i64::from(utc_offset_minutes) * 60;
|
||||
let days = local_secs.div_euclid(86_400);
|
||||
let secs_of_day = local_secs.rem_euclid(86_400);
|
||||
let hour = (secs_of_day / 3600) as u32;
|
||||
let minute = ((secs_of_day % 3600) / 60) as u32;
|
||||
let (year, month, day) = civil_from_days(days);
|
||||
// 1970-01-01 was a Thursday; Monday-based weekday is `(z + 3) mod 7`.
|
||||
let weekday = (days + 3).rem_euclid(7) as u32;
|
||||
CivilTime {
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
weekday,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gregorian `(year, month, day)` for a day count `z` relative to 1970-01-01.
|
||||
///
|
||||
/// Howard Hinnant, "chrono-Compatible Low-Level Date Algorithms".
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let day = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
|
||||
(if month <= 2 { year + 1 } else { year }, month, day)
|
||||
}
|
||||
|
||||
/// Whether `year` is a Gregorian leap year.
|
||||
pub(crate) const fn is_leap(year: i64) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||
}
|
||||
|
||||
/// Number of days in `month` (`1..=12`) of `year`.
|
||||
pub(crate) const fn days_in_month(year: i64, month: u32) -> u32 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
_ => {
|
||||
if is_leap(year) {
|
||||
29
|
||||
} else {
|
||||
28
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn epoch_zero_is_thursday_midnight() {
|
||||
let t = civil_from_timestamp(0, 0);
|
||||
assert_eq!(
|
||||
t,
|
||||
CivilTime {
|
||||
year: 1970,
|
||||
month: 1,
|
||||
day: 1,
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
weekday: 3, // Thursday
|
||||
}
|
||||
);
|
||||
assert_eq!(t.minute_of_day(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_utc_instant_mid_year() {
|
||||
// 2021-06-15 13:45:00 UTC = 1623764700 s.
|
||||
let t = civil_from_timestamp(1_623_764_700_000, 0);
|
||||
assert_eq!(t.year, 2021);
|
||||
assert_eq!(t.month, 6);
|
||||
assert_eq!(t.day, 15);
|
||||
assert_eq!(t.hour, 13);
|
||||
assert_eq!(t.minute, 45);
|
||||
assert_eq!(t.weekday, 1); // Tuesday
|
||||
assert_eq!(t.minute_of_day(), 13 * 60 + 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_year_2021_is_friday() {
|
||||
// 2021-01-01 00:00:00 UTC = 1609459200 s — exercises the m<=2 year bump.
|
||||
let t = civil_from_timestamp(1_609_459_200_000, 0);
|
||||
assert_eq!((t.year, t.month, t.day), (2021, 1, 1));
|
||||
assert_eq!(t.weekday, 4); // Friday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_offset_rolls_to_next_day() {
|
||||
// 2021-01-01 23:30 UTC shifted +60 min -> 2021-01-02 00:30 local.
|
||||
let base = 1_609_459_200_000 + (23 * 3600 + 30 * 60) * 1000;
|
||||
let t = civil_from_timestamp(base, 60);
|
||||
assert_eq!((t.year, t.month, t.day), (2021, 1, 2));
|
||||
assert_eq!((t.hour, t.minute), (0, 30));
|
||||
assert_eq!(t.weekday, 5); // Saturday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_offset_rolls_to_previous_day() {
|
||||
// 2021-01-01 00:30 UTC shifted -60 min -> 2020-12-31 23:30 local.
|
||||
let base = 1_609_459_200_000 + 30 * 60 * 1000;
|
||||
let t = civil_from_timestamp(base, -60);
|
||||
assert_eq!((t.year, t.month, t.day), (2020, 12, 31));
|
||||
assert_eq!((t.hour, t.minute), (23, 30));
|
||||
assert_eq!(t.weekday, 3); // Thursday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_epoch_millis_floor_correctly() {
|
||||
// -1 ms -> 1969-12-31 23:59:59.999, a Wednesday.
|
||||
let t = civil_from_timestamp(-1, 0);
|
||||
assert_eq!((t.year, t.month, t.day), (1969, 12, 31));
|
||||
assert_eq!((t.hour, t.minute), (23, 59));
|
||||
assert_eq!(t.weekday, 2); // Wednesday
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn far_negative_day_count_hits_pre_era_branch() {
|
||||
// A day count below -719468 drives `z + 719468` negative, exercising the
|
||||
// `z - 146096` era branch in civil_from_days (year < 1).
|
||||
let (year, month, day) = civil_from_days(-1_000_000);
|
||||
// -1_000_000 days before 1970-01-01 is 0768-02-04 BCE (proleptic
|
||||
// Gregorian, astronomical year numbering where year 0 exists).
|
||||
assert_eq!((year, month, day), (-768, 2, 4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leap_year_rules() {
|
||||
assert!(is_leap(2000));
|
||||
assert!(!is_leap(1900));
|
||||
assert!(is_leap(2024));
|
||||
assert!(!is_leap(2023));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_in_month_all_cases() {
|
||||
assert_eq!(days_in_month(2023, 1), 31);
|
||||
assert_eq!(days_in_month(2023, 4), 30);
|
||||
assert_eq!(days_in_month(2023, 2), 28);
|
||||
assert_eq!(days_in_month(2024, 2), 29);
|
||||
assert_eq!(days_in_month(2023, 12), 31);
|
||||
assert_eq!(days_in_month(2023, 11), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leap_day_decodes() {
|
||||
// 2024-02-29 12:00 UTC.
|
||||
let secs = 1_709_208_000; // 2024-02-29T12:00:00Z
|
||||
let t = civil_from_timestamp(secs * 1000, 0);
|
||||
assert_eq!((t.year, t.month, t.day), (2024, 2, 29));
|
||||
assert_eq!(t.hour, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! 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`, the
|
||||
//! `new_high` / `new_low` extreme flags, and the `above_ma` / `on_buy_signal`
|
||||
//! state 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; `above_ma` / `on_buy_signal` are caller-supplied
|
||||
/// per-symbol state signals (whether the symbol trades above its reference moving
|
||||
/// average, and whether it is on a point-and-figure buy signal). None of the four
|
||||
/// flags carries a numeric invariant.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
#[allow(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "the four flags are independent per-symbol breadth signals, not a state machine"
|
||||
)]
|
||||
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,
|
||||
/// Whether the symbol is trading above its reference moving average
|
||||
/// (consumed by the `% Above Moving Average` breadth indicator).
|
||||
pub above_ma: bool,
|
||||
/// Whether the symbol is on a point-and-figure buy signal
|
||||
/// (consumed by the `Bullish Percent Index` breadth indicator).
|
||||
pub on_buy_signal: bool,
|
||||
}
|
||||
|
||||
impl Member {
|
||||
/// Assemble a cross-section member from its core signals, leaving the
|
||||
/// extended per-symbol state flags (`above_ma`, `on_buy_signal`) cleared.
|
||||
///
|
||||
/// 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,
|
||||
above_ma: false,
|
||||
on_buy_signal: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble a cross-section member including the extended per-symbol state
|
||||
/// signals `above_ma` and `on_buy_signal`.
|
||||
///
|
||||
/// Use this constructor for the breadth indicators that read per-symbol
|
||||
/// state (`% Above Moving Average`, `Bullish Percent Index`); [`new`](Member::new)
|
||||
/// is the shorthand that leaves both flags `false`.
|
||||
#[must_use]
|
||||
#[allow(
|
||||
clippy::fn_params_excessive_bools,
|
||||
reason = "mirrors the four independent per-symbol flag fields of Member"
|
||||
)]
|
||||
pub const fn with_signals(
|
||||
change: f64,
|
||||
volume: f64,
|
||||
new_high: bool,
|
||||
new_low: bool,
|
||||
above_ma: bool,
|
||||
on_buy_signal: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
change,
|
||||
volume,
|
||||
new_high,
|
||||
new_low,
|
||||
above_ma,
|
||||
on_buy_signal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Total volume traded by advancing symbols (those with positive `change`).
|
||||
#[must_use]
|
||||
pub fn advancing_volume(&self) -> f64 {
|
||||
self.members
|
||||
.iter()
|
||||
.filter(|m| m.change > 0.0)
|
||||
.map(|m| m.volume)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Total volume traded by declining symbols (those with negative `change`).
|
||||
#[must_use]
|
||||
pub fn declining_volume(&self) -> f64 {
|
||||
self.members
|
||||
.iter()
|
||||
.filter(|m| m.change < 0.0)
|
||||
.map(|m| m.volume)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Total volume traded across the whole universe.
|
||||
#[must_use]
|
||||
pub fn total_volume(&self) -> f64 {
|
||||
self.members.iter().map(|m| m.volume).sum()
|
||||
}
|
||||
|
||||
/// Number of symbols that printed a new period high.
|
||||
#[must_use]
|
||||
pub fn new_highs(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.new_high).count()
|
||||
}
|
||||
|
||||
/// Number of symbols that printed a new period low.
|
||||
#[must_use]
|
||||
pub fn new_lows(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.new_low).count()
|
||||
}
|
||||
|
||||
/// Number of symbols trading above their reference moving average.
|
||||
#[must_use]
|
||||
pub fn above_ma_count(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.above_ma).count()
|
||||
}
|
||||
|
||||
/// Number of symbols on a point-and-figure buy signal.
|
||||
#[must_use]
|
||||
pub fn on_buy_signal_count(&self) -> usize {
|
||||
self.members.iter().filter(|m| m.on_buy_signal).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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_leaves_extended_flags_cleared() {
|
||||
let m = Member::new(1.0, 10.0, true, false);
|
||||
assert!(!m.above_ma);
|
||||
assert!(!m.on_buy_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_signals_assembles_all_fields() {
|
||||
let m = Member::with_signals(2.0, 10.0, true, false, true, true);
|
||||
assert_eq!(m.change, 2.0);
|
||||
assert_eq!(m.volume, 10.0);
|
||||
assert!(m.new_high);
|
||||
assert!(!m.new_low);
|
||||
assert!(m.above_ma);
|
||||
assert!(m.on_buy_signal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volume_helpers_bucket_by_change_sign() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::new(1.5, 100.0, false, false), // advancing
|
||||
Member::new(2.0, 40.0, false, false), // advancing
|
||||
Member::new(-0.5, 50.0, false, false), // declining
|
||||
Member::new(0.0, 7.0, false, false), // unchanged
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.advancing_volume(), 140.0);
|
||||
assert_eq!(cs.declining_volume(), 50.0);
|
||||
assert_eq!(cs.total_volume(), 197.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_low_helpers_count_flags() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::new(1.0, 1.0, true, false),
|
||||
Member::new(1.0, 1.0, true, false),
|
||||
Member::new(-1.0, 1.0, false, true),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.new_highs(), 2);
|
||||
assert_eq!(cs.new_lows(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_helpers_count_extended_flags() {
|
||||
let cs = CrossSection::new(
|
||||
vec![
|
||||
Member::with_signals(1.0, 1.0, false, false, true, true),
|
||||
Member::with_signals(1.0, 1.0, false, false, true, false),
|
||||
Member::with_signals(-1.0, 1.0, false, false, false, true),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cs.above_ma_count(), 2);
|
||||
assert_eq!(cs.on_buy_signal_count(), 2);
|
||||
}
|
||||
}
|
||||
@@ -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,154 @@
|
||||
//! AB=CD harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{approx_equal, ratios_in, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// AB=CD — the simplest four-point harmonic pattern: an A→B leg, a B→C
|
||||
/// retracement, and a C→D leg that mirrors A→B in length:
|
||||
///
|
||||
/// ```text
|
||||
/// BC / AB ∈ [0.382, 0.886] (C retraces AB)
|
||||
/// CD / BC ∈ [1.13, 2.618] (D extends BC)
|
||||
/// AB ≈ CD (within 10%) (the two legs are equal — the defining symmetry)
|
||||
/// ```
|
||||
///
|
||||
/// Read from the last four confirmed pivots `A-B-C-D`. Output is `+1.0`
|
||||
/// (bullish, D a swing low), `-1.0` (bearish, D a swing high), or `0.0`; never
|
||||
/// `None`. See `crates/wickra-core/src/indicators/abcd.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Abcd {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Abcd {
|
||||
/// Construct a new AB=CD detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Abcd {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Abcd {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let len = pivots.len();
|
||||
let pa = pivots[len - 4];
|
||||
let pb = pivots[len - 3];
|
||||
let pc = pivots[len - 2];
|
||||
let pd = pivots[len - 1];
|
||||
let ab = (pb.price - pa.price).abs();
|
||||
let bc = (pc.price - pb.price).abs();
|
||||
let cd = (pd.price - pc.price).abs();
|
||||
let ratios_ok = ratios_in(&[(bc / ab, 0.382, 0.886), (cd / bc, 1.13, 2.618)]);
|
||||
let legs_equal = approx_equal(ab, cd, 0.10);
|
||||
if ratios_ok && legs_equal {
|
||||
return Some(if pd.direction < 0.0 { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Abcd"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Abcd::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Abcd::new();
|
||||
assert_eq!(indicator.name(), "Abcd");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Abcd::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_abcd_is_plus_one() {
|
||||
// AB = 40 down, BC = 24.7 up (0.618), CD = 40 down → AB = CD.
|
||||
let out = run(&[140.0, 100.0, 124.7, 84.7]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_abcd_is_minus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 115.3, 155.3]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_legs_do_not_trigger() {
|
||||
// CD (82) far longer than AB (40) → not an AB=CD.
|
||||
let out = run(&[150.0, 100.0, 140.0, 118.0, 200.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Abcd::new();
|
||||
for c in candles_for_pivots(&[140.0, 100.0, 124.7]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[140.0, 100.0, 124.7, 84.7]);
|
||||
let mut a = Abcd::new();
|
||||
let mut b = Abcd::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Absolute Breadth Index — the magnitude of net advancing-minus-declining issues.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Absolute Breadth Index (ABI) — the absolute value of net advancing issues,
|
||||
/// `|advancers - decliners|`.
|
||||
///
|
||||
/// The ABI ignores the *direction* of breadth and measures only its *magnitude*:
|
||||
/// a high reading means the universe moved decisively one way or the other (high
|
||||
/// internal activity / volatility), while a low reading means advances and
|
||||
/// declines were nearly balanced (a quiet, directionless market). It is sometimes
|
||||
/// called a "market thermometer" because elevated readings often cluster around
|
||||
/// turning points.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AbsoluteBreadthIndex, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut abi = AbsoluteBreadthIndex::new();
|
||||
/// // 2 advancers, 5 decliners -> |2 - 5| = 3.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(abi.update(tick), Some(3.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AbsoluteBreadthIndex {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AbsoluteBreadthIndex {
|
||||
/// Construct a new Absolute Breadth Index indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AbsoluteBreadthIndex {
|
||||
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.has_emitted = true;
|
||||
Some(net.abs())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AbsoluteBreadthIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn section(up: usize, down: 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));
|
||||
}
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let abi = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi.name(), "AbsoluteBreadthIndex");
|
||||
assert_eq!(abi.warmup_period(), 1);
|
||||
assert!(!abi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magnitude_ignores_direction() {
|
||||
let mut abi = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi.update(section(2, 5)), Some(3.0));
|
||||
// Same magnitude with the direction reversed.
|
||||
let mut abi2 = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi2.update(section(5, 2)), Some(3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balanced_universe_yields_zero() {
|
||||
let mut abi = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(abi.update(section(3, 3)), Some(0.0));
|
||||
assert!(abi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut abi = AbsoluteBreadthIndex::new();
|
||||
abi.update(section(2, 5));
|
||||
assert!(abi.is_ready());
|
||||
abi.reset();
|
||||
assert!(!abi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![section(2, 5), section(5, 2), section(3, 3)];
|
||||
let mut a = AbsoluteBreadthIndex::new();
|
||||
let mut b = AbsoluteBreadthIndex::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Advance/Decline Volume Line — cumulative net advancing-minus-declining volume.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Advance/Decline Volume Line (AD Volume Line) — the running cumulative sum of
|
||||
/// net advancing volume across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the net is `advancing volume - declining volume`,
|
||||
/// where advancing volume is the total volume of symbols with a positive change
|
||||
/// and declining volume the total volume of symbols with a negative change. The
|
||||
/// line accumulates this net over time, so a rising line means volume is flowing
|
||||
/// into advancing issues (healthy participation) while a falling line warns that
|
||||
/// declining issues are carrying the volume — the volume-weighted analogue of the
|
||||
/// plain Advance/Decline Line.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1` (defined from the
|
||||
/// first tick).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AdVolumeLine, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut adv = AdVolumeLine::new();
|
||||
/// // advancing volume 150, declining volume 50 -> net +100.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 150.0, false, false),
|
||||
/// Member::new(-1.0, 50.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(adv.update(tick), Some(100.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdVolumeLine {
|
||||
line: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdVolumeLine {
|
||||
/// Construct a new Advance/Decline Volume Line indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
line: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdVolumeLine {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let net = section.advancing_volume() - section.declining_volume();
|
||||
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 {
|
||||
"AdVolumeLine"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn tick(items: &[(f64, f64)]) -> CrossSection {
|
||||
CrossSection::new(
|
||||
items
|
||||
.iter()
|
||||
.map(|&(change, volume)| Member::new(change, volume, false, false))
|
||||
.collect(),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let adv = AdVolumeLine::new();
|
||||
assert_eq!(adv.name(), "AdVolumeLine");
|
||||
assert_eq!(adv.warmup_period(), 1);
|
||||
assert!(!adv.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_net_volume() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
|
||||
assert!(adv.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_accumulates_across_ticks() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
assert_eq!(adv.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(100.0));
|
||||
assert_eq!(adv.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(100.0));
|
||||
assert_eq!(adv.update(tick(&[(1.0, 30.0)])), Some(130.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_volume_is_ignored() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
// Unchanged symbols (zero change) contribute to neither bucket.
|
||||
assert_eq!(adv.update(tick(&[(0.0, 1000.0), (1.0, 10.0)])), Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adv = AdVolumeLine::new();
|
||||
adv.update(tick(&[(1.0, 100.0)]));
|
||||
assert!(adv.is_ready());
|
||||
adv.reset();
|
||||
assert!(!adv.is_ready());
|
||||
assert_eq!(adv.update(tick(&[(1.0, 20.0)])), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![
|
||||
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
|
||||
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
|
||||
tick(&[(1.0, 30.0)]),
|
||||
];
|
||||
let mut a = AdVolumeLine::new();
|
||||
let mut b = AdVolumeLine::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,151 @@
|
||||
//! Advance/Decline Ratio — advancing issues divided by declining issues.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Advance/Decline Ratio (ADR) — the number of advancing symbols divided by the
|
||||
/// number of declining symbols across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the ratio is `advancers / decliners`: a reading
|
||||
/// above one means advancing issues outnumber declining ones (broad strength),
|
||||
/// while a reading below one signals broad weakness. Because it is a ratio rather
|
||||
/// than a difference, the ADR is comparable across universes of different sizes.
|
||||
///
|
||||
/// When a tick has no declining symbols the denominator is floored to one, so the
|
||||
/// ratio degrades gracefully to the advancer count instead of dividing by zero.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`. The ratio is defined from the first
|
||||
/// tick, so `warmup_period == 1` and the indicator is ready after one update.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{AdvanceDeclineRatio, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut adr = AdvanceDeclineRatio::new();
|
||||
/// // 3 advancers, 1 decliner -> ratio 3.0.
|
||||
/// 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!(adr.update(tick), Some(3.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AdvanceDeclineRatio {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl AdvanceDeclineRatio {
|
||||
/// Construct a new Advance/Decline Ratio indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AdvanceDeclineRatio {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let advancers = section.advancers() as f64;
|
||||
let decliners = section.decliners().max(1) as f64;
|
||||
self.has_emitted = true;
|
||||
Some(advancers / decliners)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AdvanceDeclineRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn section(up: usize, down: 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));
|
||||
}
|
||||
// A non-empty unchanged member guarantees a valid universe when both
|
||||
// counts are zero.
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let adr = AdvanceDeclineRatio::new();
|
||||
assert_eq!(adr.name(), "AdvanceDeclineRatio");
|
||||
assert_eq!(adr.warmup_period(), 1);
|
||||
assert!(!adr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_ratio() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
assert_eq!(adr.update(section(3, 1)), Some(3.0));
|
||||
assert!(adr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_decliners_floors_denominator() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
// 4 advancers, 0 decliners -> 4 / max(0, 1) = 4.0.
|
||||
assert_eq!(adr.update(section(4, 0)), Some(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_advancers_yields_zero() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
assert_eq!(adr.update(section(0, 5)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adr = AdvanceDeclineRatio::new();
|
||||
adr.update(section(3, 1));
|
||||
assert!(adr.is_ready());
|
||||
adr.reset();
|
||||
assert!(!adr.is_ready());
|
||||
assert_eq!(adr.update(section(2, 1)), Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![section(3, 1), section(4, 0), section(0, 5), section(2, 2)];
|
||||
let mut a = AdvanceDeclineRatio::new();
|
||||
let mut b = AdvanceDeclineRatio::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Auto-Fibonacci — retracement of the most significant recent swing leg.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// How many recent pivots to consider when picking the dominant leg.
|
||||
const PIVOT_HISTORY: usize = 6;
|
||||
|
||||
/// The seven canonical retracement ratios, in ascending order.
|
||||
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
|
||||
|
||||
/// Auto-Fibonacci retracement levels for the dominant recent swing leg.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AutoFibOutput {
|
||||
/// 0.0% — the dominant leg's end.
|
||||
pub level_0: f64,
|
||||
/// 23.6% retracement.
|
||||
pub level_236: f64,
|
||||
/// 38.2% retracement.
|
||||
pub level_382: f64,
|
||||
/// 50% retracement.
|
||||
pub level_500: f64,
|
||||
/// 61.8% retracement.
|
||||
pub level_618: f64,
|
||||
/// 78.6% retracement.
|
||||
pub level_786: f64,
|
||||
/// 100% — the dominant leg's start.
|
||||
pub level_1000: f64,
|
||||
}
|
||||
|
||||
/// Auto-Fibonacci (`AutoFib`).
|
||||
///
|
||||
/// Like [`crate::indicators::FibRetracement`], but instead of always using the
|
||||
/// immediate last leg it scans the last six confirmed pivots and anchors the
|
||||
/// retracement on the single largest-magnitude leg among them — the dominant
|
||||
/// swing the market is most likely respecting.
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until two pivots
|
||||
/// have confirmed.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/auto_fib.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AutoFib {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl AutoFib {
|
||||
/// Construct a new Auto-Fibonacci tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(&self) -> Option<AutoFibOutput> {
|
||||
let dominant = self.swing.pivots().windows(2).max_by(|x, y| {
|
||||
(x[0].price - x[1].price)
|
||||
.abs()
|
||||
.total_cmp(&(y[0].price - y[1].price).abs())
|
||||
})?;
|
||||
let (start, end) = (dominant[0].price, dominant[1].price);
|
||||
let level = |r: f64| end + r * (start - end);
|
||||
Some(AutoFibOutput {
|
||||
level_0: level(RATIOS[0]),
|
||||
level_236: level(RATIOS[1]),
|
||||
level_382: level(RATIOS[2]),
|
||||
level_500: level(RATIOS[3]),
|
||||
level_618: level(RATIOS[4]),
|
||||
level_786: level(RATIOS[5]),
|
||||
level_1000: level(RATIOS[6]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AutoFib {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AutoFib {
|
||||
type Input = Candle;
|
||||
type Output = AutoFibOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<AutoFibOutput> {
|
||||
self.swing.update(candle);
|
||||
self.levels()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 2
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AutoFib"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = AutoFib::new();
|
||||
assert_eq!(indicator.name(), "AutoFib");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!AutoFib::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_pivots() {
|
||||
let mut indicator = AutoFib::new();
|
||||
let outputs: Vec<_> = candles_for_pivots(&[120.0])
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchors_on_the_largest_leg() {
|
||||
// Pivots: 130 -> 120 (small, 10) -> 220 (large, 100) -> 200 (small, 20).
|
||||
// The dominant leg is 120 -> 220; its retracement spans [120, 220].
|
||||
let mut indicator = AutoFib::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// Largest leg 120 -> 220: 0% on 220 (end), 100% on 120 (start).
|
||||
assert_relative_eq!(v.level_0, 220.0);
|
||||
assert_relative_eq!(v.level_1000, 120.0);
|
||||
assert_relative_eq!(v.level_500, 170.0);
|
||||
assert_relative_eq!(v.level_618, 220.0 + 0.618 * (120.0 - 220.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = AutoFib::new();
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert!(indicator.update(c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[130.0, 120.0, 220.0, 200.0]);
|
||||
let mut a = AutoFib::new();
|
||||
let mut b = AutoFib::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Average Daily Range (ADR) — the mean high-minus-low range of the last `period`
|
||||
//! completed calendar-day sessions.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::calendar::civil_from_timestamp;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Average Daily Range over the last `period` completed sessions.
|
||||
///
|
||||
/// The indicator tracks the running high / low of the current session (the
|
||||
/// wall-clock day of [`Candle::timestamp`](crate::Candle) shifted by
|
||||
/// `utc_offset_minutes`). When a new day begins, the just-finished session's
|
||||
/// range (`high - low`) joins a rolling window of the last `period` completed
|
||||
/// days, and the reported value is their mean. The current, still-forming day is
|
||||
/// excluded until it closes. No value is produced until the first session
|
||||
/// completes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, AverageDailyRange};
|
||||
///
|
||||
/// let hour = 3_600_000;
|
||||
/// let mut adr = AverageDailyRange::new(2, 0).unwrap();
|
||||
/// // Day 1 range 10 (high 110, low 100) — still forming, so None.
|
||||
/// assert!(adr.update(Candle::new(105.0, 110.0, 100.0, 108.0, 1.0, 0).unwrap()).is_none());
|
||||
/// // First bar of day 2 closes day 1: ADR = 10.
|
||||
/// let v = adr.update(Candle::new(108.0, 112.0, 106.0, 109.0, 1.0, 24 * hour).unwrap()).unwrap();
|
||||
/// assert!((v - 10.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AverageDailyRange {
|
||||
period: usize,
|
||||
utc_offset_minutes: i32,
|
||||
day_key: Option<(i64, u32, u32)>,
|
||||
cur_high: f64,
|
||||
cur_low: f64,
|
||||
completed: VecDeque<f64>,
|
||||
sum: f64,
|
||||
}
|
||||
|
||||
impl AverageDailyRange {
|
||||
/// Construct an ADR indicator over `period` completed days.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize, utc_offset_minutes: i32) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
utc_offset_minutes,
|
||||
day_key: None,
|
||||
cur_high: f64::NEG_INFINITY,
|
||||
cur_low: f64::INFINITY,
|
||||
completed: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, utc_offset_minutes)`.
|
||||
pub const fn params(&self) -> (usize, i32) {
|
||||
(self.period, self.utc_offset_minutes)
|
||||
}
|
||||
|
||||
/// Most recent ADR if at least one session has completed.
|
||||
pub fn value(&self) -> Option<f64> {
|
||||
if self.completed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.sum / self.completed.len() as f64)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for AverageDailyRange {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
|
||||
let key = (civil.year, civil.month, civil.day);
|
||||
match self.day_key {
|
||||
Some(prev) if prev == key => {
|
||||
if candle.high > self.cur_high {
|
||||
self.cur_high = candle.high;
|
||||
}
|
||||
if candle.low < self.cur_low {
|
||||
self.cur_low = candle.low;
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
let range = self.cur_high - self.cur_low;
|
||||
self.completed.push_back(range);
|
||||
self.sum += range;
|
||||
if self.completed.len() > self.period {
|
||||
self.sum -= self
|
||||
.completed
|
||||
.pop_front()
|
||||
.expect("len > period implies a front element");
|
||||
}
|
||||
self.day_key = Some(key);
|
||||
self.cur_high = candle.high;
|
||||
self.cur_low = candle.low;
|
||||
}
|
||||
None => {
|
||||
self.day_key = Some(key);
|
||||
self.cur_high = candle.high;
|
||||
self.cur_low = candle.low;
|
||||
}
|
||||
}
|
||||
self.value()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.day_key = None;
|
||||
self.cur_high = f64::NEG_INFINITY;
|
||||
self.cur_low = f64::INFINITY;
|
||||
self.completed.clear();
|
||||
self.sum = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
!self.completed.is_empty()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"AverageDailyRange"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
const DAY: i64 = 24 * HOUR;
|
||||
|
||||
fn c(high: f64, low: f64, ts: i64) -> Candle {
|
||||
let mid = f64::midpoint(high, low);
|
||||
Candle::new(mid, high, low, mid, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
AverageDailyRange::new(0, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_accessors() {
|
||||
let adr = AverageDailyRange::new(5, -60).unwrap();
|
||||
assert_eq!(adr.params(), (5, -60));
|
||||
assert_eq!(adr.name(), "AverageDailyRange");
|
||||
assert_eq!(adr.warmup_period(), 5);
|
||||
assert!(!adr.is_ready());
|
||||
assert!(adr.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averages_completed_day_ranges() {
|
||||
let mut adr = AverageDailyRange::new(3, 0).unwrap();
|
||||
// Day 1: range 10.
|
||||
assert!(adr.update(c(110.0, 100.0, 0)).is_none());
|
||||
assert!(adr.update(c(108.0, 104.0, HOUR)).is_none());
|
||||
// Day 2 opens -> day 1 (range 10) completes.
|
||||
let v = adr.update(c(120.0, 110.0, DAY)).unwrap();
|
||||
assert_relative_eq!(v, 10.0);
|
||||
assert!(adr.is_ready());
|
||||
// Day 3 opens -> day 2 (range 10) completes: mean of [10, 10] = 10.
|
||||
let v = adr.update(c(130.0, 100.0, 2 * DAY)).unwrap();
|
||||
assert_relative_eq!(v, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolls_off_oldest_day_beyond_period() {
|
||||
let mut adr = AverageDailyRange::new(2, 0).unwrap();
|
||||
adr.update(c(110.0, 100.0, 0)); // day 1 range 10
|
||||
let v = adr.update(c(125.0, 110.0, DAY)).unwrap(); // close day 1 -> [10]
|
||||
assert_relative_eq!(v, 10.0);
|
||||
// Close day 2 (range 125-110=15) -> window [10, 15], mean 12.5.
|
||||
let v = adr.update(c(130.0, 110.0, 2 * DAY)).unwrap();
|
||||
assert_relative_eq!(v, 12.5);
|
||||
// Close day 3 (range 130-110=20) -> window [15, 20], oldest (10) rolled off.
|
||||
let v = adr.update(c(140.0, 138.0, 3 * DAY)).unwrap();
|
||||
assert_relative_eq!(v, 17.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut adr = AverageDailyRange::new(2, 0).unwrap();
|
||||
adr.update(c(110.0, 100.0, 0));
|
||||
adr.update(c(120.0, 110.0, DAY));
|
||||
adr.reset();
|
||||
assert!(!adr.is_ready());
|
||||
assert!(adr.value().is_none());
|
||||
assert!(adr.update(c(50.0, 40.0, 2 * DAY)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..60)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + f64::from(i % 5),
|
||||
100.0 - f64::from(i % 3),
|
||||
i64::from(i) * 6 * HOUR,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = AverageDailyRange::new(4, 0).unwrap();
|
||||
let mut b = AverageDailyRange::new(4, 0).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Bat harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Bat — a 5-point (X-A-B-C-D) harmonic pattern with a shallow B and a deep
|
||||
/// `0.886` D completion:
|
||||
///
|
||||
/// ```text
|
||||
/// AB / XA ∈ [0.382, 0.50]
|
||||
/// BC / AB ∈ [0.382, 0.886]
|
||||
/// CD / BC ∈ [1.618, 2.618]
|
||||
/// AD / XA ∈ [0.84, 0.93] (≈ 0.886 — the defining D completion)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
|
||||
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/bat.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Bat {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Bat {
|
||||
/// Construct a new Bat detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Bat {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Bat {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let p = xabcd(pivots);
|
||||
let xa = (p.a - p.x).abs();
|
||||
let ab = (p.b - p.a).abs();
|
||||
let bc = (p.c - p.b).abs();
|
||||
let cd = (p.d - p.c).abs();
|
||||
let ad = (p.d - p.a).abs();
|
||||
let matched = ratios_in(&[
|
||||
(ab / xa, 0.382, 0.50),
|
||||
(bc / ab, 0.382, 0.886),
|
||||
(cd / bc, 1.618, 2.618),
|
||||
(ad / xa, 0.84, 0.93),
|
||||
]);
|
||||
if matched {
|
||||
return Some(if p.bullish { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Bat"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Bat::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Bat::new();
|
||||
assert_eq!(indicator.name(), "Bat");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Bat::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_bat_is_plus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_bat_is_minus_one() {
|
||||
let out = run(&[150.0, 110.0, 128.0, 113.0, 145.44]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_ratio_does_not_trigger() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Bat::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 122.0, 137.0, 104.56]);
|
||||
let mut a = Bat::new();
|
||||
let mut b = Bat::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).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,165 @@
|
||||
//! Breadth Thrust (Zweig) — a moving average of the advancing-issues share.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::error::Result;
|
||||
use crate::traits::Indicator;
|
||||
use crate::Sma;
|
||||
|
||||
/// Breadth Thrust (Zweig) — a simple moving average of the advancing-issues
|
||||
/// share, `advancers / (advancers + decliners)`.
|
||||
///
|
||||
/// Martin Zweig's breadth thrust smooths the fraction of participating issues
|
||||
/// that are advancing over a short window (the classic period is 10). A "thrust"
|
||||
/// fires when this average climbs from below ~0.40 (oversold, washed-out breadth)
|
||||
/// to above ~0.615 within about ten sessions — historically a rare, reliable
|
||||
/// signal that a powerful new advance has begun with broad participation.
|
||||
///
|
||||
/// Each tick's share floors the participating count to one, so a tick with no
|
||||
/// advancing or declining issues contributes a defined `0.0` instead of dividing
|
||||
/// by zero. The reading is `None` until `period` ticks have been seen.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64` (a share in `0..=1`),
|
||||
/// `warmup_period == period`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BreadthThrust, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut bt = BreadthThrust::new(2).unwrap();
|
||||
/// let up = CrossSection::new(vec![Member::new(1.0, 1.0, false, false)], 0).unwrap();
|
||||
/// assert_eq!(bt.update(up.clone()), None); // warming up
|
||||
/// assert_eq!(bt.update(up), Some(1.0)); // both ticks 100% advancing
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BreadthThrust {
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl BreadthThrust {
|
||||
/// Construct a new Breadth Thrust over the given window length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
#[must_use]
|
||||
pub const fn period(&self) -> usize {
|
||||
self.sma.period()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BreadthThrust {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let advancers = section.advancers();
|
||||
let decliners = section.decliners();
|
||||
let participating = (advancers + decliners).max(1) as f64;
|
||||
let share = advancers as f64 / participating;
|
||||
self.sma.update(share)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.sma.period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.value().is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BreadthThrust"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn section(up: usize, down: 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));
|
||||
}
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bt = BreadthThrust::new(10).unwrap();
|
||||
assert_eq!(bt.name(), "BreadthThrust");
|
||||
assert_eq!(bt.warmup_period(), 10);
|
||||
assert_eq!(bt.period(), 10);
|
||||
assert!(!bt.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(BreadthThrust::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averages_the_advancing_share() {
|
||||
let mut bt = BreadthThrust::new(2).unwrap();
|
||||
// share = 8 / 10 = 0.8 ; window not full yet.
|
||||
assert_eq!(bt.update(section(8, 2)), None);
|
||||
// share = 6 / 10 = 0.6 ; SMA(2) = (0.8 + 0.6) / 2 = 0.7.
|
||||
let value = bt.update(section(6, 4)).unwrap();
|
||||
assert!((value - 0.7).abs() < 1e-9);
|
||||
assert!(bt.is_ready());
|
||||
// share = 5 / 10 = 0.5 ; SMA(2) = (0.6 + 0.5) / 2 = 0.55.
|
||||
let value = bt.update(section(5, 5)).unwrap();
|
||||
assert!((value - 0.55).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_participation_floors_to_zero_share() {
|
||||
let mut bt = BreadthThrust::new(1).unwrap();
|
||||
// No advancers or decliners -> 0 / max(0, 1) = 0.0.
|
||||
assert_eq!(bt.update(section(0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bt = BreadthThrust::new(2).unwrap();
|
||||
bt.update(section(8, 2));
|
||||
bt.update(section(6, 4));
|
||||
assert!(bt.is_ready());
|
||||
bt.reset();
|
||||
assert!(!bt.is_ready());
|
||||
assert_eq!(bt.update(section(8, 2)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![section(8, 2), section(6, 4), section(5, 5), section(0, 0)];
|
||||
let mut a = BreadthThrust::new(2).unwrap();
|
||||
let mut b = BreadthThrust::new(2).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Bullish Percent Index — share of a universe on a point-and-figure buy signal.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Bullish Percent Index (BPI) — the percentage of symbols in a universe that are
|
||||
/// currently on a point-and-figure buy signal.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the value is `100 * on_buy_signal_count /
|
||||
/// universe size`, read from the per-symbol `on_buy_signal` flag (the caller
|
||||
/// evaluates each symbol's point-and-figure chart when it builds the tick). It is
|
||||
/// a bounded `0..=100` gauge of how many issues are in a confirmed uptrend.
|
||||
/// Readings above 70 are considered overbought (broad strength, but a crowded
|
||||
/// market) and below 30 oversold; reversals from those zones are classic BPI
|
||||
/// buy/sell triggers.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
|
||||
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
|
||||
/// is always defined.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{BullishPercentIndex, CrossSection, Indicator, Member};
|
||||
///
|
||||
/// let mut bpi = BullishPercentIndex::new();
|
||||
/// // 2 of 4 symbols on a buy signal -> 50%.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::with_signals(1.0, 10.0, false, false, false, true),
|
||||
/// Member::with_signals(1.0, 10.0, false, false, false, true),
|
||||
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
|
||||
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(bpi.update(tick), Some(50.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct BullishPercentIndex {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl BullishPercentIndex {
|
||||
/// Construct a new Bullish Percent Index indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BullishPercentIndex {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let bullish = section.on_buy_signal_count() as f64;
|
||||
let total = section.members.len() as f64;
|
||||
self.has_emitted = true;
|
||||
Some(100.0 * bullish / total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"BullishPercentIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn tick(bullish: usize, bearish: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..bullish {
|
||||
members.push(Member::with_signals(1.0, 10.0, false, false, false, true));
|
||||
}
|
||||
for _ in 0..bearish {
|
||||
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
|
||||
}
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let bpi = BullishPercentIndex::new();
|
||||
assert_eq!(bpi.name(), "BullishPercentIndex");
|
||||
assert_eq!(bpi.warmup_period(), 1);
|
||||
assert!(!bpi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_percentage() {
|
||||
let mut bpi = BullishPercentIndex::new();
|
||||
assert_eq!(bpi.update(tick(2, 2)), Some(50.0));
|
||||
assert!(bpi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_bullish_is_one_hundred() {
|
||||
let mut bpi = BullishPercentIndex::new();
|
||||
assert_eq!(bpi.update(tick(5, 0)), Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_bullish_is_zero() {
|
||||
let mut bpi = BullishPercentIndex::new();
|
||||
assert_eq!(bpi.update(tick(0, 4)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut bpi = BullishPercentIndex::new();
|
||||
bpi.update(tick(2, 2));
|
||||
assert!(bpi.is_ready());
|
||||
bpi.reset();
|
||||
assert!(!bpi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![tick(2, 2), tick(5, 0), tick(0, 4)];
|
||||
let mut a = BullishPercentIndex::new();
|
||||
let mut b = BullishPercentIndex::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Butterfly harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Butterfly — a 5-point (X-A-B-C-D) harmonic pattern with a `0.786` B and an
|
||||
/// **extended** D that overshoots X:
|
||||
///
|
||||
/// ```text
|
||||
/// AB / XA ∈ [0.74, 0.84] (≈ 0.786)
|
||||
/// BC / AB ∈ [0.382, 0.886]
|
||||
/// CD / BC ∈ [1.618, 2.618]
|
||||
/// AD / XA ∈ [1.27, 1.618] (the defining extended D completion)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
|
||||
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/butterfly.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Butterfly {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Butterfly {
|
||||
/// Construct a new Butterfly detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Butterfly {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Butterfly {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let p = xabcd(pivots);
|
||||
let xa = (p.a - p.x).abs();
|
||||
let ab = (p.b - p.a).abs();
|
||||
let bc = (p.c - p.b).abs();
|
||||
let cd = (p.d - p.c).abs();
|
||||
let ad = (p.d - p.a).abs();
|
||||
let matched = ratios_in(&[
|
||||
(ab / xa, 0.74, 0.84),
|
||||
(bc / ab, 0.382, 0.886),
|
||||
(cd / bc, 1.618, 2.618),
|
||||
(ad / xa, 1.27, 1.618),
|
||||
]);
|
||||
if matched {
|
||||
return Some(if p.bullish { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Butterfly"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Butterfly::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Butterfly::new();
|
||||
assert_eq!(indicator.name(), "Butterfly");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Butterfly::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_butterfly_is_plus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_butterfly_is_minus_one() {
|
||||
let out = run(&[150.0, 110.0, 141.4, 121.4, 170.2]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_ratio_does_not_trigger() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Butterfly::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 108.6, 128.0, 79.8]);
|
||||
let mut a = Butterfly::new();
|
||||
let mut b = Butterfly::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Crab harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Crab — a 5-point (X-A-B-C-D) harmonic pattern with the deepest D completion
|
||||
/// of the family, an `1.618` extension of XA:
|
||||
///
|
||||
/// ```text
|
||||
/// AB / XA ∈ [0.382, 0.618]
|
||||
/// BC / AB ∈ [0.382, 0.886]
|
||||
/// CD / BC ∈ [2.24, 3.618] (a very long terminal leg)
|
||||
/// AD / XA ∈ [1.55, 1.65] (≈ 1.618 — the defining D completion)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
|
||||
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/crab.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Crab {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Crab {
|
||||
/// Construct a new Crab detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Crab {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Crab {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let p = xabcd(pivots);
|
||||
let xa = (p.a - p.x).abs();
|
||||
let ab = (p.b - p.a).abs();
|
||||
let bc = (p.c - p.b).abs();
|
||||
let cd = (p.d - p.c).abs();
|
||||
let ad = (p.d - p.a).abs();
|
||||
let matched = ratios_in(&[
|
||||
(ab / xa, 0.382, 0.618),
|
||||
(bc / ab, 0.382, 0.886),
|
||||
(cd / bc, 2.24, 3.618),
|
||||
(ad / xa, 1.55, 1.65),
|
||||
]);
|
||||
if matched {
|
||||
return Some(if p.bullish { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Crab"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Crab::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Crab::new();
|
||||
assert_eq!(indicator.name(), "Crab");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Crab::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_crab_is_plus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_crab_is_minus_one() {
|
||||
let out = run(&[150.0, 110.0, 130.0, 112.5, 174.7]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_ratio_does_not_trigger() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Crab::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 137.5, 75.3]);
|
||||
let mut a = Crab::new();
|
||||
let mut b = Crab::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Cumulative Volume Index — running total of volume-normalised net advancing volume.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Cumulative Volume Index (CVI) — the running total of *volume-normalised* net
|
||||
/// advancing volume across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the increment is `(advancing volume - declining
|
||||
/// volume) / total volume`: the share of the tick's total volume that flowed,
|
||||
/// net, into advancing issues. The index accumulates this share over time. Where
|
||||
/// the raw [`AdVolumeLine`](crate::AdVolumeLine) sums *absolute* net volume — and
|
||||
/// so drifts with secular growth in trading activity — the CVI normalises each
|
||||
/// tick by its own total volume, so a one-share-net day in a thin market counts
|
||||
/// the same as in a heavy one. This keeps the index comparable across regimes of
|
||||
/// very different volume.
|
||||
///
|
||||
/// When a tick has zero total volume the net is necessarily zero too, so the
|
||||
/// increment is zero and the index is unchanged (the divisor is floored to the
|
||||
/// smallest positive `f64` purely to keep the division defined).
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CrossSection, CumulativeVolumeIndex, Indicator, Member};
|
||||
///
|
||||
/// let mut cvi = CumulativeVolumeIndex::new();
|
||||
/// // adv vol 150, dec vol 50, total 200 -> (150 - 50) / 200 = 0.5.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 150.0, false, false),
|
||||
/// Member::new(-1.0, 50.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(cvi.update(tick), Some(0.5));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CumulativeVolumeIndex {
|
||||
index: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl CumulativeVolumeIndex {
|
||||
/// Construct a new Cumulative Volume Index indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
index: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CumulativeVolumeIndex {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let net = section.advancing_volume() - section.declining_volume();
|
||||
let total = section.total_volume().max(f64::MIN_POSITIVE);
|
||||
self.index += net / total;
|
||||
self.has_emitted = true;
|
||||
Some(self.index)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.index = 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 {
|
||||
"CumulativeVolumeIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn tick(items: &[(f64, f64)]) -> CrossSection {
|
||||
CrossSection::new(
|
||||
items
|
||||
.iter()
|
||||
.map(|&(change, volume)| Member::new(change, volume, false, false))
|
||||
.collect(),
|
||||
0,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let cvi = CumulativeVolumeIndex::new();
|
||||
assert_eq!(cvi.name(), "CumulativeVolumeIndex");
|
||||
assert_eq!(cvi.warmup_period(), 1);
|
||||
assert!(!cvi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_normalised_net() {
|
||||
let mut cvi = CumulativeVolumeIndex::new();
|
||||
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
|
||||
assert!(cvi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_accumulates_normalised_shares() {
|
||||
let mut cvi = CumulativeVolumeIndex::new();
|
||||
assert_eq!(cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)])), Some(0.5));
|
||||
// adv 60, dec 60, total 120 -> net 0 -> index unchanged.
|
||||
assert_eq!(cvi.update(tick(&[(1.0, 60.0), (-1.0, 60.0)])), Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_total_volume_leaves_index_unchanged() {
|
||||
let mut cvi = CumulativeVolumeIndex::new();
|
||||
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
|
||||
// A tick with no volume at all: net 0 / floored divisor -> 0 increment.
|
||||
assert_eq!(cvi.update(tick(&[(0.0, 0.0)])), Some(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut cvi = CumulativeVolumeIndex::new();
|
||||
cvi.update(tick(&[(1.0, 150.0), (-1.0, 50.0)]));
|
||||
assert!(cvi.is_ready());
|
||||
cvi.reset();
|
||||
assert!(!cvi.is_ready());
|
||||
assert_eq!(cvi.update(tick(&[(1.0, 100.0)])), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![
|
||||
tick(&[(1.0, 150.0), (-1.0, 50.0)]),
|
||||
tick(&[(1.0, 60.0), (-1.0, 60.0)]),
|
||||
tick(&[(0.0, 0.0)]),
|
||||
];
|
||||
let mut a = CumulativeVolumeIndex::new();
|
||||
let mut b = CumulativeVolumeIndex::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Cup-and-Handle (and Inverse) continuation chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Cup-and-Handle / Inverse — a rounded base (the cup) followed by a shallow
|
||||
/// pullback (the handle) near the rim, then a breakout in the cup's direction.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%) and read from the
|
||||
/// last four pivots:
|
||||
///
|
||||
/// ```text
|
||||
/// cup-and-handle (bullish, +1): Rim(high) , Cup(low) , Rim(high) , Handle(low)
|
||||
/// the two rims match (±3%) ; the handle low sits ABOVE the cup low (a shallow
|
||||
/// pullback) and below the right rim
|
||||
///
|
||||
/// inverse (bearish, -1): Rim(low) , Cap(high) , Rim(low) , Handle(high)
|
||||
/// the two rims match ; the handle high sits BELOW the cap high and above the
|
||||
/// right rim
|
||||
/// ```
|
||||
///
|
||||
/// The shallow handle (closer to the rim than the cup extreme) is what
|
||||
/// distinguishes a cup-and-handle from a plain double bottom/top. Output is
|
||||
/// `+1.0` / `-1.0` / `0.0`; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CupAndHandle {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl CupAndHandle {
|
||||
/// Construct a new Cup-and-Handle detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CupAndHandle {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CupAndHandle {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let n = pivots.len();
|
||||
let rim_left = pivots[n - 4];
|
||||
let extreme = pivots[n - 3];
|
||||
let rim_right = pivots[n - 2];
|
||||
let handle = pivots[n - 1];
|
||||
let rims_match = approx_equal(rim_left.price, rim_right.price, LEVEL_TOLERANCE);
|
||||
|
||||
if handle.direction < 0.0 {
|
||||
// Bullish cup-and-handle: rims are highs, cup is the low between them,
|
||||
// handle is a shallow low above the cup but below the right rim.
|
||||
if rims_match && handle.price > extreme.price && handle.price < rim_right.price {
|
||||
return Some(1.0);
|
||||
}
|
||||
} else if rims_match && handle.price < extreme.price && handle.price > rim_right.price {
|
||||
// Inverse: rims are lows, cap is the high, handle a shallow high.
|
||||
return Some(-1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CupAndHandle"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = CupAndHandle::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = CupAndHandle::new();
|
||||
assert_eq!(indicator.name(), "CupAndHandle");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!CupAndHandle::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cup_and_handle_is_plus_one() {
|
||||
// Rims 120/121, cup 90 (deep), handle 110 (shallow, above the cup).
|
||||
let out = run(&[120.0, 90.0, 121.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_cup_and_handle_is_minus_one() {
|
||||
// Lead high then rims 100/101, cap 130, handle 110 (below cap, above rim).
|
||||
let out = run(&[140.0, 100.0, 130.0, 101.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_handle_is_not_cup_and_handle() {
|
||||
// Handle (85) below the cup low (90) → a double bottom, not cup-and-handle.
|
||||
let out = run(&[120.0, 90.0, 121.0, 85.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_with_mismatched_rims_does_not_trigger() {
|
||||
// Inverse shape (ends high) but the rims (100 / 90) diverge → enters the
|
||||
// inverse branch yet reports no pattern.
|
||||
let out = run(&[140.0, 100.0, 130.0, 90.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = CupAndHandle::new();
|
||||
for c in candles_for_pivots(&[120.0, 90.0, 121.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[120.0, 90.0, 121.0, 110.0]);
|
||||
let mut a = CupAndHandle::new();
|
||||
let mut b = CupAndHandle::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Cypher harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Cypher — a 5-point (X-A-B-C-D) harmonic pattern whose C leg is measured
|
||||
/// against XA (not AB) and whose D retraces the XC leg by `0.786`:
|
||||
///
|
||||
/// ```text
|
||||
/// AB / XA ∈ [0.382, 0.618]
|
||||
/// BC / XA ∈ [1.13, 1.414] (C extends beyond A, measured on XA)
|
||||
/// CD / XC ∈ [0.74, 0.83] (≈ 0.786 retracement of XC — the D completion)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` (bullish, D a swing low), `-1.0` (bearish, D a swing high),
|
||||
/// or `0.0`; never `None`. See `crates/wickra-core/src/indicators/cypher.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Cypher {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Cypher {
|
||||
/// Construct a new Cypher detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Cypher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Cypher {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let p = xabcd(pivots);
|
||||
let xa = (p.a - p.x).abs();
|
||||
let ab = (p.b - p.a).abs();
|
||||
let bc = (p.c - p.b).abs();
|
||||
let xc = (p.c - p.x).abs();
|
||||
let cd = (p.d - p.c).abs();
|
||||
let matched = ratios_in(&[
|
||||
(ab / xa, 0.382, 0.618),
|
||||
(bc / xa, 1.13, 1.414),
|
||||
(cd / xc, 0.74, 0.83),
|
||||
]);
|
||||
if matched {
|
||||
return Some(if p.bullish { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Cypher"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Cypher::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Cypher::new();
|
||||
assert_eq!(indicator.name(), "Cypher");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Cypher::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_cypher_is_plus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_cypher_is_minus_one() {
|
||||
let out = run(&[150.0, 110.0, 130.0, 82.0, 135.45]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_ratio_does_not_trigger() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Cypher::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 120.0, 168.0, 114.55]);
|
||||
let mut a = Cypher::new();
|
||||
let mut b = Cypher::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Day-of-Week Profile — the mean bar return for each weekday.
|
||||
|
||||
use crate::calendar::civil_from_timestamp;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
const DAYS: usize = 7;
|
||||
|
||||
/// Day-of-Week Profile output: the per-weekday mean return.
|
||||
///
|
||||
/// `bins[i]` is the mean simple return of all bars whose local weekday was `i`,
|
||||
/// with Monday as `0` through Sunday as `6`. Weekdays with no bars read `0.0`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DayOfWeekProfileOutput {
|
||||
/// Per-weekday mean return, Monday first. Always length 7.
|
||||
pub bins: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Mean bar return bucketed by local weekday (Monday `0` .. Sunday `6`).
|
||||
///
|
||||
/// Each bar's simple return `close / previous_close - 1` is accumulated into the
|
||||
/// bucket of its local weekday (the wall-clock day of
|
||||
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`), and the
|
||||
/// profile reports the running mean per weekday. The first bar produces no output.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, DayOfWeekProfile};
|
||||
///
|
||||
/// let day = 24 * 3_600_000;
|
||||
/// let mut prof = DayOfWeekProfile::new(0);
|
||||
/// // 1970-01-01 was a Thursday (weekday 3).
|
||||
/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
|
||||
/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, day).unwrap()).unwrap();
|
||||
/// assert_eq!(out.bins.len(), 7);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DayOfWeekProfile {
|
||||
utc_offset_minutes: i32,
|
||||
prev_close: Option<f64>,
|
||||
sum: [f64; DAYS],
|
||||
count: [u64; DAYS],
|
||||
last: Option<DayOfWeekProfileOutput>,
|
||||
}
|
||||
|
||||
impl DayOfWeekProfile {
|
||||
/// Construct a Day-of-Week Profile with the given UTC offset (minutes).
|
||||
pub const fn new(utc_offset_minutes: i32) -> Self {
|
||||
Self {
|
||||
utc_offset_minutes,
|
||||
prev_close: None,
|
||||
sum: [0.0; DAYS],
|
||||
count: [0; DAYS],
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured UTC offset in minutes.
|
||||
pub const fn utc_offset_minutes(&self) -> i32 {
|
||||
self.utc_offset_minutes
|
||||
}
|
||||
|
||||
/// Most recent profile if at least one return has been recorded.
|
||||
pub fn value(&self) -> Option<&DayOfWeekProfileOutput> {
|
||||
self.last.as_ref()
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> DayOfWeekProfileOutput {
|
||||
let bins = self
|
||||
.sum
|
||||
.iter()
|
||||
.zip(&self.count)
|
||||
.map(|(total, n)| if *n > 0 { total / *n as f64 } else { 0.0 })
|
||||
.collect();
|
||||
DayOfWeekProfileOutput { bins }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DayOfWeekProfile {
|
||||
type Input = Candle;
|
||||
type Output = DayOfWeekProfileOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<DayOfWeekProfileOutput> {
|
||||
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
|
||||
let result = if let Some(prev) = self.prev_close {
|
||||
let ret = if prev == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
candle.close / prev - 1.0
|
||||
};
|
||||
let day = civil.weekday as usize;
|
||||
self.sum[day] += ret;
|
||||
self.count[day] += 1;
|
||||
let out = self.snapshot();
|
||||
self.last = Some(out.clone());
|
||||
Some(out)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.prev_close = Some(candle.close);
|
||||
result
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.sum = [0.0; DAYS];
|
||||
self.count = [0; DAYS];
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DayOfWeekProfile"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const DAY: i64 = 24 * 3_600_000;
|
||||
|
||||
fn c(close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, close, close, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_accessors() {
|
||||
let prof = DayOfWeekProfile::new(60);
|
||||
assert_eq!(prof.utc_offset_minutes(), 60);
|
||||
assert_eq!(prof.name(), "DayOfWeekProfile");
|
||||
assert_eq!(prof.warmup_period(), 2);
|
||||
assert!(!prof.is_ready());
|
||||
assert!(prof.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buckets_by_weekday() {
|
||||
let mut prof = DayOfWeekProfile::new(0);
|
||||
// 1970-01-01 Thursday (3); 01-02 Friday (4).
|
||||
assert!(prof.update(c(100.0, 0)).is_none());
|
||||
let out = prof.update(c(101.0, DAY)).unwrap(); // Friday return +0.01
|
||||
assert_eq!(out.bins.len(), 7);
|
||||
assert_relative_eq!(out.bins[4], 0.01); // Friday
|
||||
assert_relative_eq!(out.bins[3], 0.0); // Thursday had no return
|
||||
assert!(prof.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averages_same_weekday_across_weeks() {
|
||||
let mut prof = DayOfWeekProfile::new(0);
|
||||
prof.update(c(100.0, 0)); // Thu
|
||||
prof.update(c(101.0, DAY)); // Fri +0.01
|
||||
// Jump to next Friday (7 days later from day 0 -> +7 days, weekday 4).
|
||||
prof.update(c(100.0, 7 * DAY)); // Thu+? actually day 7 -> weekday (7+3)%7=3 Thu
|
||||
let out = prof.update(c(103.0, 8 * DAY)).unwrap(); // day 8 -> Fri, return
|
||||
// Friday now has two samples; both positive.
|
||||
assert!(out.bins[4] > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_prev_close_uses_zero_return() {
|
||||
let mut prof = DayOfWeekProfile::new(0);
|
||||
prof.update(c(0.0, 0));
|
||||
let out = prof.update(c(5.0, DAY)).unwrap();
|
||||
assert_relative_eq!(out.bins[4], 0.0); // Friday, guarded return 0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut prof = DayOfWeekProfile::new(0);
|
||||
prof.update(c(100.0, 0));
|
||||
prof.update(c(101.0, DAY));
|
||||
prof.reset();
|
||||
assert!(!prof.is_ready());
|
||||
assert!(prof.value().is_none());
|
||||
assert!(prof.update(c(100.0, 2 * DAY)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| c(100.0 + f64::from(i % 5), i64::from(i) * DAY))
|
||||
.collect();
|
||||
let mut a = DayOfWeekProfile::new(0);
|
||||
let mut b = DayOfWeekProfile::new(0);
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,188 @@
|
||||
//! Double Top / Double Bottom reversal chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Double Top / Double Bottom — a two-peak (or two-trough) reversal pattern.
|
||||
///
|
||||
/// The detector tracks confirmed swing pivots (a non-repainting percent-threshold
|
||||
/// zig-zag, [`SWING_THRESHOLD`] = 5%). A pattern is recognised on the bar that
|
||||
/// confirms the **second** matching extreme:
|
||||
///
|
||||
/// ```text
|
||||
/// double top : … High₁ , Low , High₂ with High₁ ≈ High₂ → -1 (bearish)
|
||||
/// double bottom : … Low₁ , High , Low₂ with Low₁ ≈ Low₂ → +1 (bullish)
|
||||
/// ```
|
||||
///
|
||||
/// Two extremes count as the same level when they are within
|
||||
/// [`LEVEL_TOLERANCE`] (3%) of each other. Because pivots strictly alternate
|
||||
/// high/low, the trough between the twin tops (or the peak between the twin
|
||||
/// bottoms) is guaranteed to sit beyond both, so no extra separation check is
|
||||
/// needed.
|
||||
///
|
||||
/// Output is `+1.0` for a double bottom, `-1.0` for a double top, and `0.0` on
|
||||
/// every other bar (including warmup and bars that confirm a pivot which does
|
||||
/// not complete the pattern). Like the candlestick family this detector never
|
||||
/// returns `None`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, DoubleTopBottom, Indicator};
|
||||
///
|
||||
/// let mut indicator = DoubleTopBottom::new();
|
||||
/// for (i, &(high, low)) in [
|
||||
/// (100.0, 99.5),
|
||||
/// (120.0, 119.5),
|
||||
/// (110.0, 100.0), // confirms the first top at 120
|
||||
/// (120.0, 119.0), // confirms the trough at 100
|
||||
/// (115.0, 110.0), // confirms the second top at 120 → double top
|
||||
/// ]
|
||||
/// .iter()
|
||||
/// .enumerate()
|
||||
/// {
|
||||
/// let c = Candle::new(low, high, low, low, 1.0, i as i64).unwrap();
|
||||
/// let signal = indicator.update(c).unwrap();
|
||||
/// if i == 4 {
|
||||
/// assert_eq!(signal, -1.0);
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DoubleTopBottom {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl DoubleTopBottom {
|
||||
/// Construct a new Double Top / Double Bottom detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 3),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DoubleTopBottom {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DoubleTopBottom {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 3 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let first = pivots[pivots.len() - 3];
|
||||
let last = pivots[pivots.len() - 1];
|
||||
if approx_equal(first.price, last.price, LEVEL_TOLERANCE) {
|
||||
// `last` is the just-confirmed extreme: a high → double top (bearish),
|
||||
// a low → double bottom (bullish).
|
||||
return Some(if last.direction > 0.0 { -1.0 } else { 1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// The first complete pattern needs three confirmed pivots; the earliest
|
||||
// bar that can confirm a third pivot is the fifth.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DoubleTopBottom"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = DoubleTopBottom::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = DoubleTopBottom::new();
|
||||
assert_eq!(indicator.name(), "DoubleTopBottom");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!DoubleTopBottom::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_top_is_minus_one() {
|
||||
// Twin highs 120 / 120 with a 100 trough → double top on the second.
|
||||
let out = run(&[120.0, 100.0, 120.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
// All earlier bars are warmup / non-completing.
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_bottom_is_plus_one() {
|
||||
// Lead high, then twin lows 100 / 99 around a 120 peak → double bottom.
|
||||
let out = run(&[130.0, 100.0, 120.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_tops_do_not_trigger() {
|
||||
// Second top 140 diverges from the first (120) → no pattern.
|
||||
let out = run(&[120.0, 100.0, 140.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
assert!(out.iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = DoubleTopBottom::new();
|
||||
for c in candles_for_pivots(&[120.0, 100.0, 120.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[120.0, 100.0, 120.0]);
|
||||
let mut a = DoubleTopBottom::new();
|
||||
let mut b = DoubleTopBottom::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Fibonacci Arcs — semicircular retracement levels centred on the swing end,
|
||||
//! decaying back toward it as time elapses.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The three arc ratios drawn (38.2% / 50% / 61.8%).
|
||||
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
|
||||
|
||||
/// Fibonacci Arc prices evaluated at the current bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibArcsOutput {
|
||||
/// Price of the 38.2% arc at the current bar.
|
||||
pub arc_382: f64,
|
||||
/// Price of the 50% arc at the current bar.
|
||||
pub arc_500: f64,
|
||||
/// Price of the 61.8% arc at the current bar.
|
||||
pub arc_618: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Arcs (`FibArcs`).
|
||||
///
|
||||
/// Three arcs centred on the end of the most recent confirmed swing leg. Time is
|
||||
/// normalised by the leg's bar-width so the construction is chart-scale-free: at
|
||||
/// the leg's end bar each arc sits exactly on its retracement level, and as time
|
||||
/// elapses the arc curves back toward the swing-end price, reaching it one leg
|
||||
/// width later.
|
||||
///
|
||||
/// ```text
|
||||
/// u = (cur - end_bar) / (end_bar - start_bar)
|
||||
/// arc(r) = end + (start - end) * r * sqrt(max(0, 1 - u^2))
|
||||
/// ```
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until the first
|
||||
/// leg is complete.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_arcs.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibArcs {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibArcs {
|
||||
/// Construct a new Fibonacci Arcs tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 2),
|
||||
}
|
||||
}
|
||||
|
||||
fn arcs(&self) -> Option<FibArcsOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
let start = pivots.first()?;
|
||||
let end = pivots.get(1)?;
|
||||
// Consecutive pivots occur at strictly increasing bars → span >= 1 bar.
|
||||
let span_bars = (end.bar - start.bar) as f64;
|
||||
let u = (self.swing.current_bar() - end.bar) as f64 / span_bars;
|
||||
let curve = (1.0 - u * u).max(0.0).sqrt();
|
||||
let arc = |r: f64| end.price + (start.price - end.price) * r * curve;
|
||||
Some(FibArcsOutput {
|
||||
arc_382: arc(RATIOS[0]),
|
||||
arc_500: arc(RATIOS[1]),
|
||||
arc_618: arc(RATIOS[2]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibArcs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibArcs {
|
||||
type Input = Candle;
|
||||
type Output = FibArcsOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibArcsOutput> {
|
||||
self.swing.update(candle);
|
||||
self.arcs()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 2
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibArcs"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
/// Leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3 so the arc is
|
||||
/// first reported with `u = (3 - 2) / (2 - 0) = 0.5`.
|
||||
fn down_leg() -> Vec<Candle> {
|
||||
vec![
|
||||
c(200.0, 199.0, 0),
|
||||
c(190.0, 160.0, 1), // confirm high @200
|
||||
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
|
||||
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibArcs::new();
|
||||
assert_eq!(indicator.name(), "FibArcs");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibArcs::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_pivots() {
|
||||
let mut indicator = FibArcs::new();
|
||||
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
|
||||
.into_iter()
|
||||
.map(|x| indicator.update(x))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arcs_curve_back_toward_the_swing_end() {
|
||||
let mut indicator = FibArcs::new();
|
||||
let mut last = None;
|
||||
for candle in down_leg() {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// u = 0.5 → curve = sqrt(0.75); arc(r) = 100 + 100 * r * curve.
|
||||
let curve = 0.75_f64.sqrt();
|
||||
assert_relative_eq!(v.arc_382, 100.0 + 100.0 * 0.382 * curve);
|
||||
assert_relative_eq!(v.arc_500, 100.0 + 100.0 * 0.5 * curve);
|
||||
assert_relative_eq!(v.arc_618, 100.0 + 100.0 * 0.618 * curve);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_clamps_to_zero_beyond_one_leg_width() {
|
||||
// Extend far past the end pivot so u > 1; the curve clamps to 0 and the
|
||||
// arcs collapse onto the swing-end price.
|
||||
let mut indicator = FibArcs::new();
|
||||
for candle in down_leg() {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
// Feed flat bars that neither extend nor confirm a new pivot.
|
||||
let mut last = None;
|
||||
for ts in 4..12 {
|
||||
last = indicator.update(c(108.0, 106.0, ts));
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert_relative_eq!(v.arc_382, 100.0);
|
||||
assert_relative_eq!(v.arc_618, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibArcs::new();
|
||||
for candle in down_leg() {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = down_leg();
|
||||
let mut a = FibArcs::new();
|
||||
let mut b = FibArcs::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Fibonacci Channel — a sloped base trendline plus parallel lines offset by
|
||||
//! Fibonacci multiples of the channel width.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The parallel-line ratios above the base (61.8% / 100% / 161.8% of the width).
|
||||
const RATIOS: [f64; 3] = [0.618, 1.0, 1.618];
|
||||
|
||||
/// Fibonacci Channel line prices evaluated at the current bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibChannelOutput {
|
||||
/// The base trendline price at the current bar.
|
||||
pub base: f64,
|
||||
/// Base + 61.8% of the channel width.
|
||||
pub level_618: f64,
|
||||
/// Base + 100% of the width — the opposite channel boundary.
|
||||
pub level_1000: f64,
|
||||
/// Base + 161.8% of the width.
|
||||
pub level_1618: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Channel (`FibChannel`).
|
||||
///
|
||||
/// From the last three confirmed pivots, the two same-direction outer pivots
|
||||
/// define a sloped base trendline and the opposite middle pivot sets the channel
|
||||
/// width (its signed distance from the base line). Parallel lines are then offset
|
||||
/// by Fibonacci multiples of that width and reported at the current bar.
|
||||
///
|
||||
/// ```text
|
||||
/// slope = (p2 - p0) / (bar2 - bar0)
|
||||
/// base(bar) = p0 + slope * (bar - bar0)
|
||||
/// width = p1 - base(bar1)
|
||||
/// level(r) = base(cur) + r * width
|
||||
/// ```
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until three pivots
|
||||
/// have confirmed.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_channel.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibChannel {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibChannel {
|
||||
/// Construct a new Fibonacci Channel tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 3),
|
||||
}
|
||||
}
|
||||
|
||||
fn channel(&self) -> Option<FibChannelOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
let p0 = pivots.first()?;
|
||||
let p1 = pivots.get(1)?;
|
||||
let p2 = pivots.get(2)?;
|
||||
// p0 and p2 are the same-direction outer pivots; their bars differ
|
||||
// strictly, so the slope denominator is non-zero.
|
||||
let slope = (p2.price - p0.price) / (p2.bar - p0.bar) as f64;
|
||||
let base_at = |bar: usize| p0.price + slope * (bar - p0.bar) as f64;
|
||||
let width = p1.price - base_at(p1.bar);
|
||||
let base = base_at(self.swing.current_bar());
|
||||
Some(FibChannelOutput {
|
||||
base,
|
||||
level_618: base + RATIOS[0] * width,
|
||||
level_1000: base + RATIOS[1] * width,
|
||||
level_1618: base + RATIOS[2] * width,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibChannel {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibChannel {
|
||||
type Input = Candle;
|
||||
type Output = FibChannelOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibChannelOutput> {
|
||||
self.swing.update(candle);
|
||||
self.channel()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 3
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibChannel"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
/// Pivots: high 200 (bar 0), low 100 (bar 1), high 220 (bar 3); confirmed at
|
||||
/// bar 4 so the channel is first reported at current bar 4.
|
||||
fn three_pivots() -> Vec<Candle> {
|
||||
vec![
|
||||
c(200.0, 199.0, 0),
|
||||
c(190.0, 100.0, 1), // confirm high @200, low candidate @100
|
||||
c(110.0, 108.0, 2), // confirm low @100, high candidate @110
|
||||
c(220.0, 210.0, 3), // extend high to 220 (bar 3)
|
||||
c(200.0, 150.0, 4), // confirm high @220 -> three pivots
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibChannel::new();
|
||||
assert_eq!(indicator.name(), "FibChannel");
|
||||
assert_eq!(indicator.warmup_period(), 3);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibChannel::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_three_pivots() {
|
||||
let mut indicator = FibChannel::new();
|
||||
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 100.0, 1), c(110.0, 108.0, 2)]
|
||||
.into_iter()
|
||||
.map(|x| indicator.update(x))
|
||||
.collect();
|
||||
// Only two pivots confirm within these three bars.
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_levels_from_three_pivots() {
|
||||
let mut indicator = FibChannel::new();
|
||||
let mut last = None;
|
||||
for candle in three_pivots() {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// Base through highs (0,200) and (3,220); width from low (1,100); cur = 4.
|
||||
let slope = (220.0 - 200.0) / 3.0;
|
||||
let base_cur = 200.0 + slope * 4.0;
|
||||
let width = 100.0 - (200.0 + slope * 1.0);
|
||||
assert_relative_eq!(v.base, base_cur);
|
||||
assert_relative_eq!(v.level_1000, base_cur + width);
|
||||
assert_relative_eq!(v.level_618, base_cur + 0.618 * width);
|
||||
assert_relative_eq!(v.level_1618, base_cur + 1.618 * width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibChannel::new();
|
||||
for candle in three_pivots() {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = three_pivots();
|
||||
let mut a = FibChannel::new();
|
||||
let mut b = FibChannel::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Fibonacci Confluence — the strongest retracement cluster across recent legs.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// How many recent pivots to consider; six pivots yield up to five legs.
|
||||
const PIVOT_HISTORY: usize = 6;
|
||||
|
||||
/// The retracement ratios contributed by each leg to the confluence search.
|
||||
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
|
||||
|
||||
/// The strongest Fibonacci confluence zone found across recent swing legs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibConfluenceOutput {
|
||||
/// Mean price of the densest cluster of retracement levels.
|
||||
pub price: f64,
|
||||
/// Number of retracement levels that fall inside the cluster (its strength).
|
||||
pub strength: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Confluence (`FibConfluence`).
|
||||
///
|
||||
/// Computes the 38.2% / 50% / 61.8% retracement prices of every leg among the
|
||||
/// last six confirmed pivots, then reports the densest price cluster — where
|
||||
/// levels from different legs stack up, the zone the market is most likely to
|
||||
/// react to. `price` is the cluster mean; `strength` is how many levels it
|
||||
/// gathers.
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until at least two
|
||||
/// legs (three pivots) exist.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_confluence.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibConfluence {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibConfluence {
|
||||
/// Construct a new Fibonacci Confluence tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, PIVOT_HISTORY),
|
||||
}
|
||||
}
|
||||
|
||||
fn confluence(&self) -> Option<FibConfluenceOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let levels: Vec<f64> = pivots
|
||||
.windows(2)
|
||||
.flat_map(|leg| {
|
||||
let (start, end) = (leg[0].price, leg[1].price);
|
||||
RATIOS.map(|r| end + r * (start - end))
|
||||
})
|
||||
.collect();
|
||||
// The `len < 3` guard guarantees at least two legs, hence a non-empty
|
||||
// level set, so `max_by` always yields a cluster.
|
||||
let (count, total) = levels
|
||||
.iter()
|
||||
.map(|¢er| {
|
||||
let members: Vec<f64> = levels
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&x| approx_equal(x, center, LEVEL_TOLERANCE))
|
||||
.collect();
|
||||
(members.len(), members.iter().sum::<f64>())
|
||||
})
|
||||
.max_by(|a, b| a.0.cmp(&b.0))
|
||||
.expect("at least two legs guarantee a non-empty level set");
|
||||
Some(FibConfluenceOutput {
|
||||
price: total / count as f64,
|
||||
strength: count as f64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibConfluence {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibConfluence {
|
||||
type Input = Candle;
|
||||
type Output = FibConfluenceOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibConfluenceOutput> {
|
||||
self.swing.update(candle);
|
||||
self.confluence()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 3
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibConfluence"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibConfluence::new();
|
||||
assert_eq!(indicator.name(), "FibConfluence");
|
||||
assert_eq!(indicator.warmup_period(), 3);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibConfluence::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_legs() {
|
||||
let mut indicator = FibConfluence::new();
|
||||
let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0])
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_the_densest_cluster() {
|
||||
// Legs 200->100 and 100->160. The 38.2% of each (138.2 and ~137.08)
|
||||
// sit within 3% of each other and form the densest cluster (strength 2).
|
||||
let mut indicator = FibConfluence::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
assert_relative_eq!(v.strength, 2.0);
|
||||
let want = (138.2 + (160.0 + 0.382 * (100.0 - 160.0))) / 2.0;
|
||||
assert_relative_eq!(v.price, want, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibConfluence::new();
|
||||
for candle in candles_for_pivots(&[200.0, 100.0, 160.0]) {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert!(indicator.update(c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[200.0, 100.0, 160.0, 120.0]);
|
||||
let mut a = FibConfluence::new();
|
||||
let mut b = FibConfluence::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Fibonacci Extension of the most recent confirmed swing leg.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The five canonical extension ratios, in ascending order. Each is a multiple
|
||||
/// of the swing leg measured from its origin, so `1.0` sits on the leg's end and
|
||||
/// every ratio here projects further in the direction of the move.
|
||||
const RATIOS: [f64; 5] = [1.272, 1.414, 1.618, 2.0, 2.618];
|
||||
|
||||
/// Fibonacci Extension levels for the most recent swing leg.
|
||||
///
|
||||
/// Each field is the price reached if the move continues to the matching
|
||||
/// multiple of the leg, measured from the leg's start.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibExtensionOutput {
|
||||
/// 127.2% extension.
|
||||
pub level_1272: f64,
|
||||
/// 141.4% extension.
|
||||
pub level_1414: f64,
|
||||
/// 161.8% extension — the "golden" extension.
|
||||
pub level_1618: f64,
|
||||
/// 200% extension.
|
||||
pub level_2000: f64,
|
||||
/// 261.8% extension.
|
||||
pub level_2618: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Extension (`FibExtension`).
|
||||
///
|
||||
/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold and, once
|
||||
/// two pivots exist, projects the leg between them to the canonical extension
|
||||
/// ratios — the price targets a continuation of the move would reach.
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until the first
|
||||
/// leg is complete.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_extension.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibExtension {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibExtension {
|
||||
/// Construct a new Fibonacci Extension tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension price at ratio `e` for a leg from `start` to `end`: the total
|
||||
/// move is `e` times the leg, measured from `start`.
|
||||
fn level(start: f64, end: f64, e: f64) -> f64 {
|
||||
start + e * (end - start)
|
||||
}
|
||||
|
||||
fn levels(&self) -> Option<FibExtensionOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
|
||||
Some(FibExtensionOutput {
|
||||
level_1272: Self::level(start, end, RATIOS[0]),
|
||||
level_1414: Self::level(start, end, RATIOS[1]),
|
||||
level_1618: Self::level(start, end, RATIOS[2]),
|
||||
level_2000: Self::level(start, end, RATIOS[3]),
|
||||
level_2618: Self::level(start, end, RATIOS[4]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibExtension {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibExtension {
|
||||
type Input = Candle;
|
||||
type Output = FibExtensionOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibExtensionOutput> {
|
||||
self.swing.update(candle);
|
||||
self.levels()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 2
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibExtension"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibExtension::new();
|
||||
assert_eq!(indicator.name(), "FibExtension");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibExtension::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_pivots() {
|
||||
let mut indicator = FibExtension::new();
|
||||
let outputs: Vec<_> = candles_for_pivots(&[120.0])
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_levels_of_a_down_leg() {
|
||||
// Leg start = 200 (high), end = 100 (low): a 100-point drop continued.
|
||||
let mut indicator = FibExtension::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// 161.8% extension projects 1.618 * (-100) below the 200 origin.
|
||||
assert_relative_eq!(v.level_1272, 200.0 - 127.2);
|
||||
assert_relative_eq!(v.level_1414, 200.0 - 141.4);
|
||||
assert_relative_eq!(v.level_1618, 200.0 - 161.8);
|
||||
assert_relative_eq!(v.level_2000, 0.0);
|
||||
assert_relative_eq!(v.level_2618, 200.0 - 261.8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibExtension::new();
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert!(indicator.update(c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
|
||||
let mut a = FibExtension::new();
|
||||
let mut b = FibExtension::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Fibonacci Fan — trendlines fanning from a swing start through the
|
||||
//! retracement levels at the swing end, extended to the current bar.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The three fan ratios drawn (38.2% / 50% / 61.8%).
|
||||
const RATIOS: [f64; 3] = [0.382, 0.5, 0.618];
|
||||
|
||||
/// Fibonacci Fan line prices evaluated at the current bar.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibFanOutput {
|
||||
/// Price of the 38.2% fan line at the current bar.
|
||||
pub fan_382: f64,
|
||||
/// Price of the 50% fan line at the current bar.
|
||||
pub fan_500: f64,
|
||||
/// Price of the 61.8% fan line at the current bar.
|
||||
pub fan_618: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Fan (`FibFan`).
|
||||
///
|
||||
/// Anchored at the start of the most recent confirmed swing leg, three lines fan
|
||||
/// out through the 38.2% / 50% / 61.8% retracement levels located at the leg's
|
||||
/// end bar, then extend to the current bar. Each line's price is reported as the
|
||||
/// fan opens with elapsed time.
|
||||
///
|
||||
/// ```text
|
||||
/// line(r) = start + r * (end - start) * (cur - start_bar) / (end_bar - start_bar)
|
||||
/// ```
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until the first
|
||||
/// leg is complete.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_fan.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibFan {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibFan {
|
||||
/// Construct a new Fibonacci Fan tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 2),
|
||||
}
|
||||
}
|
||||
|
||||
fn fan(&self) -> Option<FibFanOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
let start = pivots.first()?;
|
||||
let end = pivots.get(1)?;
|
||||
// Consecutive pivots occur at strictly increasing bars, so the span is
|
||||
// always at least one bar — no division by zero.
|
||||
let span_bars = (end.bar - start.bar) as f64;
|
||||
let elapsed = (self.swing.current_bar() - start.bar) as f64;
|
||||
let progress = elapsed / span_bars;
|
||||
let line = |r: f64| start.price + r * (end.price - start.price) * progress;
|
||||
Some(FibFanOutput {
|
||||
fan_382: line(RATIOS[0]),
|
||||
fan_500: line(RATIOS[1]),
|
||||
fan_618: line(RATIOS[2]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibFan {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibFan {
|
||||
type Input = Candle;
|
||||
type Output = FibFanOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibFanOutput> {
|
||||
self.swing.update(candle);
|
||||
self.fan()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 2
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibFan"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
/// Drive a leg start=200 (bar 0) -> end=100 (bar 2), confirmed at bar 3, so
|
||||
/// the fan is first reported at bar 3 with `progress = 3 / 2 = 1.5`.
|
||||
fn down_leg() -> Vec<Candle> {
|
||||
vec![
|
||||
c(200.0, 199.0, 0), // bootstrap high @200 (bar 0)
|
||||
c(190.0, 160.0, 1), // confirm high @200, low candidate @160
|
||||
c(150.0, 100.0, 2), // extend low to 100 (bar 2)
|
||||
c(110.0, 105.0, 3), // confirm low @100 -> two pivots
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibFan::new();
|
||||
assert_eq!(indicator.name(), "FibFan");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibFan::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_pivots() {
|
||||
let mut indicator = FibFan::new();
|
||||
// Only the high confirms here; no end pivot yet.
|
||||
let outputs: Vec<_> = [c(200.0, 199.0, 0), c(190.0, 150.0, 1)]
|
||||
.into_iter()
|
||||
.map(|x| indicator.update(x))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fan_lines_open_with_elapsed_time() {
|
||||
let mut indicator = FibFan::new();
|
||||
let mut last = None;
|
||||
for candle in down_leg() {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// progress = (3 - 0) / (2 - 0) = 1.5; line(r) = 200 + r*(-100)*1.5.
|
||||
assert_relative_eq!(v.fan_382, 200.0 - 0.382 * 150.0);
|
||||
assert_relative_eq!(v.fan_500, 125.0);
|
||||
assert_relative_eq!(v.fan_618, 200.0 - 0.618 * 150.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibFan::new();
|
||||
for candle in down_leg() {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = down_leg();
|
||||
let mut a = FibFan::new();
|
||||
let mut b = FibFan::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! Fibonacci Projection — a measured move from the last three swing pivots.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The four canonical projection ratios, in ascending order. Each scales the
|
||||
/// A→B leg and projects it from C; `1.0` is the classic AB=CD measured move.
|
||||
const RATIOS: [f64; 4] = [0.618, 1.0, 1.618, 2.618];
|
||||
|
||||
/// Fibonacci Projection levels (the C→D target zone of a measured move).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibProjectionOutput {
|
||||
/// 61.8% projection of the A→B leg from C.
|
||||
pub level_618: f64,
|
||||
/// 100% projection — the AB=CD measured move.
|
||||
pub level_1000: f64,
|
||||
/// 161.8% projection.
|
||||
pub level_1618: f64,
|
||||
/// 261.8% projection.
|
||||
pub level_2618: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Projection (`FibProjection`).
|
||||
///
|
||||
/// Reads the last three confirmed swing pivots as the points A, B and C of a
|
||||
/// measured move and projects the A→B leg from C at the canonical ratios — the
|
||||
/// price targets for the C→D leg.
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until three
|
||||
/// pivots have confirmed.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_projection.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibProjection {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibProjection {
|
||||
/// Construct a new Fibonacci Projection tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 3),
|
||||
}
|
||||
}
|
||||
|
||||
fn levels(&self) -> Option<FibProjectionOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
let [a, b, c] = [
|
||||
pivots.first()?.price,
|
||||
pivots.get(1)?.price,
|
||||
pivots.get(2)?.price,
|
||||
];
|
||||
let project = |p: f64| c + p * (b - a);
|
||||
Some(FibProjectionOutput {
|
||||
level_618: project(RATIOS[0]),
|
||||
level_1000: project(RATIOS[1]),
|
||||
level_1618: project(RATIOS[2]),
|
||||
level_2618: project(RATIOS[3]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibProjection {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibProjection {
|
||||
type Input = Candle;
|
||||
type Output = FibProjectionOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibProjectionOutput> {
|
||||
self.swing.update(candle);
|
||||
self.levels()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 3
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibProjection"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibProjection::new();
|
||||
assert_eq!(indicator.name(), "FibProjection");
|
||||
assert_eq!(indicator.warmup_period(), 3);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibProjection::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_three_pivots() {
|
||||
let mut indicator = FibProjection::new();
|
||||
let outputs: Vec<_> = candles_for_pivots(&[200.0, 100.0])
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_move_from_three_pivots() {
|
||||
// A = 200 (high), B = 160 (low), C = 190 (high). A->B = -40, projected
|
||||
// down from C.
|
||||
let mut indicator = FibProjection::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
let (a, b, c) = (200.0, 160.0, 190.0);
|
||||
assert_relative_eq!(v.level_618, c + 0.618 * (b - a));
|
||||
assert_relative_eq!(v.level_1000, c + (b - a));
|
||||
assert_relative_eq!(v.level_1618, c + 1.618 * (b - a));
|
||||
assert_relative_eq!(v.level_2618, c + 2.618 * (b - a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibProjection::new();
|
||||
for candle in candles_for_pivots(&[200.0, 160.0, 190.0]) {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert!(indicator.update(c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[200.0, 160.0, 190.0, 150.0]);
|
||||
let mut a = FibProjection::new();
|
||||
let mut b = FibProjection::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Fibonacci Retracement of the most recent confirmed swing leg.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The seven canonical retracement ratios, in ascending order. `0.0` marks the
|
||||
/// most recent swing extreme (the end of the leg) and `1.0` the swing origin
|
||||
/// (its start); the interior ratios are the classic Fibonacci pullbacks.
|
||||
const RATIOS: [f64; 7] = [0.0, 0.236, 0.382, 0.5, 0.618, 0.786, 1.0];
|
||||
|
||||
/// Fibonacci Retracement levels for the most recent swing leg.
|
||||
///
|
||||
/// Each field is the price at the matching retracement ratio, measured from the
|
||||
/// leg's end (`level_0`, the latest confirmed extreme) back toward its start
|
||||
/// (`level_1000`, the prior pivot).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibRetracementOutput {
|
||||
/// 0.0% — the most recent confirmed swing extreme.
|
||||
pub level_0: f64,
|
||||
/// 23.6% retracement.
|
||||
pub level_236: f64,
|
||||
/// 38.2% retracement.
|
||||
pub level_382: f64,
|
||||
/// 50% retracement (not a Fibonacci ratio, but conventionally drawn).
|
||||
pub level_500: f64,
|
||||
/// 61.8% retracement — the "golden ratio" pullback.
|
||||
pub level_618: f64,
|
||||
/// 78.6% retracement.
|
||||
pub level_786: f64,
|
||||
/// 100% — the swing origin.
|
||||
pub level_1000: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Retracement (`FibRetracement`).
|
||||
///
|
||||
/// Tracks confirmed swing pivots with a baked-in 5% reversal threshold (the
|
||||
/// same non-repainting logic as [`crate::indicators::ZigZag`]) and, once two
|
||||
/// pivots exist, reports the seven retracement levels of the leg between them.
|
||||
///
|
||||
/// The levels are recomputed each time a new pivot confirms; between
|
||||
/// confirmations [`Indicator::update`] returns the locked levels of the current
|
||||
/// leg. Before the first leg is complete it returns `None`.
|
||||
///
|
||||
/// Parameter-free: the threshold is a compile-time constant, mirroring the
|
||||
/// chart- and harmonic-pattern detectors, so construction is infallible.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_retracement.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibRetracement {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibRetracement {
|
||||
/// Construct a new Fibonacci Retracement tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retracement price at ratio `r` for a leg from `start` to `end`: `0.0`
|
||||
/// sits on `end`, `1.0` on `start`.
|
||||
fn level(start: f64, end: f64, r: f64) -> f64 {
|
||||
end + r * (start - end)
|
||||
}
|
||||
|
||||
fn levels(&self) -> Option<FibRetracementOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
|
||||
Some(FibRetracementOutput {
|
||||
level_0: Self::level(start, end, RATIOS[0]),
|
||||
level_236: Self::level(start, end, RATIOS[1]),
|
||||
level_382: Self::level(start, end, RATIOS[2]),
|
||||
level_500: Self::level(start, end, RATIOS[3]),
|
||||
level_618: Self::level(start, end, RATIOS[4]),
|
||||
level_786: Self::level(start, end, RATIOS[5]),
|
||||
level_1000: Self::level(start, end, RATIOS[6]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibRetracement {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibRetracement {
|
||||
type Input = Candle;
|
||||
type Output = FibRetracementOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibRetracementOutput> {
|
||||
self.swing.update(candle);
|
||||
self.levels()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 2
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibRetracement"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibRetracement::new();
|
||||
assert_eq!(indicator.name(), "FibRetracement");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibRetracement::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_pivots() {
|
||||
let mut indicator = FibRetracement::new();
|
||||
// A single confirmed pivot is not enough to define a leg.
|
||||
let candles = candles_for_pivots(&[120.0]);
|
||||
let outputs: Vec<_> = candles.into_iter().map(|c| indicator.update(c)).collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retracement_levels_of_a_down_leg() {
|
||||
// Leg start = 200 (high), end = 100 (low): a 100-point drop.
|
||||
let mut indicator = FibRetracement::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// 0% on the low (end), 100% on the high (start).
|
||||
assert_relative_eq!(v.level_0, 100.0);
|
||||
assert_relative_eq!(v.level_1000, 200.0);
|
||||
// 61.8% retracement of a 100-point drop, measured up from the low.
|
||||
assert_relative_eq!(v.level_618, 161.8);
|
||||
assert_relative_eq!(v.level_500, 150.0);
|
||||
assert_relative_eq!(v.level_382, 138.2);
|
||||
assert_relative_eq!(v.level_236, 123.6);
|
||||
assert_relative_eq!(v.level_786, 178.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_refresh_on_a_new_leg() {
|
||||
// Four pivots, cap = 2: once the third and fourth confirm, the reported
|
||||
// leg shifts to the latest pair (130 high -> 90 low).
|
||||
let mut indicator = FibRetracement::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[200.0, 100.0, 130.0, 90.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert_relative_eq!(v.level_0, 90.0);
|
||||
assert_relative_eq!(v.level_1000, 130.0);
|
||||
assert_relative_eq!(v.level_618, 90.0 + 0.618 * 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibRetracement::new();
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert!(indicator.update(c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
|
||||
let mut a = FibRetracement::new();
|
||||
let mut b = FibRetracement::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Fibonacci Time Zones — vertical markers at Fibonacci bar-distances from the
|
||||
//! most recent swing pivot.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Where the current bar sits relative to the Fibonacci time-zone grid anchored
|
||||
/// on the most recent confirmed pivot.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FibTimeZonesOutput {
|
||||
/// `1.0` when the current bar lands on a Fibonacci time zone (a bar distance
|
||||
/// of 1, 2, 3, 5, 8, 13, … from the anchor pivot), otherwise `0.0`.
|
||||
pub on_zone: f64,
|
||||
/// Number of bars until the next Fibonacci time zone (`0` is never returned —
|
||||
/// when on a zone this is the gap to the following one).
|
||||
pub bars_to_next: f64,
|
||||
}
|
||||
|
||||
/// Fibonacci Time Zones (`FibTimeZones`).
|
||||
///
|
||||
/// Anchored on the most recent confirmed swing pivot, the Fibonacci sequence
|
||||
/// `1, 2, 3, 5, 8, 13, …` marks bars at which trend changes are classically
|
||||
/// anticipated. Reports whether the current bar is on a zone and how many bars
|
||||
/// remain until the next one.
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until the first
|
||||
/// pivot has confirmed.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/fib_time_zones.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FibTimeZones {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl FibTimeZones {
|
||||
/// Construct a new Fibonacci Time Zones tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 2),
|
||||
}
|
||||
}
|
||||
|
||||
fn zones(&self) -> Option<FibTimeZonesOutput> {
|
||||
let anchor = self.swing.pivots().last()?;
|
||||
let distance = self.swing.current_bar() - anchor.bar;
|
||||
// Walk the time-zone sequence 1, 2, 3, 5, 8, … : `lo` advances through the
|
||||
// members, `on_zone` records a hit, and the loop exits with `lo` holding
|
||||
// the smallest member strictly greater than `distance`.
|
||||
let (mut lo, mut hi) = (1usize, 2usize);
|
||||
let mut on_zone = false;
|
||||
while lo <= distance {
|
||||
if lo == distance {
|
||||
on_zone = true;
|
||||
}
|
||||
let next = lo + hi;
|
||||
lo = hi;
|
||||
hi = next;
|
||||
}
|
||||
Some(FibTimeZonesOutput {
|
||||
on_zone: f64::from(u8::from(on_zone)),
|
||||
bars_to_next: (lo - distance) as f64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FibTimeZones {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FibTimeZones {
|
||||
type Input = Candle;
|
||||
type Output = FibTimeZonesOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<FibTimeZonesOutput> {
|
||||
self.swing.update(candle);
|
||||
self.zones()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
!self.swing.pivots().is_empty()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FibTimeZones"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn c(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
/// One pivot confirms at bar 0 (high @200, confirmed at bar 1); subsequent
|
||||
/// flat bars neither extend nor confirm, so the anchor stays at bar 0 and the
|
||||
/// distance equals the current bar index.
|
||||
fn anchored_run() -> Vec<Candle> {
|
||||
let mut bars = vec![c(200.0, 199.0, 0), c(190.0, 150.0, 1)];
|
||||
for ts in 2..=5 {
|
||||
bars.push(c(155.0, 151.0, ts));
|
||||
}
|
||||
bars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FibTimeZones::new();
|
||||
assert_eq!(indicator.name(), "FibTimeZones");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FibTimeZones::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_first_pivot() {
|
||||
let mut indicator = FibTimeZones::new();
|
||||
// The bootstrap bar confirms nothing.
|
||||
assert!(indicator.update(c(200.0, 199.0, 0)).is_none());
|
||||
assert!(!indicator.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_zones_and_counts_to_next() {
|
||||
let mut indicator = FibTimeZones::new();
|
||||
let out: Vec<_> = anchored_run()
|
||||
.into_iter()
|
||||
.map(|x| indicator.update(x))
|
||||
.collect();
|
||||
assert!(out[0].is_none()); // bootstrap, no pivot yet
|
||||
assert!(indicator.is_ready());
|
||||
// out[i] is reported at current bar i; anchor at bar 0 → distance = i.
|
||||
let d1 = out[1].unwrap(); // distance 1 → a zone
|
||||
assert_relative_eq!(d1.on_zone, 1.0);
|
||||
assert_relative_eq!(d1.bars_to_next, 1.0); // next zone at 2
|
||||
let d4 = out[4].unwrap(); // distance 4 → not a zone
|
||||
assert_relative_eq!(d4.on_zone, 0.0);
|
||||
assert_relative_eq!(d4.bars_to_next, 1.0); // next zone at 5
|
||||
let d5 = out[5].unwrap(); // distance 5 → a zone
|
||||
assert_relative_eq!(d5.on_zone, 1.0);
|
||||
assert_relative_eq!(d5.bars_to_next, 3.0); // next zone at 8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FibTimeZones::new();
|
||||
for candle in anchored_run() {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
assert!(indicator.is_ready());
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(indicator.update(c(100.0, 99.5, 0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = anchored_run();
|
||||
let mut a = FibTimeZones::new();
|
||||
let mut b = FibTimeZones::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Flag / Pennant continuation chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Maximum size of the consolidation swing relative to the pole for a
|
||||
/// flag/pennant to qualify — the pullback must retrace less than half the pole.
|
||||
const MAX_RETRACE_FRACTION: f64 = 0.5;
|
||||
|
||||
/// Flag / Pennant — a brief consolidation against a sharp prior move (the
|
||||
/// "pole"), resolving in the pole's direction.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); evaluated from the
|
||||
/// last three pivots `pole_start → pole_end → consolidation`:
|
||||
///
|
||||
/// ```text
|
||||
/// pole = |pole_end − pole_start| (the sharp impulse)
|
||||
/// pullback = |consolidation − pole_end| (the shallow counter-move)
|
||||
/// qualifies when pullback < 0.5 · pole
|
||||
/// bull flag : pole_end is a swing high → +1 (up-pole, continuation up)
|
||||
/// bear flag : pole_end is a swing low → -1 (down-pole, continuation down)
|
||||
/// ```
|
||||
///
|
||||
/// The detector fires on the bar that confirms the consolidation pivot (the flag
|
||||
/// is complete; the breakout is expected to follow). Output is `+1.0` / `-1.0` /
|
||||
/// `0.0`; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FlagPennant {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl FlagPennant {
|
||||
/// Construct a new Flag / Pennant detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 3),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FlagPennant {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FlagPennant {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 3 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let n = pivots.len();
|
||||
let pole_start = pivots[n - 3];
|
||||
let pole_end = pivots[n - 2];
|
||||
let consolidation = pivots[n - 1];
|
||||
let pole = (pole_end.price - pole_start.price).abs();
|
||||
let pullback = (consolidation.price - pole_end.price).abs();
|
||||
|
||||
if pole > 0.0 && pullback < MAX_RETRACE_FRACTION * pole {
|
||||
// pole_end a high → up-pole → bull flag; a low → bear flag.
|
||||
return Some(if pole_end.direction > 0.0 { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Three confirmed pivots; the earliest confirmation of the third is bar 4.
|
||||
4
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FlagPennant"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = FlagPennant::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = FlagPennant::new();
|
||||
assert_eq!(indicator.name(), "FlagPennant");
|
||||
assert_eq!(indicator.warmup_period(), 4);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!FlagPennant::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bull_flag_is_plus_one() {
|
||||
// Up-pole 100 → 140 (40), shallow pullback to 130 (10 < 20) → bull flag.
|
||||
let out = run(&[150.0, 100.0, 140.0, 130.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bear_flag_is_minus_one() {
|
||||
// Down-pole 140 → 100 (40), shallow pullback to 110 (10 < 20) → bear flag.
|
||||
let out = run(&[140.0, 100.0, 110.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_pullback_is_not_a_flag() {
|
||||
// Pole 100 → 140 (40) but pullback to 104 (36 > 20) → not a flag.
|
||||
let out = run(&[150.0, 100.0, 140.0, 104.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = FlagPennant::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 130.0]);
|
||||
let mut a = FlagPennant::new();
|
||||
let mut b = FlagPennant::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Gartley harmonic pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{ratios_in, xabcd, SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Gartley — the classic 5-point (X-A-B-C-D) harmonic pattern, recognised from
|
||||
/// confirmed swing pivots when the legs fall inside the Gartley Fibonacci
|
||||
/// windows:
|
||||
///
|
||||
/// ```text
|
||||
/// AB / XA ∈ [0.55, 0.70] (≈ 0.618 retracement of XA)
|
||||
/// BC / AB ∈ [0.382, 0.886]
|
||||
/// CD / BC ∈ [1.13, 1.618]
|
||||
/// AD / XA ∈ [0.74, 0.84] (≈ 0.786 — the defining D completion)
|
||||
/// ```
|
||||
///
|
||||
/// Output is `+1.0` when the terminal point D is a swing low (bullish
|
||||
/// completion), `-1.0` when D is a swing high (bearish), and `0.0` otherwise;
|
||||
/// never `None`. See `crates/wickra-core/src/indicators/gartley.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Gartley {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Gartley {
|
||||
/// Construct a new Gartley detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Gartley {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Gartley {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let p = xabcd(pivots);
|
||||
let xa = (p.a - p.x).abs();
|
||||
let ab = (p.b - p.a).abs();
|
||||
let bc = (p.c - p.b).abs();
|
||||
let cd = (p.d - p.c).abs();
|
||||
let ad = (p.d - p.a).abs();
|
||||
let matched = ratios_in(&[
|
||||
(ab / xa, 0.55, 0.70),
|
||||
(bc / ab, 0.382, 0.886),
|
||||
(cd / bc, 1.13, 1.618),
|
||||
(ad / xa, 0.74, 0.84),
|
||||
]);
|
||||
if matched {
|
||||
return Some(if p.bullish { 1.0 } else { -1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Gartley"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = Gartley::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = Gartley::new();
|
||||
assert_eq!(indicator.name(), "Gartley");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!Gartley::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullish_gartley_is_plus_one() {
|
||||
let out = run(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bearish_gartley_is_minus_one() {
|
||||
let out = run(&[150.0, 110.0, 134.7, 122.35, 141.44]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_ratio_does_not_trigger() {
|
||||
// Five pivots but the D completion (AD/XA ≈ 0.25) is far from 0.786.
|
||||
let out = run(&[150.0, 100.0, 140.0, 110.0, 135.0, 105.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = Gartley::new();
|
||||
for c in candles_for_pivots(&[150.0, 100.0, 140.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[150.0, 100.0, 140.0, 115.3, 127.65, 108.56]);
|
||||
let mut a = Gartley::new();
|
||||
let mut b = Gartley::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Golden Pocket — the 0.618-0.65 optimal-trade-entry zone of the last swing.
|
||||
|
||||
use crate::indicators::pattern_swing::{SwingTracker, SWING_THRESHOLD};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Lower bound of the golden pocket (the 61.8% retracement).
|
||||
const RATIO_LOW: f64 = 0.618;
|
||||
/// Upper bound of the golden pocket (the 65% retracement).
|
||||
const RATIO_HIGH: f64 = 0.65;
|
||||
|
||||
/// The golden-pocket zone of the most recent swing leg.
|
||||
///
|
||||
/// `low`/`high` bracket the 0.618-0.65 retracement band (sorted, so `low <=
|
||||
/// high` regardless of swing direction); `mid` is their midpoint.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct GoldenPocketOutput {
|
||||
/// Lower price of the golden-pocket band.
|
||||
pub low: f64,
|
||||
/// Midpoint of the band.
|
||||
pub mid: f64,
|
||||
/// Upper price of the golden-pocket band.
|
||||
pub high: f64,
|
||||
}
|
||||
|
||||
/// Golden Pocket (`GoldenPocket`).
|
||||
///
|
||||
/// The 0.618-0.65 retracement band of the most recent confirmed swing leg — the
|
||||
/// "optimal trade entry" zone many swing traders watch for continuation.
|
||||
///
|
||||
/// Parameter-free; construction is infallible. Returns `None` until the first
|
||||
/// leg is complete.
|
||||
///
|
||||
/// See `crates/wickra-core/src/indicators/golden_pocket.rs`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GoldenPocket {
|
||||
swing: SwingTracker,
|
||||
}
|
||||
|
||||
impl GoldenPocket {
|
||||
/// Construct a new Golden Pocket tracker.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 2),
|
||||
}
|
||||
}
|
||||
|
||||
fn zone(&self) -> Option<GoldenPocketOutput> {
|
||||
let pivots = self.swing.pivots();
|
||||
let [start, end] = [pivots.first()?.price, pivots.get(1)?.price];
|
||||
let span = start - end;
|
||||
let edge_low = end + RATIO_LOW * span;
|
||||
let edge_high = end + RATIO_HIGH * span;
|
||||
let low = edge_low.min(edge_high);
|
||||
let high = edge_low.max(edge_high);
|
||||
Some(GoldenPocketOutput {
|
||||
low,
|
||||
mid: f64::midpoint(low, high),
|
||||
high,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GoldenPocket {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GoldenPocket {
|
||||
type Input = Candle;
|
||||
type Output = GoldenPocketOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<GoldenPocketOutput> {
|
||||
self.swing.update(candle);
|
||||
self.zone()
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.swing.pivots().len() >= 2
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GoldenPocket"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = GoldenPocket::new();
|
||||
assert_eq!(indicator.name(), "GoldenPocket");
|
||||
assert_eq!(indicator.warmup_period(), 2);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!GoldenPocket::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_before_two_pivots() {
|
||||
let mut indicator = GoldenPocket::new();
|
||||
let outputs: Vec<_> = candles_for_pivots(&[120.0])
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c))
|
||||
.collect();
|
||||
assert!(outputs.iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_of_a_down_leg() {
|
||||
// Leg 200 (high) -> 100 (low), span = 100.
|
||||
let mut indicator = GoldenPocket::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(indicator.is_ready());
|
||||
// 61.8% = 161.8, 65% = 165 → sorted band [161.8, 165], mid 163.4.
|
||||
assert_relative_eq!(v.low, 161.8);
|
||||
assert_relative_eq!(v.high, 165.0);
|
||||
assert_relative_eq!(v.mid, 163.4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn band_is_sorted_for_an_up_leg() {
|
||||
// Latest leg 100 (low) -> 250 (high): span negative, edges flip, but
|
||||
// low <= high must still hold.
|
||||
let mut indicator = GoldenPocket::new();
|
||||
let mut last = None;
|
||||
for candle in candles_for_pivots(&[200.0, 100.0, 250.0]) {
|
||||
last = indicator.update(candle);
|
||||
}
|
||||
let v = last.unwrap();
|
||||
assert!(v.low <= v.high);
|
||||
assert_relative_eq!(v.mid, f64::midpoint(v.low, v.high));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = GoldenPocket::new();
|
||||
for candle in candles_for_pivots(&[200.0, 100.0]) {
|
||||
let _ = indicator.update(candle);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert!(indicator.update(c).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[200.0, 100.0, 150.0]);
|
||||
let mut a = GoldenPocket::new();
|
||||
let mut b = GoldenPocket::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,191 @@
|
||||
//! Head-and-Shoulders (and Inverse) reversal chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Head-and-Shoulders / Inverse Head-and-Shoulders — a five-pivot reversal
|
||||
/// pattern with a central extreme (the head) flanked by two lower/higher
|
||||
/// shoulders at a similar level, joined by a roughly horizontal neckline.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised on the
|
||||
/// bar that confirms the right shoulder:
|
||||
///
|
||||
/// ```text
|
||||
/// head-and-shoulders top (bearish, -1):
|
||||
/// LeftShoulder(high) , Trough , Head(high) , Trough , RightShoulder(high)
|
||||
/// Head > both shoulders ; LeftShoulder ≈ RightShoulder ; Trough₁ ≈ Trough₂
|
||||
///
|
||||
/// inverse head-and-shoulders (bullish, +1):
|
||||
/// LeftShoulder(low) , Peak , Head(low) , Peak , RightShoulder(low)
|
||||
/// Head < both shoulders ; LeftShoulder ≈ RightShoulder ; Peak₁ ≈ Peak₂
|
||||
/// ```
|
||||
///
|
||||
/// The shoulders must match within [`LEVEL_TOLERANCE`] (3%) and the two neckline
|
||||
/// points within the same tolerance. Output is `-1.0` for a top, `+1.0` for an
|
||||
/// inverse, `0.0` otherwise; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeadAndShoulders {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl HeadAndShoulders {
|
||||
/// Construct a new Head-and-Shoulders detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 5),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HeadAndShoulders {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HeadAndShoulders {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 5 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let n = pivots.len();
|
||||
let left_shoulder = pivots[n - 5];
|
||||
let neck_1 = pivots[n - 4];
|
||||
let head = pivots[n - 3];
|
||||
let neck_2 = pivots[n - 2];
|
||||
let right_shoulder = pivots[n - 1];
|
||||
|
||||
let shoulders_match =
|
||||
approx_equal(left_shoulder.price, right_shoulder.price, LEVEL_TOLERANCE);
|
||||
let neckline_flat = approx_equal(neck_1.price, neck_2.price, LEVEL_TOLERANCE);
|
||||
let head_is_peak = head.price > left_shoulder.price && head.price > right_shoulder.price;
|
||||
let head_is_trough = head.price < left_shoulder.price && head.price < right_shoulder.price;
|
||||
let frame_matches = shoulders_match && neckline_flat;
|
||||
|
||||
if right_shoulder.direction > 0.0 {
|
||||
// Head-and-shoulders top: head is the highest of the three highs.
|
||||
if head_is_peak && frame_matches {
|
||||
return Some(-1.0);
|
||||
}
|
||||
} else if head_is_trough && frame_matches {
|
||||
// Inverse: head is the lowest of the three lows.
|
||||
return Some(1.0);
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Five confirmed pivots; the earliest confirmation of the fifth is bar 6.
|
||||
6
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HeadAndShoulders"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = HeadAndShoulders::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = HeadAndShoulders::new();
|
||||
assert_eq!(indicator.name(), "HeadAndShoulders");
|
||||
assert_eq!(indicator.warmup_period(), 6);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!HeadAndShoulders::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_and_shoulders_top_is_minus_one() {
|
||||
// LS 100, trough 90, head 120, trough 92, RS 101.
|
||||
let out = run(&[100.0, 90.0, 120.0, 92.0, 101.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
assert!(out[..out.len() - 1].iter().all(|&x| x == 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_head_and_shoulders_is_plus_one() {
|
||||
// Lead high then LS 100, peak 110, head 80, peak 108, RS 101.
|
||||
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 101.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_shoulders_do_not_trigger() {
|
||||
// Right shoulder (115) far from left (100) → no pattern.
|
||||
let out = run(&[100.0, 90.0, 130.0, 92.0, 115.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inverse_mismatched_shoulders_do_not_trigger() {
|
||||
// Inverse shape (ends on a low) but the right shoulder (90) diverges from
|
||||
// the left (100) → enters the inverse branch yet reports no pattern.
|
||||
let out = run(&[130.0, 100.0, 110.0, 80.0, 108.0, 90.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_highs_without_taller_head_do_not_trigger() {
|
||||
// Three equal highs (no dominant head) → not H&S (that is a triple top).
|
||||
let out = run(&[120.0, 90.0, 120.0, 92.0, 120.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = HeadAndShoulders::new();
|
||||
for c in candles_for_pivots(&[100.0, 90.0, 120.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[100.0, 90.0, 120.0, 92.0, 101.0]);
|
||||
let mut a = HeadAndShoulders::new();
|
||||
let mut b = HeadAndShoulders::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! High-Low Index — a moving average of the record-high percentage.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::error::Result;
|
||||
use crate::traits::Indicator;
|
||||
use crate::Sma;
|
||||
|
||||
/// High-Low Index — a simple moving average of the *record high percent*,
|
||||
/// `100 * new_highs / (new_highs + new_lows)`.
|
||||
///
|
||||
/// The record high percent is the share of new-extreme issues that are new
|
||||
/// *highs* rather than new *lows*; smoothing it over a window (the classic period
|
||||
/// is 10) gives the High-Low Index. Readings above 50 mean new highs dominate
|
||||
/// (a healthy, broadening trend), readings below 50 mean new lows dominate. The
|
||||
/// 30 and 70 lines are watched as oversold / overbought breadth thresholds.
|
||||
///
|
||||
/// Each tick floors the new-extreme count to one, so a tick with no new highs or
|
||||
/// lows contributes a defined `0.0` instead of dividing by zero. The reading is
|
||||
/// `None` until `period` ticks have been seen.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
|
||||
/// `warmup_period == period`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CrossSection, HighLowIndex, Indicator, Member};
|
||||
///
|
||||
/// let mut hli = HighLowIndex::new(2).unwrap();
|
||||
/// let highs = CrossSection::new(vec![Member::new(1.0, 1.0, true, false)], 0).unwrap();
|
||||
/// assert_eq!(hli.update(highs.clone()), None); // warming up
|
||||
/// assert_eq!(hli.update(highs), Some(100.0)); // all new highs
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighLowIndex {
|
||||
sma: Sma,
|
||||
}
|
||||
|
||||
impl HighLowIndex {
|
||||
/// Construct a new High-Low Index over the given window length.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`](crate::Error::PeriodZero) if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
Ok(Self {
|
||||
sma: Sma::new(period)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window length.
|
||||
#[must_use]
|
||||
pub const fn period(&self) -> usize {
|
||||
self.sma.period()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HighLowIndex {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let new_highs = section.new_highs();
|
||||
let new_lows = section.new_lows();
|
||||
let extremes = (new_highs + new_lows).max(1) as f64;
|
||||
let record_high_percent = 100.0 * new_highs as f64 / extremes;
|
||||
self.sma.update(record_high_percent)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.sma.reset();
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.sma.period()
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.sma.value().is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HighLowIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::error::Error;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn flags(highs: usize, lows: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..highs {
|
||||
members.push(Member::new(1.0, 10.0, true, false));
|
||||
}
|
||||
for _ in 0..lows {
|
||||
members.push(Member::new(-1.0, 10.0, false, true));
|
||||
}
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let hli = HighLowIndex::new(10).unwrap();
|
||||
assert_eq!(hli.name(), "HighLowIndex");
|
||||
assert_eq!(hli.warmup_period(), 10);
|
||||
assert_eq!(hli.period(), 10);
|
||||
assert!(!hli.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(HighLowIndex::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averages_the_record_high_percent() {
|
||||
let mut hli = HighLowIndex::new(2).unwrap();
|
||||
// 8 highs / 10 extremes -> 80% ; window not full.
|
||||
assert_eq!(hli.update(flags(8, 2)), None);
|
||||
// 6 highs / 10 extremes -> 60% ; SMA(2) = (80 + 60) / 2 = 70.
|
||||
let value = hli.update(flags(6, 4)).unwrap();
|
||||
assert!((value - 70.0).abs() < 1e-9);
|
||||
assert!(hli.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_extremes_floors_to_zero_percent() {
|
||||
let mut hli = HighLowIndex::new(1).unwrap();
|
||||
// No new highs or lows -> 0 / max(0, 1) -> 0%.
|
||||
assert_eq!(hli.update(flags(0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut hli = HighLowIndex::new(2).unwrap();
|
||||
hli.update(flags(8, 2));
|
||||
hli.update(flags(6, 4));
|
||||
assert!(hli.is_ready());
|
||||
hli.reset();
|
||||
assert!(!hli.is_ready());
|
||||
assert_eq!(hli.update(flags(8, 2)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![flags(8, 2), flags(6, 4), flags(3, 7), flags(0, 0)];
|
||||
let mut a = HighLowIndex::new(2).unwrap();
|
||||
let mut b = HighLowIndex::new(2).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Intraday Volatility Profile — the return volatility in each intraday bucket.
|
||||
|
||||
use crate::calendar::civil_from_timestamp;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Intraday Volatility Profile output: the per-bucket return standard deviation.
|
||||
///
|
||||
/// `bins[i]` is the sample standard deviation of the simple returns of all bars
|
||||
/// whose local time-of-day fell in bucket `i`. Buckets with fewer than two
|
||||
/// samples read `0.0`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct IntradayVolatilityProfileOutput {
|
||||
/// Per-bucket return standard deviation, earliest bucket first.
|
||||
pub bins: Vec<f64>,
|
||||
}
|
||||
|
||||
/// Return volatility bucketed by local time of day.
|
||||
///
|
||||
/// The local day (the wall-clock day of [`Candle::timestamp`](crate::Candle)
|
||||
/// shifted by `utc_offset_minutes`) is split into `buckets` equal slices. Each
|
||||
/// bar's simple return `close / previous_close - 1` updates the per-bucket
|
||||
/// running variance (Welford), and the profile reports the per-bucket sample
|
||||
/// standard deviation. The first bar produces no output.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, IntradayVolatilityProfile};
|
||||
///
|
||||
/// let hour = 3_600_000;
|
||||
/// let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
|
||||
/// assert!(prof.update(Candle::new(100.0, 100.0, 100.0, 100.0, 1.0, 0).unwrap()).is_none());
|
||||
/// let out = prof.update(Candle::new(101.0, 101.0, 101.0, 101.0, 1.0, hour).unwrap()).unwrap();
|
||||
/// assert_eq!(out.bins.len(), 24);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntradayVolatilityProfile {
|
||||
buckets: usize,
|
||||
utc_offset_minutes: i32,
|
||||
prev_close: Option<f64>,
|
||||
count: Vec<u64>,
|
||||
mean: Vec<f64>,
|
||||
m2: Vec<f64>,
|
||||
last: Option<IntradayVolatilityProfileOutput>,
|
||||
}
|
||||
|
||||
impl IntradayVolatilityProfile {
|
||||
/// Construct an Intraday Volatility Profile with `buckets` intraday slices.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `buckets == 0`.
|
||||
pub fn new(buckets: usize, utc_offset_minutes: i32) -> Result<Self> {
|
||||
if buckets == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
buckets,
|
||||
utc_offset_minutes,
|
||||
prev_close: None,
|
||||
count: vec![0; buckets],
|
||||
mean: vec![0.0; buckets],
|
||||
m2: vec![0.0; buckets],
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(buckets, utc_offset_minutes)`.
|
||||
pub const fn params(&self) -> (usize, i32) {
|
||||
(self.buckets, self.utc_offset_minutes)
|
||||
}
|
||||
|
||||
/// Most recent profile if at least one return has been recorded.
|
||||
pub fn value(&self) -> Option<&IntradayVolatilityProfileOutput> {
|
||||
self.last.as_ref()
|
||||
}
|
||||
|
||||
fn bucket_of(&self, minute_of_day: u32) -> usize {
|
||||
let raw = (minute_of_day as usize * self.buckets) / 1440;
|
||||
raw.min(self.buckets - 1)
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> IntradayVolatilityProfileOutput {
|
||||
let bins = self
|
||||
.count
|
||||
.iter()
|
||||
.zip(&self.m2)
|
||||
.map(|(n, m2)| {
|
||||
if *n >= 2 {
|
||||
(m2 / (*n - 1) as f64).sqrt()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
IntradayVolatilityProfileOutput { bins }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for IntradayVolatilityProfile {
|
||||
type Input = Candle;
|
||||
type Output = IntradayVolatilityProfileOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<IntradayVolatilityProfileOutput> {
|
||||
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
|
||||
let result = if let Some(prev) = self.prev_close {
|
||||
let ret = if prev == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
candle.close / prev - 1.0
|
||||
};
|
||||
let bucket = self.bucket_of(civil.minute_of_day());
|
||||
self.count[bucket] += 1;
|
||||
let delta = ret - self.mean[bucket];
|
||||
self.mean[bucket] += delta / self.count[bucket] as f64;
|
||||
let delta2 = ret - self.mean[bucket];
|
||||
self.m2[bucket] += delta * delta2;
|
||||
let out = self.snapshot();
|
||||
self.last = Some(out.clone());
|
||||
Some(out)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.prev_close = Some(candle.close);
|
||||
result
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.count.iter_mut().for_each(|x| *x = 0);
|
||||
self.mean.iter_mut().for_each(|x| *x = 0.0);
|
||||
self.m2.iter_mut().for_each(|x| *x = 0.0);
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"IntradayVolatilityProfile"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
const DAY: i64 = 24 * HOUR;
|
||||
|
||||
fn c(close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, close, close, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_buckets() {
|
||||
assert!(matches!(
|
||||
IntradayVolatilityProfile::new(0, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_accessors() {
|
||||
let prof = IntradayVolatilityProfile::new(24, 90).unwrap();
|
||||
assert_eq!(prof.params(), (24, 90));
|
||||
assert_eq!(prof.name(), "IntradayVolatilityProfile");
|
||||
assert_eq!(prof.warmup_period(), 2);
|
||||
assert!(!prof.is_ready());
|
||||
assert!(prof.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sample_bucket_has_zero_vol() {
|
||||
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
|
||||
assert!(prof.update(c(100.0, 0)).is_none());
|
||||
let out = prof.update(c(101.0, HOUR)).unwrap();
|
||||
assert_eq!(out.bins.len(), 24);
|
||||
assert_relative_eq!(out.bins[1], 0.0); // only one sample in bucket 1
|
||||
assert!(prof.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn std_matches_manual_two_samples() {
|
||||
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
|
||||
prof.update(c(100.0, 0)); // 00:00
|
||||
prof.update(c(101.0, HOUR)); // 01:00 r=0.01 into bucket 1
|
||||
// Next day 01:00, r2 = 0.03 into bucket 1.
|
||||
let out = prof.update(c(101.0 * 1.03, 25 * HOUR)).unwrap();
|
||||
// sample std of {0.01, 0.03} = sqrt(((.01-.02)^2+(.03-.02)^2)/1) = 0.01414..
|
||||
let mean = 0.02;
|
||||
let expected = (((0.01_f64 - mean).powi(2) + (0.03 - mean).powi(2)) / 1.0).sqrt();
|
||||
assert_relative_eq!(out.bins[1], expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_prev_close_uses_zero_return() {
|
||||
let mut prof = IntradayVolatilityProfile::new(4, 0).unwrap();
|
||||
prof.update(c(0.0, 0));
|
||||
let out = prof.update(c(5.0, HOUR)).unwrap();
|
||||
assert_relative_eq!(out.bins[0], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut prof = IntradayVolatilityProfile::new(24, 0).unwrap();
|
||||
prof.update(c(100.0, 0));
|
||||
prof.update(c(101.0, HOUR));
|
||||
prof.reset();
|
||||
assert!(!prof.is_ready());
|
||||
assert!(prof.value().is_none());
|
||||
assert!(prof.update(c(100.0, DAY)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| c(100.0 + f64::from(i % 6), i64::from(i) * HOUR))
|
||||
.collect();
|
||||
let mut a = IntradayVolatilityProfile::new(12, 0).unwrap();
|
||||
let mut b = IntradayVolatilityProfile::new(12, 0).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! McClellan Oscillator — the spread between a fast and slow EMA of breadth.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Fast EMA smoothing constant — the classic McClellan 19-period weight
|
||||
/// `2 / (19 + 1)`.
|
||||
const ALPHA_FAST: f64 = 0.1;
|
||||
/// Slow EMA smoothing constant — the classic McClellan 39-period weight
|
||||
/// `2 / (39 + 1)`.
|
||||
const ALPHA_SLOW: f64 = 0.05;
|
||||
/// Scale applied to the ratio-adjusted net advances so readings land on the
|
||||
/// familiar McClellan amplitude.
|
||||
const RANA_SCALE: f64 = 1000.0;
|
||||
|
||||
/// McClellan Oscillator — the difference between a 19-period and a 39-period
|
||||
/// exponential moving average of *ratio-adjusted net advances*.
|
||||
///
|
||||
/// Each tick's breadth is reduced to ratio-adjusted net advances (RANA),
|
||||
/// `(advancers - decliners) / (advancers + decliners) * 1000`. Dividing by the
|
||||
/// number of participating issues makes the reading independent of universe size,
|
||||
/// so the oscillator stays comparable as the universe grows or shrinks. The
|
||||
/// oscillator is then the fast EMA minus the slow EMA of that series, using the
|
||||
/// classic McClellan smoothing constants `0.10` (19-period) and `0.05`
|
||||
/// (39-period). Both EMAs are seeded from the first tick's RANA, so the
|
||||
/// oscillator is defined from the first update (`warmup_period == 1`); it starts
|
||||
/// at `0.0` and crosses zero as breadth momentum shifts.
|
||||
///
|
||||
/// A tick with no advancing or declining issues yields a RANA of `0.0` (the
|
||||
/// participating count is floored to one).
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CrossSection, Indicator, McClellanOscillator, Member};
|
||||
///
|
||||
/// let mut osc = McClellanOscillator::new();
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// // First tick seeds both EMAs to the same value -> oscillator 0.
|
||||
/// assert_eq!(osc.update(tick), Some(0.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct McClellanOscillator {
|
||||
ema_fast: f64,
|
||||
ema_slow: f64,
|
||||
seeded: bool,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl McClellanOscillator {
|
||||
/// Construct a new McClellan Oscillator with the classic 19/39 smoothing.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
ema_fast: 0.0,
|
||||
ema_slow: 0.0,
|
||||
seeded: false,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a cross-section tick and return the oscillator value, which is defined
|
||||
/// on every tick. Shared with [`McClellanSummationIndex`] so the summation
|
||||
/// index can accumulate the oscillator without an `Option` round-trip.
|
||||
///
|
||||
/// [`McClellanSummationIndex`]: crate::McClellanSummationIndex
|
||||
pub(crate) fn step(&mut self, section: &CrossSection) -> f64 {
|
||||
let advancers = section.advancers();
|
||||
let decliners = section.decliners();
|
||||
let net = advancers as f64 - decliners as f64;
|
||||
let participating = (advancers + decliners).max(1) as f64;
|
||||
let rana = net / participating * RANA_SCALE;
|
||||
if self.seeded {
|
||||
self.ema_fast += ALPHA_FAST * (rana - self.ema_fast);
|
||||
self.ema_slow += ALPHA_SLOW * (rana - self.ema_slow);
|
||||
} else {
|
||||
self.ema_fast = rana;
|
||||
self.ema_slow = rana;
|
||||
self.seeded = true;
|
||||
}
|
||||
self.has_emitted = true;
|
||||
self.ema_fast - self.ema_slow
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for McClellanOscillator {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
Some(self.step(§ion))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ema_fast = 0.0;
|
||||
self.ema_slow = 0.0;
|
||||
self.seeded = false;
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"McClellanOscillator"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn section(up: usize, down: 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));
|
||||
}
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let osc = McClellanOscillator::new();
|
||||
assert_eq!(osc.name(), "McClellanOscillator");
|
||||
assert_eq!(osc.warmup_period(), 1);
|
||||
assert!(!osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeds_to_zero_on_first_tick() {
|
||||
let mut osc = McClellanOscillator::new();
|
||||
// RANA = (3 - 1) / 4 * 1000 = 500 ; both EMAs seed to 500 -> spread 0.
|
||||
assert_eq!(osc.update(section(3, 1)), Some(0.0));
|
||||
assert!(osc.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_breadth_momentum_after_seeding() {
|
||||
let mut osc = McClellanOscillator::new();
|
||||
osc.update(section(3, 1)); // seed at RANA 500
|
||||
// RANA = (1 - 3) / 4 * 1000 = -500.
|
||||
// fast = 500 + 0.1 * (-1000) = 400 ; slow = 500 + 0.05 * (-1000) = 450.
|
||||
let value = osc.update(section(1, 3)).unwrap();
|
||||
assert!((value - (-50.0)).abs() < 1e-9);
|
||||
// RANA = 0. fast = 400 + 0.1 * (-400) = 360 ; slow = 450 + 0.05 * (-450) = 427.5.
|
||||
let value = osc.update(section(2, 2)).unwrap();
|
||||
assert!((value - (-67.5)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_participation_yields_zero_rana() {
|
||||
let mut osc = McClellanOscillator::new();
|
||||
// No advancers or decliners -> RANA 0 ; seeds both EMAs to 0 -> spread 0.
|
||||
assert_eq!(osc.update(section(0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut osc = McClellanOscillator::new();
|
||||
osc.update(section(3, 1));
|
||||
osc.update(section(1, 3));
|
||||
assert!(osc.is_ready());
|
||||
osc.reset();
|
||||
assert!(!osc.is_ready());
|
||||
// After reset the next tick re-seeds to spread 0.
|
||||
assert_eq!(osc.update(section(1, 3)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![section(3, 1), section(1, 3), section(2, 2), section(0, 0)];
|
||||
let mut a = McClellanOscillator::new();
|
||||
let mut b = McClellanOscillator::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! McClellan Summation Index — the running total of the McClellan Oscillator.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::indicators::mcclellan_oscillator::McClellanOscillator;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// McClellan Summation Index — the running cumulative sum of the
|
||||
/// [`McClellanOscillator`].
|
||||
///
|
||||
/// Where the oscillator measures the *momentum* of breadth, the summation index
|
||||
/// integrates it into a longer-term breadth trend: it rises while the oscillator
|
||||
/// is positive and falls while it is negative, so it behaves like a slow,
|
||||
/// smoothed advance/decline line. Sustained readings far above or below zero mark
|
||||
/// strong bull or bear breadth regimes, and crosses of the zero line are read as
|
||||
/// major trend changes.
|
||||
///
|
||||
/// The index embeds a [`McClellanOscillator`] and adds its value on every tick.
|
||||
/// Because the oscillator seeds to `0.0` on the first tick, the summation index
|
||||
/// also starts at `0.0` and is defined from the first update
|
||||
/// (`warmup_period == 1`).
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CrossSection, Indicator, McClellanSummationIndex, Member};
|
||||
///
|
||||
/// let mut msi = McClellanSummationIndex::new();
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, false, false),
|
||||
/// Member::new(-1.0, 10.0, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// // First tick: oscillator seeds to 0, so the summation index is 0.
|
||||
/// assert_eq!(msi.update(tick), Some(0.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct McClellanSummationIndex {
|
||||
oscillator: McClellanOscillator,
|
||||
sum: f64,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl McClellanSummationIndex {
|
||||
/// Construct a new McClellan Summation Index.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
oscillator: McClellanOscillator::new(),
|
||||
sum: 0.0,
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for McClellanSummationIndex {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let oscillator = self.oscillator.step(§ion);
|
||||
self.sum += oscillator;
|
||||
self.has_emitted = true;
|
||||
Some(self.sum)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.oscillator.reset();
|
||||
self.sum = 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 {
|
||||
"McClellanSummationIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn section(up: usize, down: 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));
|
||||
}
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let msi = McClellanSummationIndex::new();
|
||||
assert_eq!(msi.name(), "McClellanSummationIndex");
|
||||
assert_eq!(msi.warmup_period(), 1);
|
||||
assert!(!msi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_starts_at_zero() {
|
||||
let mut msi = McClellanSummationIndex::new();
|
||||
assert_eq!(msi.update(section(3, 1)), Some(0.0));
|
||||
assert!(msi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accumulates_the_oscillator() {
|
||||
let mut msi = McClellanSummationIndex::new();
|
||||
assert_eq!(msi.update(section(3, 1)), Some(0.0)); // osc 0 -> sum 0
|
||||
// osc -50 -> sum -50.
|
||||
let value = msi.update(section(1, 3)).unwrap();
|
||||
assert!((value - (-50.0)).abs() < 1e-9);
|
||||
// osc -67.5 -> sum -117.5.
|
||||
let value = msi.update(section(2, 2)).unwrap();
|
||||
assert!((value - (-117.5)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut msi = McClellanSummationIndex::new();
|
||||
msi.update(section(3, 1));
|
||||
msi.update(section(1, 3));
|
||||
assert!(msi.is_ready());
|
||||
msi.reset();
|
||||
assert!(!msi.is_ready());
|
||||
// Oscillator re-seeds, so the summation index restarts at 0.
|
||||
assert_eq!(msi.update(section(1, 3)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![section(3, 1), section(1, 3), section(2, 2)];
|
||||
let mut a = McClellanSummationIndex::new();
|
||||
let mut b = McClellanSummationIndex::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,23 @@
|
||||
//! [`FAMILIES`]. Every public name is re-exported flat from this module and
|
||||
//! from the crate root for convenience.
|
||||
|
||||
// Internal shared building block for the chart- and harmonic-pattern detectors.
|
||||
// Declared `pub(crate)` (not `mod`) so it is excluded from the public-catalogue
|
||||
// counter (`grep -c '^mod '`) and re-exported nowhere.
|
||||
pub(crate) mod pattern_swing;
|
||||
|
||||
mod abandoned_baby;
|
||||
mod abcd;
|
||||
mod absolute_breadth_index;
|
||||
mod acceleration_bands;
|
||||
mod accelerator_oscillator;
|
||||
mod ad_oscillator;
|
||||
mod ad_volume_line;
|
||||
mod adaptive_cycle;
|
||||
mod adl;
|
||||
mod advance_block;
|
||||
mod advance_decline;
|
||||
mod advance_decline_ratio;
|
||||
mod adx;
|
||||
mod adxr;
|
||||
mod alligator;
|
||||
@@ -24,17 +34,24 @@ mod aroon_oscillator;
|
||||
mod atr;
|
||||
mod atr_bands;
|
||||
mod atr_trailing_stop;
|
||||
mod auto_fib;
|
||||
mod autocorrelation;
|
||||
mod average_daily_range;
|
||||
mod average_drawdown;
|
||||
mod avg_price;
|
||||
mod awesome_oscillator;
|
||||
mod awesome_oscillator_histogram;
|
||||
mod balance_of_power;
|
||||
mod bat;
|
||||
mod belt_hold;
|
||||
mod beta;
|
||||
mod beta_neutral_spread;
|
||||
mod bollinger;
|
||||
mod bollinger_bandwidth;
|
||||
mod breadth_thrust;
|
||||
mod breakaway;
|
||||
mod bullish_percent_index;
|
||||
mod butterfly;
|
||||
mod calendar_spread;
|
||||
mod calmar_ratio;
|
||||
mod camarilla_pivots;
|
||||
@@ -57,8 +74,13 @@ mod conditional_value_at_risk;
|
||||
mod connors_rsi;
|
||||
mod coppock;
|
||||
mod counterattack;
|
||||
mod crab;
|
||||
mod cumulative_volume_index;
|
||||
mod cup_and_handle;
|
||||
mod cvd;
|
||||
mod cybernetic_cycle;
|
||||
mod cypher;
|
||||
mod day_of_week_profile;
|
||||
mod decycler;
|
||||
mod decycler_oscillator;
|
||||
mod dema;
|
||||
@@ -66,11 +88,13 @@ mod demand_index;
|
||||
mod demark_pivots;
|
||||
mod depth_slope;
|
||||
mod detrended_std_dev;
|
||||
mod distance_ssd;
|
||||
mod doji;
|
||||
mod doji_star;
|
||||
mod donchian;
|
||||
mod donchian_stop;
|
||||
mod double_bollinger;
|
||||
mod double_top_bottom;
|
||||
mod downside_gap_three_methods;
|
||||
mod dpo;
|
||||
mod dragonfly_doji;
|
||||
@@ -87,8 +111,17 @@ mod evening_doji_star;
|
||||
mod evwma;
|
||||
mod falling_three_methods;
|
||||
mod fama;
|
||||
mod fib_arcs;
|
||||
mod fib_channel;
|
||||
mod fib_confluence;
|
||||
mod fib_extension;
|
||||
mod fib_fan;
|
||||
mod fib_projection;
|
||||
mod fib_retracement;
|
||||
mod fib_time_zones;
|
||||
mod fibonacci_pivots;
|
||||
mod fisher_transform;
|
||||
mod flag_pennant;
|
||||
mod footprint;
|
||||
mod force_index;
|
||||
mod fractal_chaos_bands;
|
||||
@@ -100,11 +133,16 @@ mod funding_rate_zscore;
|
||||
mod gain_loss_ratio;
|
||||
mod gap_side_by_side_white;
|
||||
mod garman_klass;
|
||||
mod gartley;
|
||||
mod golden_pocket;
|
||||
mod granger_causality;
|
||||
mod gravestone_doji;
|
||||
mod hammer;
|
||||
mod hanging_man;
|
||||
mod harami;
|
||||
mod head_and_shoulders;
|
||||
mod heikin_ashi;
|
||||
mod high_low_index;
|
||||
mod high_wave;
|
||||
mod hikkake;
|
||||
mod hikkake_modified;
|
||||
@@ -125,10 +163,12 @@ mod inertia;
|
||||
mod information_ratio;
|
||||
mod initial_balance;
|
||||
mod instantaneous_trendline;
|
||||
mod intraday_volatility_profile;
|
||||
mod inverse_fisher_transform;
|
||||
mod inverted_hammer;
|
||||
mod jma;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
mod kelly_criterion;
|
||||
mod keltner;
|
||||
@@ -161,6 +201,8 @@ mod mass_index;
|
||||
mod mat_hold;
|
||||
mod matching_low;
|
||||
mod max_drawdown;
|
||||
mod mcclellan_oscillator;
|
||||
mod mcclellan_summation_index;
|
||||
mod mcginley_dynamic;
|
||||
mod median_absolute_deviation;
|
||||
mod median_price;
|
||||
@@ -174,6 +216,7 @@ mod mom;
|
||||
mod morning_doji_star;
|
||||
mod morning_evening_star;
|
||||
mod natr;
|
||||
mod new_highs_new_lows;
|
||||
mod nvi;
|
||||
mod ob_imbalance_full;
|
||||
mod ob_imbalance_top1;
|
||||
@@ -186,11 +229,15 @@ mod omega_ratio;
|
||||
mod on_neck;
|
||||
mod opening_marubozu;
|
||||
mod opening_range;
|
||||
mod ou_half_life;
|
||||
mod overnight_gap;
|
||||
mod overnight_intraday_return;
|
||||
mod pain_index;
|
||||
mod pair_spread_zscore;
|
||||
mod pairwise_beta;
|
||||
mod parkinson;
|
||||
mod pearson_correlation;
|
||||
mod percent_above_ma;
|
||||
mod percent_b;
|
||||
mod percentage_trailing_stop;
|
||||
mod pgo;
|
||||
@@ -207,6 +254,7 @@ mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
mod recovery_factor;
|
||||
mod rectangle_range;
|
||||
mod relative_strength_ab;
|
||||
mod renko_bars;
|
||||
mod renko_trailing_stop;
|
||||
@@ -217,13 +265,20 @@ mod rocp;
|
||||
mod rocr;
|
||||
mod rocr100;
|
||||
mod rogers_satchell;
|
||||
mod rolling_correlation;
|
||||
mod rolling_covariance;
|
||||
mod roofing_filter;
|
||||
mod rsi;
|
||||
mod rvi;
|
||||
mod rvi_volatility;
|
||||
mod rwi;
|
||||
mod sar_ext;
|
||||
mod seasonal_z_score;
|
||||
mod separating_lines;
|
||||
mod session_high_low;
|
||||
mod session_range;
|
||||
mod session_vwap;
|
||||
mod shark;
|
||||
mod sharpe_ratio;
|
||||
mod shooting_star;
|
||||
mod short_line;
|
||||
@@ -236,6 +291,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;
|
||||
@@ -266,37 +323,47 @@ mod td_sequential;
|
||||
mod td_setup;
|
||||
mod tema;
|
||||
mod term_structure_basis;
|
||||
mod three_drives;
|
||||
mod three_inside;
|
||||
mod three_line_strike;
|
||||
mod three_outside;
|
||||
mod three_soldiers_or_crows;
|
||||
mod three_stars_in_south;
|
||||
mod thrusting;
|
||||
mod tick_index;
|
||||
mod tii;
|
||||
mod time_of_day_return_profile;
|
||||
mod tpo_profile;
|
||||
mod trade_imbalance;
|
||||
mod treynor_ratio;
|
||||
mod triangle;
|
||||
mod trima;
|
||||
mod trin;
|
||||
mod triple_top_bottom;
|
||||
mod trix;
|
||||
mod true_range;
|
||||
mod tsf;
|
||||
mod tsi;
|
||||
mod tsv;
|
||||
mod ttm_squeeze;
|
||||
mod turn_of_month;
|
||||
mod tweezer;
|
||||
mod two_crows;
|
||||
mod typical_price;
|
||||
mod ulcer_index;
|
||||
mod ultimate_oscillator;
|
||||
mod unique_three_river;
|
||||
mod up_down_volume_ratio;
|
||||
mod upside_gap_three_methods;
|
||||
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;
|
||||
mod volume_by_time_profile;
|
||||
mod volume_oscillator;
|
||||
mod volume_profile;
|
||||
mod vortex;
|
||||
@@ -306,6 +373,7 @@ mod vwap_stddev_bands;
|
||||
mod vwma;
|
||||
mod vzo;
|
||||
mod wave_trend;
|
||||
mod wedge;
|
||||
mod weighted_close;
|
||||
mod williams_fractals;
|
||||
mod williams_r;
|
||||
@@ -319,12 +387,17 @@ mod zig_zag;
|
||||
mod zlema;
|
||||
|
||||
pub use abandoned_baby::AbandonedBaby;
|
||||
pub use abcd::Abcd;
|
||||
pub use absolute_breadth_index::AbsoluteBreadthIndex;
|
||||
pub use acceleration_bands::{AccelerationBands, AccelerationBandsOutput};
|
||||
pub use accelerator_oscillator::AcceleratorOscillator;
|
||||
pub use ad_oscillator::AdOscillator;
|
||||
pub use ad_volume_line::AdVolumeLine;
|
||||
pub use adaptive_cycle::AdaptiveCycle;
|
||||
pub use adl::Adl;
|
||||
pub use advance_block::AdvanceBlock;
|
||||
pub use advance_decline::AdvanceDecline;
|
||||
pub use advance_decline_ratio::AdvanceDeclineRatio;
|
||||
pub use adx::{Adx, AdxOutput};
|
||||
pub use adxr::Adxr;
|
||||
pub use alligator::{Alligator, AlligatorOutput};
|
||||
@@ -338,17 +411,24 @@ pub use aroon_oscillator::AroonOscillator;
|
||||
pub use atr::Atr;
|
||||
pub use atr_bands::{AtrBands, AtrBandsOutput};
|
||||
pub use atr_trailing_stop::AtrTrailingStop;
|
||||
pub use auto_fib::{AutoFib, AutoFibOutput};
|
||||
pub use autocorrelation::Autocorrelation;
|
||||
pub use average_daily_range::AverageDailyRange;
|
||||
pub use average_drawdown::AverageDrawdown;
|
||||
pub use avg_price::AvgPrice;
|
||||
pub use awesome_oscillator::AwesomeOscillator;
|
||||
pub use awesome_oscillator_histogram::AwesomeOscillatorHistogram;
|
||||
pub use balance_of_power::BalanceOfPower;
|
||||
pub use bat::Bat;
|
||||
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 breadth_thrust::BreadthThrust;
|
||||
pub use breakaway::Breakaway;
|
||||
pub use bullish_percent_index::BullishPercentIndex;
|
||||
pub use butterfly::Butterfly;
|
||||
pub use calendar_spread::CalendarSpread;
|
||||
pub use calmar_ratio::CalmarRatio;
|
||||
pub use camarilla_pivots::{Camarilla, CamarillaPivotsOutput};
|
||||
@@ -371,8 +451,13 @@ pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
||||
pub use connors_rsi::ConnorsRsi;
|
||||
pub use coppock::Coppock;
|
||||
pub use counterattack::Counterattack;
|
||||
pub use crab::Crab;
|
||||
pub use cumulative_volume_index::CumulativeVolumeIndex;
|
||||
pub use cup_and_handle::CupAndHandle;
|
||||
pub use cvd::CumulativeVolumeDelta;
|
||||
pub use cybernetic_cycle::CyberneticCycle;
|
||||
pub use cypher::Cypher;
|
||||
pub use day_of_week_profile::{DayOfWeekProfile, DayOfWeekProfileOutput};
|
||||
pub use decycler::Decycler;
|
||||
pub use decycler_oscillator::DecyclerOscillator;
|
||||
pub use dema::Dema;
|
||||
@@ -380,11 +465,13 @@ 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};
|
||||
pub use donchian_stop::{DonchianStop, DonchianStopOutput};
|
||||
pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
|
||||
pub use double_top_bottom::DoubleTopBottom;
|
||||
pub use downside_gap_three_methods::DownsideGapThreeMethods;
|
||||
pub use dpo::Dpo;
|
||||
pub use dragonfly_doji::DragonflyDoji;
|
||||
@@ -401,8 +488,17 @@ pub use evening_doji_star::EveningDojiStar;
|
||||
pub use evwma::Evwma;
|
||||
pub use falling_three_methods::FallingThreeMethods;
|
||||
pub use fama::Fama;
|
||||
pub use fib_arcs::{FibArcs, FibArcsOutput};
|
||||
pub use fib_channel::{FibChannel, FibChannelOutput};
|
||||
pub use fib_confluence::{FibConfluence, FibConfluenceOutput};
|
||||
pub use fib_extension::{FibExtension, FibExtensionOutput};
|
||||
pub use fib_fan::{FibFan, FibFanOutput};
|
||||
pub use fib_projection::{FibProjection, FibProjectionOutput};
|
||||
pub use fib_retracement::{FibRetracement, FibRetracementOutput};
|
||||
pub use fib_time_zones::{FibTimeZones, FibTimeZonesOutput};
|
||||
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
|
||||
pub use fisher_transform::FisherTransform;
|
||||
pub use flag_pennant::FlagPennant;
|
||||
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
pub use force_index::ForceIndex;
|
||||
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
@@ -414,11 +510,16 @@ 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 gartley::Gartley;
|
||||
pub use golden_pocket::{GoldenPocket, GoldenPocketOutput};
|
||||
pub use granger_causality::GrangerCausality;
|
||||
pub use gravestone_doji::GravestoneDoji;
|
||||
pub use hammer::Hammer;
|
||||
pub use hanging_man::HangingMan;
|
||||
pub use harami::Harami;
|
||||
pub use head_and_shoulders::HeadAndShoulders;
|
||||
pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use high_low_index::HighLowIndex;
|
||||
pub use high_wave::HighWave;
|
||||
pub use hikkake::Hikkake;
|
||||
pub use hikkake_modified::HikkakeModified;
|
||||
@@ -439,10 +540,12 @@ pub use inertia::Inertia;
|
||||
pub use information_ratio::InformationRatio;
|
||||
pub use initial_balance::{InitialBalance, InitialBalanceOutput};
|
||||
pub use instantaneous_trendline::InstantaneousTrendline;
|
||||
pub use intraday_volatility_profile::{IntradayVolatilityProfile, IntradayVolatilityProfileOutput};
|
||||
pub use inverse_fisher_transform::InverseFisherTransform;
|
||||
pub use inverted_hammer::InvertedHammer;
|
||||
pub use jma::Jma;
|
||||
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};
|
||||
@@ -475,6 +578,8 @@ pub use mass_index::MassIndex;
|
||||
pub use mat_hold::MatHold;
|
||||
pub use matching_low::MatchingLow;
|
||||
pub use max_drawdown::MaxDrawdown;
|
||||
pub use mcclellan_oscillator::McClellanOscillator;
|
||||
pub use mcclellan_summation_index::McClellanSummationIndex;
|
||||
pub use mcginley_dynamic::McGinleyDynamic;
|
||||
pub use median_absolute_deviation::MedianAbsoluteDeviation;
|
||||
pub use median_price::MedianPrice;
|
||||
@@ -488,6 +593,7 @@ pub use mom::Mom;
|
||||
pub use morning_doji_star::MorningDojiStar;
|
||||
pub use morning_evening_star::MorningEveningStar;
|
||||
pub use natr::Natr;
|
||||
pub use new_highs_new_lows::NewHighsNewLows;
|
||||
pub use nvi::Nvi;
|
||||
pub use ob_imbalance_full::OrderBookImbalanceFull;
|
||||
pub use ob_imbalance_top1::OrderBookImbalanceTop1;
|
||||
@@ -500,11 +606,15 @@ 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 overnight_gap::OvernightGap;
|
||||
pub use overnight_intraday_return::{OvernightIntradayReturn, OvernightIntradayReturnOutput};
|
||||
pub use pain_index::PainIndex;
|
||||
pub use pair_spread_zscore::PairSpreadZScore;
|
||||
pub use pairwise_beta::PairwiseBeta;
|
||||
pub use parkinson::ParkinsonVolatility;
|
||||
pub use pearson_correlation::PearsonCorrelation;
|
||||
pub use percent_above_ma::PercentAboveMa;
|
||||
pub use percent_b::PercentB;
|
||||
pub use percentage_trailing_stop::PercentageTrailingStop;
|
||||
pub use pgo::Pgo;
|
||||
@@ -521,6 +631,7 @@ pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
pub use recovery_factor::RecoveryFactor;
|
||||
pub use rectangle_range::RectangleRange;
|
||||
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
|
||||
pub use renko_bars::{RenkoBars, RenkoBrick};
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
@@ -531,13 +642,20 @@ 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;
|
||||
pub use rvi_volatility::RviVolatility;
|
||||
pub use rwi::{Rwi, RwiOutput};
|
||||
pub use sar_ext::SarExt;
|
||||
pub use seasonal_z_score::SeasonalZScore;
|
||||
pub use separating_lines::SeparatingLines;
|
||||
pub use session_high_low::{SessionHighLow, SessionHighLowOutput};
|
||||
pub use session_range::{SessionRange, SessionRangeOutput};
|
||||
pub use session_vwap::SessionVwap;
|
||||
pub use shark::Shark;
|
||||
pub use sharpe_ratio::SharpeRatio;
|
||||
pub use shooting_star::ShootingStar;
|
||||
pub use short_line::ShortLine;
|
||||
@@ -550,6 +668,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};
|
||||
@@ -580,37 +700,47 @@ pub use td_sequential::{TdSequential, TdSequentialOutput};
|
||||
pub use td_setup::TdSetup;
|
||||
pub use tema::Tema;
|
||||
pub use term_structure_basis::TermStructureBasis;
|
||||
pub use three_drives::ThreeDrives;
|
||||
pub use three_inside::ThreeInside;
|
||||
pub use three_line_strike::ThreeLineStrike;
|
||||
pub use three_outside::ThreeOutside;
|
||||
pub use three_soldiers_or_crows::ThreeSoldiersOrCrows;
|
||||
pub use three_stars_in_south::ThreeStarsInSouth;
|
||||
pub use thrusting::Thrusting;
|
||||
pub use tick_index::TickIndex;
|
||||
pub use tii::Tii;
|
||||
pub use time_of_day_return_profile::{TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput};
|
||||
pub use tpo_profile::{TpoProfile, TpoProfileOutput};
|
||||
pub use trade_imbalance::TradeImbalance;
|
||||
pub use treynor_ratio::TreynorRatio;
|
||||
pub use triangle::Triangle;
|
||||
pub use trima::Trima;
|
||||
pub use trin::Trin;
|
||||
pub use triple_top_bottom::TripleTopBottom;
|
||||
pub use trix::Trix;
|
||||
pub use true_range::TrueRange;
|
||||
pub use tsf::Tsf;
|
||||
pub use tsi::Tsi;
|
||||
pub use tsv::Tsv;
|
||||
pub use ttm_squeeze::{TtmSqueeze, TtmSqueezeOutput};
|
||||
pub use turn_of_month::TurnOfMonth;
|
||||
pub use tweezer::Tweezer;
|
||||
pub use two_crows::TwoCrows;
|
||||
pub use typical_price::TypicalPrice;
|
||||
pub use ulcer_index::UlcerIndex;
|
||||
pub use ultimate_oscillator::UltimateOscillator;
|
||||
pub use unique_three_river::UniqueThreeRiver;
|
||||
pub use up_down_volume_ratio::UpDownVolumeRatio;
|
||||
pub use upside_gap_three_methods::UpsideGapThreeMethods;
|
||||
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;
|
||||
pub use volume_by_time_profile::{VolumeByTimeProfile, VolumeByTimeProfileOutput};
|
||||
pub use volume_oscillator::VolumeOscillator;
|
||||
pub use volume_profile::{VolumeProfile, VolumeProfileOutput};
|
||||
pub use vortex::{Vortex, VortexOutput};
|
||||
@@ -620,6 +750,7 @@ pub use vwap_stddev_bands::{VwapStdDevBands, VwapStdDevBandsOutput};
|
||||
pub use vwma::Vwma;
|
||||
pub use vzo::Vzo;
|
||||
pub use wave_trend::{WaveTrend, WaveTrendOutput};
|
||||
pub use wedge::Wedge;
|
||||
pub use weighted_close::WeightedClose;
|
||||
pub use williams_fractals::{WilliamsFractals, WilliamsFractalsOutput};
|
||||
pub use williams_r::WilliamsR;
|
||||
@@ -836,11 +967,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 +1179,84 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Alt-Chart Bars",
|
||||
&["RenkoBars", "KagiBars", "PointAndFigureBars"],
|
||||
),
|
||||
(
|
||||
"Market Breadth",
|
||||
&[
|
||||
"AdvanceDecline",
|
||||
"AdvanceDeclineRatio",
|
||||
"AdVolumeLine",
|
||||
"McClellanOscillator",
|
||||
"McClellanSummationIndex",
|
||||
"Trin",
|
||||
"BreadthThrust",
|
||||
"NewHighsNewLows",
|
||||
"HighLowIndex",
|
||||
"PercentAboveMa",
|
||||
"UpDownVolumeRatio",
|
||||
"BullishPercentIndex",
|
||||
"CumulativeVolumeIndex",
|
||||
"AbsoluteBreadthIndex",
|
||||
"TickIndex",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Seasonality & Session",
|
||||
&[
|
||||
"SessionVwap",
|
||||
"SessionHighLow",
|
||||
"SessionRange",
|
||||
"AverageDailyRange",
|
||||
"OvernightGap",
|
||||
"OvernightIntradayReturn",
|
||||
"TurnOfMonth",
|
||||
"SeasonalZScore",
|
||||
"TimeOfDayReturnProfile",
|
||||
"DayOfWeekProfile",
|
||||
"IntradayVolatilityProfile",
|
||||
"VolumeByTimeProfile",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Chart Patterns",
|
||||
&[
|
||||
"DoubleTopBottom",
|
||||
"TripleTopBottom",
|
||||
"HeadAndShoulders",
|
||||
"Triangle",
|
||||
"Wedge",
|
||||
"FlagPennant",
|
||||
"RectangleRange",
|
||||
"CupAndHandle",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Harmonic Patterns",
|
||||
&[
|
||||
"Abcd",
|
||||
"Gartley",
|
||||
"Butterfly",
|
||||
"Bat",
|
||||
"Crab",
|
||||
"Shark",
|
||||
"Cypher",
|
||||
"ThreeDrives",
|
||||
],
|
||||
),
|
||||
(
|
||||
"Fibonacci",
|
||||
&[
|
||||
"FibRetracement",
|
||||
"FibExtension",
|
||||
"FibProjection",
|
||||
"AutoFib",
|
||||
"GoldenPocket",
|
||||
"FibConfluence",
|
||||
"FibFan",
|
||||
"FibArcs",
|
||||
"FibChannel",
|
||||
"FibTimeZones",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1061,6 +1285,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, 377, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! New Highs − New Lows — net count of fresh period extremes across a universe.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// New Highs − New Lows — the number of symbols printing a new period high minus
|
||||
/// the number printing a new period low across a universe.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the value is `new_highs - new_lows`, read from the
|
||||
/// per-symbol `new_high` / `new_low` flags. A persistently positive reading means
|
||||
/// fresh leadership is broad (many names making new highs); a negative reading
|
||||
/// during an index advance is a classic breadth divergence warning that the rally
|
||||
/// is narrowing.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64`, `warmup_period == 1`.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CrossSection, Indicator, Member, NewHighsNewLows};
|
||||
///
|
||||
/// let mut nhnl = NewHighsNewLows::new();
|
||||
/// // 2 new highs, 1 new low -> net +1.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::new(1.0, 10.0, true, false),
|
||||
/// Member::new(1.0, 10.0, true, false),
|
||||
/// Member::new(-1.0, 10.0, false, true),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(nhnl.update(tick), Some(1.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NewHighsNewLows {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl NewHighsNewLows {
|
||||
/// Construct a new New Highs − New Lows indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for NewHighsNewLows {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let net = section.new_highs() as f64 - section.new_lows() as f64;
|
||||
self.has_emitted = true;
|
||||
Some(net)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"NewHighsNewLows"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn flags(highs: usize, lows: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..highs {
|
||||
members.push(Member::new(1.0, 10.0, true, false));
|
||||
}
|
||||
for _ in 0..lows {
|
||||
members.push(Member::new(-1.0, 10.0, false, true));
|
||||
}
|
||||
members.push(Member::new(0.0, 10.0, false, false));
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let nhnl = NewHighsNewLows::new();
|
||||
assert_eq!(nhnl.name(), "NewHighsNewLows");
|
||||
assert_eq!(nhnl.warmup_period(), 1);
|
||||
assert!(!nhnl.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_net_extremes() {
|
||||
let mut nhnl = NewHighsNewLows::new();
|
||||
assert_eq!(nhnl.update(flags(5, 2)), Some(3.0));
|
||||
assert!(nhnl.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_lows_than_highs_is_negative() {
|
||||
let mut nhnl = NewHighsNewLows::new();
|
||||
assert_eq!(nhnl.update(flags(1, 4)), Some(-3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_extremes_yields_zero() {
|
||||
let mut nhnl = NewHighsNewLows::new();
|
||||
assert_eq!(nhnl.update(flags(0, 0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut nhnl = NewHighsNewLows::new();
|
||||
nhnl.update(flags(3, 1));
|
||||
assert!(nhnl.is_ready());
|
||||
nhnl.reset();
|
||||
assert!(!nhnl.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![flags(5, 2), flags(1, 4), flags(0, 0)];
|
||||
let mut a = NewHighsNewLows::new();
|
||||
let mut b = NewHighsNewLows::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,191 @@
|
||||
//! Overnight Gap — the return from the previous session's close to the current
|
||||
//! session's open, detected automatically at each day boundary.
|
||||
|
||||
use crate::calendar::civil_from_timestamp;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Close-to-open overnight gap as a simple return.
|
||||
///
|
||||
/// At every local day boundary the indicator computes
|
||||
/// `open / previous_close - 1`, where `previous_close` is the close of the last
|
||||
/// bar of the prior session and `open` is the open of the first bar of the new
|
||||
/// session. The value holds for the rest of the session until the next boundary.
|
||||
/// The boundary is the wall-clock day of [`Candle::timestamp`](crate::Candle)
|
||||
/// shifted by `utc_offset_minutes`. The first session yields no gap (there is no
|
||||
/// prior close to compare against).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, OvernightGap};
|
||||
///
|
||||
/// let hour = 3_600_000;
|
||||
/// let mut gap = OvernightGap::new(0);
|
||||
/// // Day 1 closes at 100.
|
||||
/// assert!(gap.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
|
||||
/// // Day 2 opens at 105 -> gap = 105 / 100 - 1 = 0.05.
|
||||
/// let g = gap.update(Candle::new(105.0, 106.0, 104.0, 105.5, 1.0, 24 * hour).unwrap()).unwrap();
|
||||
/// assert!((g - 0.05).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OvernightGap {
|
||||
utc_offset_minutes: i32,
|
||||
day_key: Option<(i64, u32, u32)>,
|
||||
last_close: Option<f64>,
|
||||
gap: Option<f64>,
|
||||
}
|
||||
|
||||
impl OvernightGap {
|
||||
/// Construct an Overnight Gap indicator with the given UTC offset (minutes).
|
||||
pub const fn new(utc_offset_minutes: i32) -> Self {
|
||||
Self {
|
||||
utc_offset_minutes,
|
||||
day_key: None,
|
||||
last_close: None,
|
||||
gap: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured UTC offset in minutes.
|
||||
pub const fn utc_offset_minutes(&self) -> i32 {
|
||||
self.utc_offset_minutes
|
||||
}
|
||||
|
||||
/// Most recent overnight gap if at least one day boundary has been crossed.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.gap
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OvernightGap {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
|
||||
let key = (civil.year, civil.month, civil.day);
|
||||
if self.day_key != Some(key) {
|
||||
if let Some(prev_close) = self.last_close {
|
||||
self.gap = Some(if prev_close == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
candle.open / prev_close - 1.0
|
||||
});
|
||||
}
|
||||
self.day_key = Some(key);
|
||||
}
|
||||
self.last_close = Some(candle.close);
|
||||
self.gap
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.day_key = None;
|
||||
self.last_close = None;
|
||||
self.gap = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.gap.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OvernightGap"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
|
||||
fn c(open: f64, close: f64, ts: i64) -> Candle {
|
||||
let high = open.max(close);
|
||||
let low = open.min(close);
|
||||
Candle::new(open, high, low, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_accessors() {
|
||||
let gap = OvernightGap::new(330);
|
||||
assert_eq!(gap.utc_offset_minutes(), 330);
|
||||
assert_eq!(gap.name(), "OvernightGap");
|
||||
assert_eq!(gap.warmup_period(), 2);
|
||||
assert!(!gap.is_ready());
|
||||
assert!(gap.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_session_has_no_gap() {
|
||||
let mut gap = OvernightGap::new(0);
|
||||
assert!(gap.update(c(99.0, 100.0, 0)).is_none());
|
||||
// Same day, still no gap.
|
||||
assert!(gap.update(c(100.0, 101.0, HOUR)).is_none());
|
||||
assert!(!gap.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computes_gap_at_day_boundary() {
|
||||
let mut gap = OvernightGap::new(0);
|
||||
gap.update(c(99.0, 100.0, 0)); // day 1 closes 100
|
||||
let g = gap.update(c(105.0, 105.5, 24 * HOUR)).unwrap();
|
||||
assert_relative_eq!(g, 0.05);
|
||||
assert!(gap.is_ready());
|
||||
// Holds for the rest of the session.
|
||||
let same = gap.update(c(106.0, 107.0, 25 * HOUR)).unwrap();
|
||||
assert_relative_eq!(same, 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_gap_down() {
|
||||
let mut gap = OvernightGap::new(0);
|
||||
gap.update(c(99.0, 100.0, 0));
|
||||
let g = gap.update(c(90.0, 91.0, 24 * HOUR)).unwrap();
|
||||
assert_relative_eq!(g, -0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_prev_close_yields_zero_gap() {
|
||||
let mut gap = OvernightGap::new(0);
|
||||
gap.update(c(0.0, 0.0, 0)); // degenerate day 1 closing at 0
|
||||
let g = gap.update(c(5.0, 6.0, 24 * HOUR)).unwrap();
|
||||
assert_relative_eq!(g, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut gap = OvernightGap::new(0);
|
||||
gap.update(c(99.0, 100.0, 0));
|
||||
gap.update(c(105.0, 105.5, 24 * HOUR));
|
||||
gap.reset();
|
||||
assert!(!gap.is_ready());
|
||||
assert!(gap.value().is_none());
|
||||
assert!(gap.update(c(10.0, 11.0, 48 * HOUR)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| {
|
||||
c(
|
||||
100.0 + f64::from(i % 7),
|
||||
100.0 + f64::from(i % 5),
|
||||
i64::from(i) * 6 * HOUR,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = OvernightGap::new(0);
|
||||
let mut b = OvernightGap::new(0);
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Overnight vs. Intraday Return — decomposes a session's total return into its
|
||||
//! overnight (close-to-open) and intraday (open-to-close) components.
|
||||
|
||||
use crate::calendar::civil_from_timestamp;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// The two return components of the current session.
|
||||
///
|
||||
/// `overnight` is fixed at the session open (`open / previous_close - 1`);
|
||||
/// `intraday` updates with every bar (`close / open - 1`). Compounding the two —
|
||||
/// `(1 + overnight)(1 + intraday) - 1` — reconstructs the full previous-close to
|
||||
/// latest-close return.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct OvernightIntradayReturnOutput {
|
||||
/// Close-to-open return carried into the session.
|
||||
pub overnight: f64,
|
||||
/// Open-to-latest-close return accumulated within the session.
|
||||
pub intraday: f64,
|
||||
}
|
||||
|
||||
/// Overnight / intraday return decomposition, re-anchored at each local day
|
||||
/// boundary of [`Candle::timestamp`](crate::Candle) shifted by
|
||||
/// `utc_offset_minutes`.
|
||||
///
|
||||
/// The first session yields no output (there is no prior close to anchor the
|
||||
/// overnight leg); from the second session onward every bar reports both
|
||||
/// components.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, OvernightIntradayReturn};
|
||||
///
|
||||
/// let hour = 3_600_000;
|
||||
/// let mut oi = OvernightIntradayReturn::new(0);
|
||||
/// // Day 1 closes at 100.
|
||||
/// assert!(oi.update(Candle::new(99.0, 101.0, 98.0, 100.0, 1.0, 0).unwrap()).is_none());
|
||||
/// // Day 2 opens 110 (overnight +10%), closes 121 (intraday +10%).
|
||||
/// let v = oi.update(Candle::new(110.0, 122.0, 109.0, 121.0, 1.0, 24 * hour).unwrap()).unwrap();
|
||||
/// assert!((v.overnight - 0.10).abs() < 1e-9);
|
||||
/// assert!((v.intraday - 0.10).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OvernightIntradayReturn {
|
||||
utc_offset_minutes: i32,
|
||||
day_key: Option<(i64, u32, u32)>,
|
||||
last_close: Option<f64>,
|
||||
today_open: f64,
|
||||
overnight: Option<f64>,
|
||||
last: Option<OvernightIntradayReturnOutput>,
|
||||
}
|
||||
|
||||
impl OvernightIntradayReturn {
|
||||
/// Construct the indicator with the given UTC offset (minutes).
|
||||
pub const fn new(utc_offset_minutes: i32) -> Self {
|
||||
Self {
|
||||
utc_offset_minutes,
|
||||
day_key: None,
|
||||
last_close: None,
|
||||
today_open: 0.0,
|
||||
overnight: None,
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured UTC offset in minutes.
|
||||
pub const fn utc_offset_minutes(&self) -> i32 {
|
||||
self.utc_offset_minutes
|
||||
}
|
||||
|
||||
/// Most recent decomposition if at least one day boundary has been crossed.
|
||||
pub const fn value(&self) -> Option<OvernightIntradayReturnOutput> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OvernightIntradayReturn {
|
||||
type Input = Candle;
|
||||
type Output = OvernightIntradayReturnOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<OvernightIntradayReturnOutput> {
|
||||
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
|
||||
let key = (civil.year, civil.month, civil.day);
|
||||
if self.day_key != Some(key) {
|
||||
if let Some(prev_close) = self.last_close {
|
||||
self.overnight = Some(if prev_close == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
candle.open / prev_close - 1.0
|
||||
});
|
||||
}
|
||||
self.today_open = candle.open;
|
||||
self.day_key = Some(key);
|
||||
}
|
||||
self.last_close = Some(candle.close);
|
||||
let overnight = self.overnight?;
|
||||
let intraday = if self.today_open == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
candle.close / self.today_open - 1.0
|
||||
};
|
||||
let out = OvernightIntradayReturnOutput {
|
||||
overnight,
|
||||
intraday,
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.day_key = None;
|
||||
self.last_close = None;
|
||||
self.today_open = 0.0;
|
||||
self.overnight = None;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OvernightIntradayReturn"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const HOUR: i64 = 3_600_000;
|
||||
|
||||
fn c(open: f64, close: f64, ts: i64) -> Candle {
|
||||
let high = open.max(close) + 1.0;
|
||||
let low = open.min(close) - 1.0;
|
||||
Candle::new(open, high, low.max(0.0), close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_accessors() {
|
||||
let oi = OvernightIntradayReturn::new(-300);
|
||||
assert_eq!(oi.utc_offset_minutes(), -300);
|
||||
assert_eq!(oi.name(), "OvernightIntradayReturn");
|
||||
assert_eq!(oi.warmup_period(), 2);
|
||||
assert!(!oi.is_ready());
|
||||
assert!(oi.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_session_yields_none() {
|
||||
let mut oi = OvernightIntradayReturn::new(0);
|
||||
assert!(oi.update(c(99.0, 100.0, 0)).is_none());
|
||||
assert!(oi.update(c(100.0, 102.0, HOUR)).is_none());
|
||||
assert!(!oi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposes_overnight_and_intraday() {
|
||||
let mut oi = OvernightIntradayReturn::new(0);
|
||||
oi.update(c(99.0, 100.0, 0)); // day 1 close 100
|
||||
let v = oi.update(c(110.0, 121.0, 24 * HOUR)).unwrap();
|
||||
assert_relative_eq!(v.overnight, 0.10);
|
||||
assert_relative_eq!(v.intraday, 0.10);
|
||||
assert!(oi.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intraday_updates_through_the_session() {
|
||||
let mut oi = OvernightIntradayReturn::new(0);
|
||||
oi.update(c(99.0, 100.0, 0));
|
||||
oi.update(c(110.0, 110.0, 24 * HOUR)); // open 110, close 110 -> intraday 0
|
||||
let later = oi.update(c(111.0, 132.0, 25 * HOUR)).unwrap();
|
||||
assert_relative_eq!(later.overnight, 0.10); // fixed at open
|
||||
assert_relative_eq!(later.intraday, 0.20); // 132 / 110 - 1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_anchors_yield_zero_components() {
|
||||
let mut oi = OvernightIntradayReturn::new(0);
|
||||
oi.update(c(1.0, 0.0, 0)); // day 1 closes at 0
|
||||
// Day 2 opens at 0: overnight uses zero prev_close -> 0; intraday uses
|
||||
// zero today_open -> 0.
|
||||
let candle = Candle::new(0.0, 5.0, 0.0, 4.0, 1.0, 24 * HOUR).unwrap();
|
||||
let v = oi.update(candle).unwrap();
|
||||
assert_relative_eq!(v.overnight, 0.0);
|
||||
assert_relative_eq!(v.intraday, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut oi = OvernightIntradayReturn::new(0);
|
||||
oi.update(c(99.0, 100.0, 0));
|
||||
oi.update(c(110.0, 121.0, 24 * HOUR));
|
||||
oi.reset();
|
||||
assert!(!oi.is_ready());
|
||||
assert!(oi.value().is_none());
|
||||
assert!(oi.update(c(50.0, 55.0, 48 * HOUR)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..48)
|
||||
.map(|i| {
|
||||
c(
|
||||
100.0 + f64::from(i % 6),
|
||||
100.0 + f64::from(i % 4),
|
||||
i64::from(i) * 8 * HOUR,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = OvernightIntradayReturn::new(0);
|
||||
let mut b = OvernightIntradayReturn::new(0);
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
//! Internal swing-pivot tracker shared by the chart-pattern and harmonic-pattern
|
||||
//! detectors. Not a public indicator — it carries no `Indicator` impl and is
|
||||
//! re-exported nowhere, so it is excluded from the public catalogue counter.
|
||||
//!
|
||||
//! The tracker mirrors [`crate::indicators::ZigZag`]'s non-repainting
|
||||
//! percent-threshold confirmation logic, but differs in two ways that make it a
|
||||
//! reusable building block rather than a standalone indicator:
|
||||
//!
|
||||
//! * It is **parameter-free at the call site** — the reversal threshold is baked
|
||||
//! in by each detector as a compile-time constant, so construction is
|
||||
//! infallible (`const fn new`) and there is no user-facing validation branch.
|
||||
//! * It **accumulates a bounded history** of the most recently confirmed pivots
|
||||
//! (capped at `cap`), so a detector can inspect the last few swings to match a
|
||||
//! geometric template (double top, head-and-shoulders, the XABCD legs of a
|
||||
//! harmonic pattern, …).
|
||||
|
||||
use crate::ohlcv::Candle;
|
||||
|
||||
/// Default fractional reversal threshold for pattern swing detection (5%). A
|
||||
/// pivot is confirmed once price reverses by this fraction away from the running
|
||||
/// extreme. Baked in so the pattern detectors stay parameter-free, mirroring the
|
||||
/// candlestick-pattern family's fixed geometric thresholds.
|
||||
pub(crate) const SWING_THRESHOLD: f64 = 0.05;
|
||||
|
||||
/// Default relative tolerance for two swing levels to count as "equal" (3%) —
|
||||
/// the twin tops of a double top, the shoulders of a head-and-shoulders, the
|
||||
/// flat boundary of a rectangle.
|
||||
pub(crate) const LEVEL_TOLERANCE: f64 = 0.03;
|
||||
|
||||
/// A confirmed swing pivot: the extreme price the swing turned from, its
|
||||
/// direction (`+1.0` for a swing high, `-1.0` for a swing low) and the bar index
|
||||
/// at which that extreme occurred.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Pivot {
|
||||
/// Price of the confirmed swing extreme.
|
||||
pub price: f64,
|
||||
/// `+1.0` if the pivot is a swing high, `-1.0` if it is a swing low.
|
||||
pub direction: f64,
|
||||
/// Zero-based index of the candle at which the swing extreme occurred (not
|
||||
/// the later bar that confirmed it). Used by the geometric Fibonacci tools
|
||||
/// (fan, arcs, channel, time zones) to place trendlines and time offsets.
|
||||
pub bar: usize,
|
||||
}
|
||||
|
||||
/// Non-repainting percent-threshold swing tracker with a bounded pivot history.
|
||||
///
|
||||
/// Feeding a candle returns `true` exactly on the bar where a new pivot is
|
||||
/// confirmed (price has reversed by the configured fraction away from the
|
||||
/// running extreme); the newly confirmed pivot is appended to [`pivots`] and the
|
||||
/// oldest is dropped once the cap is exceeded. Bars that merely extend the
|
||||
/// running extreme, or that move less than the threshold, return `false`.
|
||||
///
|
||||
/// [`pivots`]: SwingTracker::pivots
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SwingTracker {
|
||||
threshold: f64,
|
||||
cap: usize,
|
||||
/// Number of candles fed so far; the current bar index is `bars_seen - 1`.
|
||||
bars_seen: usize,
|
||||
state: Option<State>,
|
||||
pivots: Vec<Pivot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct State {
|
||||
/// `+1.0` while tracking a candidate high (uptrend), `-1.0` while tracking a
|
||||
/// candidate low (downtrend).
|
||||
direction: f64,
|
||||
/// The running candidate extreme price.
|
||||
extreme: f64,
|
||||
/// Bar index at which the running extreme was last set.
|
||||
extreme_bar: usize,
|
||||
}
|
||||
|
||||
impl SwingTracker {
|
||||
/// Construct a tracker with a fractional reversal `threshold` (e.g. `0.05`
|
||||
/// for 5%) and a pivot history capped at `cap` entries.
|
||||
///
|
||||
/// The threshold is supplied by the detectors as a compile-time constant in
|
||||
/// `(0, 1)`, so no runtime validation is performed — an out-of-range
|
||||
/// constant would be a library bug caught by the unit tests, not invalid
|
||||
/// caller input.
|
||||
pub(crate) const fn new(threshold: f64, cap: usize) -> Self {
|
||||
Self {
|
||||
threshold,
|
||||
cap,
|
||||
bars_seen: 0,
|
||||
state: None,
|
||||
pivots: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one candle. Returns `true` when a new pivot was confirmed this bar.
|
||||
pub(crate) fn update(&mut self, candle: Candle) -> bool {
|
||||
let bar = self.bars_seen;
|
||||
self.bars_seen += 1;
|
||||
let Some(s) = self.state else {
|
||||
// Bootstrap: seed an uptrend tracking the first candle's high.
|
||||
self.state = Some(State {
|
||||
direction: 1.0,
|
||||
extreme: candle.high,
|
||||
extreme_bar: bar,
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
if s.direction > 0.0 {
|
||||
if candle.high > s.extreme {
|
||||
// Extend the candidate high.
|
||||
self.state = Some(State {
|
||||
direction: 1.0,
|
||||
extreme: candle.high,
|
||||
extreme_bar: bar,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if candle.low <= s.extreme * (1.0 - self.threshold) {
|
||||
// Confirm the swing high; flip to tracking this bar's low.
|
||||
self.push(Pivot {
|
||||
price: s.extreme,
|
||||
direction: 1.0,
|
||||
bar: s.extreme_bar,
|
||||
});
|
||||
self.state = Some(State {
|
||||
direction: -1.0,
|
||||
extreme: candle.low,
|
||||
extreme_bar: bar,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
false
|
||||
} else {
|
||||
if candle.low < s.extreme {
|
||||
// Extend the candidate low.
|
||||
self.state = Some(State {
|
||||
direction: -1.0,
|
||||
extreme: candle.low,
|
||||
extreme_bar: bar,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if candle.high >= s.extreme * (1.0 + self.threshold) {
|
||||
// Confirm the swing low; flip to tracking this bar's high.
|
||||
self.push(Pivot {
|
||||
price: s.extreme,
|
||||
direction: -1.0,
|
||||
bar: s.extreme_bar,
|
||||
});
|
||||
self.state = Some(State {
|
||||
direction: 1.0,
|
||||
extreme: candle.high,
|
||||
extreme_bar: bar,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero-based index of the most recently fed candle. Saturates at `0` before
|
||||
/// any candle has been seen.
|
||||
pub(crate) fn current_bar(&self) -> usize {
|
||||
self.bars_seen.saturating_sub(1)
|
||||
}
|
||||
|
||||
fn push(&mut self, pivot: Pivot) {
|
||||
self.pivots.push(pivot);
|
||||
if self.pivots.len() > self.cap {
|
||||
self.pivots.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmed pivots in chronological order (oldest first, newest last).
|
||||
pub(crate) fn pivots(&self) -> &[Pivot] {
|
||||
&self.pivots
|
||||
}
|
||||
|
||||
/// Clear all state, returning the tracker to its just-constructed condition.
|
||||
pub(crate) fn reset(&mut self) {
|
||||
self.bars_seen = 0;
|
||||
self.state = None;
|
||||
self.pivots.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The two most recent swing highs and lows from the last four (strictly
|
||||
/// alternating) pivots, returned as `(high_old, high_new, low_old, low_new)`.
|
||||
/// Used by the converging/diverging trendline patterns (triangle, wedge,
|
||||
/// rectangle). The slice must hold at least four pivots.
|
||||
pub(crate) fn recent_legs(pivots: &[Pivot]) -> (f64, f64, f64, f64) {
|
||||
let n = pivots.len();
|
||||
if pivots[n - 1].direction > 0.0 {
|
||||
// … low_old, high_old, low_new, high_new (newest is a high)
|
||||
(
|
||||
pivots[n - 3].price,
|
||||
pivots[n - 1].price,
|
||||
pivots[n - 4].price,
|
||||
pivots[n - 2].price,
|
||||
)
|
||||
} else {
|
||||
// … high_old, low_old, high_new, low_new (newest is a low)
|
||||
(
|
||||
pivots[n - 4].price,
|
||||
pivots[n - 2].price,
|
||||
pivots[n - 3].price,
|
||||
pivots[n - 1].price,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative-tolerance equality: `true` when `a` and `b` are within `tol`
|
||||
/// (a fraction) of the larger magnitude. Used to decide whether two swing
|
||||
/// levels (the twin highs of a double top, the shoulders of a head-and-shoulders,
|
||||
/// a harmonic Fibonacci ratio) count as "the same".
|
||||
pub(crate) fn approx_equal(a: f64, b: f64, tol: f64) -> bool {
|
||||
let scale = a.abs().max(b.abs()).max(f64::MIN_POSITIVE);
|
||||
(a - b).abs() <= tol * scale
|
||||
}
|
||||
|
||||
/// The five most recent pivots interpreted as the X-A-B-C-D points of a harmonic
|
||||
/// pattern, with the terminal direction. The slice must hold at least five
|
||||
/// pivots. Each detector derives the leg lengths and Fibonacci ratios it needs
|
||||
/// from these five prices.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct Xabcd {
|
||||
pub x: f64,
|
||||
pub a: f64,
|
||||
pub b: f64,
|
||||
pub c: f64,
|
||||
pub d: f64,
|
||||
/// `true` when the terminal point D is a swing low (a bullish, buy-side
|
||||
/// completion); `false` when D is a swing high (bearish).
|
||||
pub bullish: bool,
|
||||
}
|
||||
|
||||
/// Read the last five pivots as an [`Xabcd`]. Pivots are guaranteed nonzero-leg
|
||||
/// (the swing tracker only confirms moves of at least the threshold), so the
|
||||
/// leg-ratio divisions in the detectors never divide by zero.
|
||||
pub(crate) fn xabcd(pivots: &[Pivot]) -> Xabcd {
|
||||
let n = pivots.len();
|
||||
Xabcd {
|
||||
x: pivots[n - 5].price,
|
||||
a: pivots[n - 4].price,
|
||||
b: pivots[n - 3].price,
|
||||
c: pivots[n - 2].price,
|
||||
d: pivots[n - 1].price,
|
||||
bullish: pivots[n - 1].direction < 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when every `(value, low, high)` triple satisfies `low <= value <= high`.
|
||||
/// Harmonic detectors express their Fibonacci windows as a list of these triples;
|
||||
/// evaluating them in one expression keeps the per-triple comparison on a single
|
||||
/// line (no multi-line `&&` coverage gaps).
|
||||
pub(crate) fn ratios_in(checks: &[(f64, f64, f64)]) -> bool {
|
||||
checks
|
||||
.iter()
|
||||
.all(|&(value, low, high)| value >= low && value <= high)
|
||||
}
|
||||
|
||||
/// Build a candle sequence that drives a `SwingTracker` (or any detector built
|
||||
/// on one) to confirm exactly the given alternating pivot prices, in order.
|
||||
///
|
||||
/// `pivots` must start with a **high** and strictly alternate high/low, with
|
||||
/// each consecutive pair differing by at least the swing threshold (5%) in the
|
||||
/// correct direction (`high > adjacent low * 1.05`). The returned vector has one
|
||||
/// seed candle plus one confirming candle per pivot; pivot `k` is confirmed by
|
||||
/// candle `k + 1`. Only the high/low of each candle is meaningful — the pattern
|
||||
/// detectors read swings, not bodies.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn candles_for_pivots(pivots: &[f64]) -> Vec<Candle> {
|
||||
fn bar(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
let mut out = vec![bar(pivots[0], pivots[0] * 0.999, 0)];
|
||||
let mut ts: i64 = 0;
|
||||
for (k, &price) in pivots.iter().enumerate() {
|
||||
ts += 1;
|
||||
let is_high = k % 2 == 0;
|
||||
let next = if k + 1 < pivots.len() {
|
||||
pivots[k + 1]
|
||||
} else if is_high {
|
||||
price * 0.90
|
||||
} else {
|
||||
price * 1.10
|
||||
};
|
||||
let candle = if is_high {
|
||||
// Reverse down from the candidate high `price` to confirm it.
|
||||
bar(price * 0.99, next, ts)
|
||||
} else {
|
||||
// Reverse up from the candidate low `price` to confirm it.
|
||||
bar(next, price * 1.01, ts)
|
||||
};
|
||||
out.push(candle);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn c_hl(high: f64, low: f64, ts: i64) -> Candle {
|
||||
Candle::new(low, high, low, low, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_bar_only_bootstraps_no_pivot() {
|
||||
let mut t = SwingTracker::new(0.05, 6);
|
||||
assert!(!t.update(c_hl(100.0, 99.5, 0)));
|
||||
assert!(t.pivots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extends_candidate_high_without_confirming() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
assert!(!t.update(c_hl(100.0, 99.5, 0)));
|
||||
// A higher high merely raises the candidate — no pivot yet.
|
||||
assert!(!t.update(c_hl(110.0, 109.0, 1)));
|
||||
assert!(t.pivots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uptrend_small_move_does_not_confirm() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0));
|
||||
// A 1% dip is below the 10% threshold — neither extends nor confirms.
|
||||
assert!(!t.update(c_hl(99.8, 99.0, 1)));
|
||||
assert!(t.pivots().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirms_high_then_low_alternating() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0)); // seed uptrend
|
||||
let _ = t.update(c_hl(120.0, 119.5, 1)); // raise candidate high to 120
|
||||
// Drop ≥10% below 120 → confirm the high at 120, flip to downtrend.
|
||||
assert!(t.update(c_hl(101.0, 100.0, 2)));
|
||||
assert_eq!(
|
||||
t.pivots().last().copied(),
|
||||
// The high extreme was set at bar 1, confirmed at bar 2.
|
||||
Some(Pivot {
|
||||
price: 120.0,
|
||||
direction: 1.0,
|
||||
bar: 1,
|
||||
})
|
||||
);
|
||||
// Now in a downtrend: a lower low extends the candidate low.
|
||||
assert!(!t.update(c_hl(100.5, 90.0, 3)));
|
||||
// Rise ≥10% above 90 → confirm the low at 90.
|
||||
assert!(t.update(c_hl(100.0, 99.0, 4)));
|
||||
assert_eq!(
|
||||
t.pivots().last().copied(),
|
||||
// The low extreme was set at bar 3, confirmed at bar 4.
|
||||
Some(Pivot {
|
||||
price: 90.0,
|
||||
direction: -1.0,
|
||||
bar: 3,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downtrend_tiny_rise_does_not_confirm() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0));
|
||||
let _ = t.update(c_hl(120.0, 119.5, 1));
|
||||
let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high, now downtrend at 90
|
||||
// A 1% bounce is below threshold — no confirmation, no new candidate low.
|
||||
assert!(!t.update(c_hl(91.0, 90.5, 3)));
|
||||
assert_eq!(t.pivots().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_capped() {
|
||||
let mut t = SwingTracker::new(0.10, 2);
|
||||
// Drive an oscillation that confirms several pivots; only the last 2 stay.
|
||||
let path = [
|
||||
(100.0, 99.5),
|
||||
(120.0, 119.5),
|
||||
(101.0, 90.0), // confirm 120 (high)
|
||||
(91.0, 90.5),
|
||||
(110.0, 109.0), // confirm 90 (low)
|
||||
(109.0, 95.0), // confirm 110 (high)
|
||||
];
|
||||
for (i, (h, l)) in path.iter().enumerate() {
|
||||
let _ = t.update(c_hl(*h, *l, i64::try_from(i).unwrap()));
|
||||
}
|
||||
assert_eq!(t.pivots().len(), 2);
|
||||
// The two most recent confirmations: low 90 then high 110.
|
||||
assert_eq!(t.pivots()[0].price, 90.0);
|
||||
assert_eq!(t.pivots()[1].price, 110.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state_and_history() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0));
|
||||
let _ = t.update(c_hl(120.0, 119.5, 1));
|
||||
let _ = t.update(c_hl(101.0, 90.0, 2));
|
||||
assert_eq!(t.pivots().len(), 1);
|
||||
t.reset();
|
||||
assert!(t.pivots().is_empty());
|
||||
// After reset the next bar bootstraps again (returns false).
|
||||
assert!(!t.update(c_hl(100.0, 99.5, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_legs_extracts_highs_and_lows_either_ending() {
|
||||
// Newest pivot a high: [low_old, high_old, low_new, high_new].
|
||||
let ending_high = [
|
||||
Pivot {
|
||||
price: 100.0,
|
||||
direction: -1.0,
|
||||
bar: 0,
|
||||
},
|
||||
Pivot {
|
||||
price: 120.0,
|
||||
direction: 1.0,
|
||||
bar: 0,
|
||||
},
|
||||
Pivot {
|
||||
price: 110.0,
|
||||
direction: -1.0,
|
||||
bar: 0,
|
||||
},
|
||||
Pivot {
|
||||
price: 121.0,
|
||||
direction: 1.0,
|
||||
bar: 0,
|
||||
},
|
||||
];
|
||||
assert_eq!(recent_legs(&ending_high), (120.0, 121.0, 100.0, 110.0));
|
||||
// Newest pivot a low: [high_old, low_old, high_new, low_new].
|
||||
let ending_low = [
|
||||
Pivot {
|
||||
price: 120.0,
|
||||
direction: 1.0,
|
||||
bar: 0,
|
||||
},
|
||||
Pivot {
|
||||
price: 100.0,
|
||||
direction: -1.0,
|
||||
bar: 0,
|
||||
},
|
||||
Pivot {
|
||||
price: 110.0,
|
||||
direction: 1.0,
|
||||
bar: 0,
|
||||
},
|
||||
Pivot {
|
||||
price: 99.0,
|
||||
direction: -1.0,
|
||||
bar: 0,
|
||||
},
|
||||
];
|
||||
assert_eq!(recent_legs(&ending_low), (120.0, 110.0, 100.0, 99.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xabcd_reads_last_five_pivots_and_direction() {
|
||||
let pivots = [
|
||||
Pivot {
|
||||
price: 50.0,
|
||||
direction: 1.0,
|
||||
bar: 0,
|
||||
},
|
||||
Pivot {
|
||||
price: 100.0,
|
||||
direction: -1.0,
|
||||
bar: 0,
|
||||
}, // X
|
||||
Pivot {
|
||||
price: 140.0,
|
||||
direction: 1.0,
|
||||
bar: 0,
|
||||
}, // A
|
||||
Pivot {
|
||||
price: 115.0,
|
||||
direction: -1.0,
|
||||
bar: 0,
|
||||
}, // B
|
||||
Pivot {
|
||||
price: 128.0,
|
||||
direction: 1.0,
|
||||
bar: 0,
|
||||
}, // C
|
||||
Pivot {
|
||||
price: 108.0,
|
||||
direction: -1.0,
|
||||
bar: 0,
|
||||
}, // D (low → bullish)
|
||||
];
|
||||
let p = xabcd(&pivots);
|
||||
assert_eq!(
|
||||
(p.x, p.a, p.b, p.c, p.d),
|
||||
(100.0, 140.0, 115.0, 128.0, 108.0)
|
||||
);
|
||||
assert!(p.bullish);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratios_in_checks_every_window() {
|
||||
assert!(ratios_in(&[(0.6, 0.5, 0.7), (1.5, 1.0, 2.0)]));
|
||||
assert!(!ratios_in(&[(0.6, 0.5, 0.7), (3.0, 1.0, 2.0)])); // second out of range
|
||||
assert!(!ratios_in(&[(0.4, 0.5, 0.7)])); // below the window
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candles_for_pivots_realizes_the_requested_swings() {
|
||||
let want = [120.0, 100.0, 125.0, 95.0];
|
||||
let mut t = SwingTracker::new(0.05, 6);
|
||||
for candle in candles_for_pivots(&want) {
|
||||
let _ = t.update(candle);
|
||||
}
|
||||
let got: Vec<f64> = t.pivots().iter().map(|p| p.price).collect();
|
||||
assert_eq!(got, want);
|
||||
// Directions alternate starting from a high.
|
||||
assert_eq!(t.pivots()[0].direction, 1.0);
|
||||
assert_eq!(t.pivots()[1].direction, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approx_equal_relative_tolerance() {
|
||||
assert!(approx_equal(100.0, 102.0, 0.03)); // 2% apart, within 3%
|
||||
assert!(!approx_equal(100.0, 110.0, 0.03)); // 10% apart, outside 3%
|
||||
assert!(approx_equal(0.0, 0.0, 0.01)); // both zero
|
||||
assert!(approx_equal(-50.0, -49.0, 0.05)); // negative magnitudes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_extreme_bar_and_current_bar() {
|
||||
let mut t = SwingTracker::new(0.10, 6);
|
||||
// Before any candle, the current bar saturates at 0.
|
||||
assert_eq!(t.current_bar(), 0);
|
||||
let _ = t.update(c_hl(100.0, 99.5, 0));
|
||||
let _ = t.update(c_hl(120.0, 119.5, 1)); // candidate high at bar 1
|
||||
let _ = t.update(c_hl(101.0, 90.0, 2)); // confirm high @120 (extreme bar 1)
|
||||
assert_eq!(t.current_bar(), 2);
|
||||
assert_eq!(t.pivots().last().unwrap().bar, 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Percent Above Moving Average — share of a universe trading above its MA.
|
||||
|
||||
use crate::cross_section::CrossSection;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Percent Above Moving Average — the percentage of symbols in a universe that
|
||||
/// are trading above their reference moving average.
|
||||
///
|
||||
/// On each [`CrossSection`] tick the value is `100 * above_ma_count / universe
|
||||
/// size`, read from the per-symbol `above_ma` flag (the caller decides which MA —
|
||||
/// 50-day, 200-day — when it builds the tick). It is a bounded `0..=100` breadth
|
||||
/// gauge: readings near 100 mean almost the whole universe is in an uptrend
|
||||
/// (broad participation, but also a potential overbought extreme), readings near
|
||||
/// zero mark washouts. Crosses of the 50 line are read as bull/bear regime flips.
|
||||
///
|
||||
/// `Input = CrossSection`, `Output = f64` (a percentage in `0..=100`),
|
||||
/// `warmup_period == 1`. The universe is non-empty by construction, so the share
|
||||
/// is always defined.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{CrossSection, Indicator, Member, PercentAboveMa};
|
||||
///
|
||||
/// let mut pct = PercentAboveMa::new();
|
||||
/// // 3 of 4 symbols above their MA -> 75%.
|
||||
/// let tick = CrossSection::new(
|
||||
/// vec![
|
||||
/// Member::with_signals(1.0, 10.0, false, false, true, false),
|
||||
/// Member::with_signals(1.0, 10.0, false, false, true, false),
|
||||
/// Member::with_signals(-1.0, 10.0, false, false, true, false),
|
||||
/// Member::with_signals(-1.0, 10.0, false, false, false, false),
|
||||
/// ],
|
||||
/// 0,
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// assert_eq!(pct.update(tick), Some(75.0));
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PercentAboveMa {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl PercentAboveMa {
|
||||
/// Construct a new Percent Above Moving Average indicator.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PercentAboveMa {
|
||||
type Input = CrossSection;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, section: CrossSection) -> Option<f64> {
|
||||
let above = section.above_ma_count() as f64;
|
||||
let total = section.members.len() as f64;
|
||||
self.has_emitted = true;
|
||||
Some(100.0 * above / total)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PercentAboveMa"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cross_section::Member;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn tick(above: usize, below: usize) -> CrossSection {
|
||||
let mut members = Vec::new();
|
||||
for _ in 0..above {
|
||||
members.push(Member::with_signals(1.0, 10.0, false, false, true, false));
|
||||
}
|
||||
for _ in 0..below {
|
||||
members.push(Member::with_signals(-1.0, 10.0, false, false, false, false));
|
||||
}
|
||||
CrossSection::new(members, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let pct = PercentAboveMa::new();
|
||||
assert_eq!(pct.name(), "PercentAboveMa");
|
||||
assert_eq!(pct.warmup_period(), 1);
|
||||
assert!(!pct.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_tick_emits_percentage() {
|
||||
let mut pct = PercentAboveMa::new();
|
||||
assert_eq!(pct.update(tick(3, 1)), Some(75.0));
|
||||
assert!(pct.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_above_is_one_hundred() {
|
||||
let mut pct = PercentAboveMa::new();
|
||||
assert_eq!(pct.update(tick(4, 0)), Some(100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_above_is_zero() {
|
||||
let mut pct = PercentAboveMa::new();
|
||||
assert_eq!(pct.update(tick(0, 5)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut pct = PercentAboveMa::new();
|
||||
pct.update(tick(3, 1));
|
||||
assert!(pct.is_ready());
|
||||
pct.reset();
|
||||
assert!(!pct.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let sections = vec![tick(3, 1), tick(4, 0), tick(0, 5)];
|
||||
let mut a = PercentAboveMa::new();
|
||||
let mut b = PercentAboveMa::new();
|
||||
assert_eq!(
|
||||
a.batch(§ions),
|
||||
sections
|
||||
.iter()
|
||||
.map(|s| b.update(s.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Rectangle / Range chart pattern.
|
||||
|
||||
use crate::indicators::pattern_swing::{
|
||||
approx_equal, recent_legs, SwingTracker, LEVEL_TOLERANCE, SWING_THRESHOLD,
|
||||
};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Rectangle / Range — price oscillating between a roughly horizontal support
|
||||
/// and resistance, a mean-reversion (range-trading) structure.
|
||||
///
|
||||
/// Built on confirmed swing pivots ([`SWING_THRESHOLD`] = 5%); recognised when the
|
||||
/// last two highs and the last two lows are each flat within [`LEVEL_TOLERANCE`]
|
||||
/// (3%):
|
||||
///
|
||||
/// ```text
|
||||
/// flat highs (resistance) AND flat lows (support):
|
||||
/// last pivot a low → +1 (a bounce off support — buy the range)
|
||||
/// last pivot a high → -1 (a rejection at resistance — sell the range)
|
||||
/// ```
|
||||
///
|
||||
/// Unlike the breakout patterns the rectangle is range-bound, so the sign
|
||||
/// encodes the actionable mean-reversion direction of the just-confirmed touch.
|
||||
/// Output is `+1.0` / `-1.0` / `0.0`; never `None`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RectangleRange {
|
||||
swing: SwingTracker,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl RectangleRange {
|
||||
/// Construct a new Rectangle / Range detector.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
swing: SwingTracker::new(SWING_THRESHOLD, 4),
|
||||
has_emitted: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RectangleRange {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RectangleRange {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
if !self.swing.update(candle) {
|
||||
return Some(0.0);
|
||||
}
|
||||
let pivots = self.swing.pivots();
|
||||
if pivots.len() < 4 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let (high_old, high_new, low_old, low_new) = recent_legs(pivots);
|
||||
let flat_highs = approx_equal(high_old, high_new, LEVEL_TOLERANCE);
|
||||
let flat_lows = approx_equal(low_old, low_new, LEVEL_TOLERANCE);
|
||||
if flat_highs && flat_lows {
|
||||
let last_is_high = pivots[pivots.len() - 1].direction > 0.0;
|
||||
return Some(if last_is_high { -1.0 } else { 1.0 });
|
||||
}
|
||||
Some(0.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.swing.reset();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
// Four confirmed pivots; the earliest confirmation of the fourth is bar 5.
|
||||
5
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RectangleRange"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::indicators::pattern_swing::candles_for_pivots;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn run(pivots: &[f64]) -> Vec<f64> {
|
||||
let mut indicator = RectangleRange::new();
|
||||
candles_for_pivots(pivots)
|
||||
.into_iter()
|
||||
.map(|c| indicator.update(c).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let indicator = RectangleRange::new();
|
||||
assert_eq!(indicator.name(), "RectangleRange");
|
||||
assert_eq!(indicator.warmup_period(), 5);
|
||||
assert!(!indicator.is_ready());
|
||||
assert!(!RectangleRange::default().is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_bounce_off_support_is_plus_one() {
|
||||
// Flat highs (120, 121), flat lows (100, 99); last pivot a low → +1.
|
||||
let out = run(&[120.0, 100.0, 121.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_rejection_at_resistance_is_minus_one() {
|
||||
// Same range but ending on a high pivot → -1.
|
||||
let out = run(&[130.0, 100.0, 120.0, 99.0, 121.0]);
|
||||
assert_eq!(*out.last().unwrap(), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trending_highs_are_not_a_rectangle() {
|
||||
// Rising highs break the flat-resistance requirement → no rectangle.
|
||||
let out = run(&[120.0, 100.0, 140.0, 99.0]);
|
||||
assert_eq!(*out.last().unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut indicator = RectangleRange::new();
|
||||
for c in candles_for_pivots(&[120.0, 100.0, 121.0]) {
|
||||
let _ = indicator.update(c);
|
||||
}
|
||||
indicator.reset();
|
||||
assert!(!indicator.is_ready());
|
||||
let c = Candle::new(99.5, 100.0, 99.5, 99.5, 1.0, 0).unwrap();
|
||||
assert_eq!(indicator.update(c), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles = candles_for_pivots(&[120.0, 100.0, 121.0, 99.0]);
|
||||
let mut a = RectangleRange::new();
|
||||
let mut b = RectangleRange::new();
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,232 @@
|
||||
//! Seasonal Z-Score — how far the current bar's return sits from the historical
|
||||
//! mean return of bars in the *same hour of day*, in standard deviations.
|
||||
|
||||
use crate::calendar::civil_from_timestamp;
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
const HOURS: usize = 24;
|
||||
|
||||
/// Seasonal Z-Score keyed on hour of day.
|
||||
///
|
||||
/// For every bar the indicator forms the simple return `close / previous_close - 1`
|
||||
/// and compares it to the running mean and standard deviation of all prior
|
||||
/// returns that fell in the *same* local hour (the wall-clock hour of
|
||||
/// [`Candle::timestamp`](crate::Candle) shifted by `utc_offset_minutes`). The
|
||||
/// output is `(return - hour_mean) / hour_std`. A bucket needs at least two prior
|
||||
/// samples before it can emit; a bucket with zero historical variance reports
|
||||
/// `0.0`. The per-hour statistics use Welford's online algorithm.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, SeasonalZScore};
|
||||
///
|
||||
/// let day = 24 * 3_600_000;
|
||||
/// let mut z = SeasonalZScore::new(0);
|
||||
/// // Same hour each day so they share a bucket; close grows then jumps.
|
||||
/// for (i, close) in [100.0, 101.0, 103.0].iter().enumerate() {
|
||||
/// z.update(Candle::new(*close, *close, *close, *close, 1.0, i as i64 * day).unwrap());
|
||||
/// }
|
||||
/// // Fourth same-hour sample has two priors in the bucket -> emits a z-score.
|
||||
/// let out = z.update(Candle::new(110.0, 110.0, 110.0, 110.0, 1.0, 3 * day).unwrap());
|
||||
/// assert!(out.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SeasonalZScore {
|
||||
utc_offset_minutes: i32,
|
||||
prev_close: Option<f64>,
|
||||
count: [u64; HOURS],
|
||||
mean: [f64; HOURS],
|
||||
m2: [f64; HOURS],
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl SeasonalZScore {
|
||||
/// Construct a Seasonal Z-Score indicator with the given UTC offset (minutes).
|
||||
pub const fn new(utc_offset_minutes: i32) -> Self {
|
||||
Self {
|
||||
utc_offset_minutes,
|
||||
prev_close: None,
|
||||
count: [0; HOURS],
|
||||
mean: [0.0; HOURS],
|
||||
m2: [0.0; HOURS],
|
||||
last: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configured UTC offset in minutes.
|
||||
pub const fn utc_offset_minutes(&self) -> i32 {
|
||||
self.utc_offset_minutes
|
||||
}
|
||||
|
||||
/// Most recent z-score if a populated bucket has produced one.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
fn z_for(&self, hour: usize, ret: f64) -> Option<f64> {
|
||||
if self.count[hour] < 2 {
|
||||
return None;
|
||||
}
|
||||
let variance = self.m2[hour] / (self.count[hour] - 1) as f64;
|
||||
if variance > 0.0 {
|
||||
Some((ret - self.mean[hour]) / variance.sqrt())
|
||||
} else {
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn accumulate(&mut self, hour: usize, ret: f64) {
|
||||
self.count[hour] += 1;
|
||||
let delta = ret - self.mean[hour];
|
||||
self.mean[hour] += delta / self.count[hour] as f64;
|
||||
let delta2 = ret - self.mean[hour];
|
||||
self.m2[hour] += delta * delta2;
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SeasonalZScore {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
let civil = civil_from_timestamp(candle.timestamp, self.utc_offset_minutes);
|
||||
let hour = civil.hour as usize;
|
||||
let result = if let Some(prev) = self.prev_close {
|
||||
let ret = if prev == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
candle.close / prev - 1.0
|
||||
};
|
||||
let z = self.z_for(hour, ret);
|
||||
self.accumulate(hour, ret);
|
||||
z
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.prev_close = Some(candle.close);
|
||||
if result.is_some() {
|
||||
self.last = result;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_close = None;
|
||||
self.count = [0; HOURS];
|
||||
self.mean = [0.0; HOURS];
|
||||
self.m2 = [0.0; HOURS];
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
2
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SeasonalZScore"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
const DAY: i64 = 24 * 3_600_000;
|
||||
|
||||
fn c(close: f64, ts: i64) -> Candle {
|
||||
Candle::new(close, close, close, close, 1.0, ts).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_accessors() {
|
||||
let z = SeasonalZScore::new(120);
|
||||
assert_eq!(z.utc_offset_minutes(), 120);
|
||||
assert_eq!(z.name(), "SeasonalZScore");
|
||||
assert_eq!(z.warmup_period(), 2);
|
||||
assert!(!z.is_ready());
|
||||
assert!(z.value().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_output_until_bucket_has_two_priors() {
|
||||
let mut z = SeasonalZScore::new(0);
|
||||
// Each bar shares the same hour bucket (same time-of-day, daily spacing).
|
||||
assert!(z.update(c(100.0, 0)).is_none()); // first: no return
|
||||
assert!(z.update(c(101.0, DAY)).is_none()); // return #1 -> bucket has 0 priors
|
||||
assert!(z.update(c(102.0, 2 * DAY)).is_none()); // return #2 -> bucket has 1 prior
|
||||
// return #3 -> bucket has 2 priors -> emits.
|
||||
assert!(z.update(c(104.0, 3 * DAY)).is_some());
|
||||
assert!(z.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn z_score_matches_manual_welford() {
|
||||
let mut z = SeasonalZScore::new(0);
|
||||
// Returns into one hourly bucket: r1 = 0.01, r2 = 0.02, r3 = 0.03.
|
||||
z.update(c(100.0, 0));
|
||||
z.update(c(101.0, DAY)); // r1 = 0.01
|
||||
z.update(c(103.02, 2 * DAY)); // r2 = 0.02
|
||||
// Priors {0.01, 0.02}: mean 0.015, sample std = sqrt(((.005)^2*2)/1).
|
||||
let mean = 0.015;
|
||||
let std = (((0.01_f64 - mean).powi(2) + (0.02 - mean).powi(2)) / 1.0).sqrt();
|
||||
let r3 = 0.03;
|
||||
let expected = (r3 - mean) / std;
|
||||
let close = 103.02 * (1.0 + r3);
|
||||
let out = z.update(c(close, 3 * DAY)).unwrap();
|
||||
assert_relative_eq!(out, expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_variance_bucket_reports_zero() {
|
||||
let mut z = SeasonalZScore::new(0);
|
||||
// Constant return into the bucket -> variance 0 -> z = 0.
|
||||
z.update(c(100.0, 0));
|
||||
z.update(c(110.0, DAY)); // r1 = 0.10
|
||||
z.update(c(121.0, 2 * DAY)); // r2 = 0.10
|
||||
let out = z.update(c(133.1, 3 * DAY)).unwrap(); // r3 = 0.10
|
||||
assert_relative_eq!(out, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_prev_close_uses_zero_return() {
|
||||
let mut z = SeasonalZScore::new(0);
|
||||
z.update(c(0.0, 0)); // prev close 0
|
||||
z.update(c(0.0, DAY)); // ret = 0 (guarded), bucket sample
|
||||
z.update(c(0.0, 2 * DAY)); // ret = 0, bucket now 2 priors
|
||||
let out = z.update(c(0.0, 3 * DAY)).unwrap();
|
||||
assert_relative_eq!(out, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut z = SeasonalZScore::new(0);
|
||||
for i in 0..4 {
|
||||
z.update(c(100.0 + f64::from(i), i64::from(i) * DAY));
|
||||
}
|
||||
z.reset();
|
||||
assert!(!z.is_ready());
|
||||
assert!(z.value().is_none());
|
||||
assert!(z.update(c(100.0, 4 * DAY)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..50)
|
||||
.map(|i| c(100.0 + f64::from(i % 9), i64::from(i) * 3 * 3_600_000))
|
||||
.collect();
|
||||
let mut a = SeasonalZScore::new(0);
|
||||
let mut b = SeasonalZScore::new(0);
|
||||
assert_eq!(
|
||||
a.batch(&candles),
|
||||
candles.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user