Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46be7a54ea | |||
| bca61322b5 | |||
| fc6f619550 | |||
| 91aa6fffbf | |||
| 5862401958 | |||
| ff5a047078 |
+29
-1
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.3] - 2026-06-08
|
||||
- **M2Measure** — M2 measure (Modigliani; Sharpe expressed in benchmark return units) (`M2Measure`).
|
||||
- **UpsidePotentialRatio** — Upside Potential Ratio (upside mean over downside deviation) (`UpsidePotentialRatio`).
|
||||
- **GainToPainRatio** — Gain-to-Pain Ratio (sum of returns over sum of losses) (`GainToPainRatio`).
|
||||
- **CommonSenseRatio** — Common Sense Ratio (tail ratio times gain-to-pain) (`CommonSenseRatio`).
|
||||
- **KRatio** — K-Ratio (Kestner; equity-curve slope over its standard error) (`KRatio`).
|
||||
- **TailRatio** — Tail Ratio (95th over absolute 5th return percentile) (`TailRatio`).
|
||||
- **MartinRatio** — Martin Ratio (Ulcer Performance Index; return over RMS drawdown) (`MartinRatio`).
|
||||
- **BurkeRatio** — Burke Ratio (return over root-sum-squared drawdowns) (`BurkeRatio`).
|
||||
- **SterlingRatio** — Sterling Ratio (mean return over average drawdown) (`SterlingRatio`).
|
||||
|
||||
## [0.7.2] - 2026-06-08
|
||||
- **Composite Profile** — multi-session composite volume profile exposing POC, VAH and VAL (`CompositeProfile`).
|
||||
- **High/Low Volume Nodes** — highest- and lowest-volume price nodes in the profile (`HighLowVolumeNodes`).
|
||||
- **Profile Shape** — profile shape classification (b/P/D normal) as a numeric code (`ProfileShape`).
|
||||
- **Single Prints** — count of single-print (low-activity) price levels in the profile (`SinglePrints`).
|
||||
- **Naked POC** — most recent untouched (naked) point of control level (`NakedPoc`).
|
||||
|
||||
## [0.7.1] - 2026-06-08
|
||||
- **Open-Interest Momentum** — rate-of-change of open interest over a rolling window (`OpenInterestMomentum`).
|
||||
- **Funding-Implied APR** — annualised funding rate (per-interval funding times intervals per year) (`FundingImpliedApr`).
|
||||
- **Perpetual Premium Index** — relative premium of the mark price over the index price (`PerpetualPremiumIndex`).
|
||||
- **OI-to-Volume Ratio** — open interest divided by taker volume (position turnover proxy) (`OiToVolumeRatio`).
|
||||
- **Estimated Leverage Ratio** — open interest divided by aggregate long+short position size (leverage proxy) (`EstimatedLeverageRatio`).
|
||||
|
||||
## [0.7.0] - 2026-06-08
|
||||
- **Hasbrouck Information Share** — variance-ratio proxy for each venue's share of price discovery (Hasbrouck information share) (`HasbrouckInformationShare`).
|
||||
- **PIN** — probability of informed trading from rolling buy/sell imbalance (EKOP single-window estimator) (`Pin`).
|
||||
@@ -1382,7 +1407,10 @@ 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.7.0...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.7.3...HEAD
|
||||
[0.7.3]: https://github.com/wickra-lib/wickra/compare/v0.7.2...v0.7.3
|
||||
[0.7.2]: https://github.com/wickra-lib/wickra/compare/v0.7.1...v0.7.2
|
||||
[0.7.1]: https://github.com/wickra-lib/wickra/compare/v0.7.0...v0.7.1
|
||||
[0.7.0]: https://github.com/wickra-lib/wickra/compare/v0.6.9...v0.7.0
|
||||
[0.6.9]: https://github.com/wickra-lib/wickra/compare/v0.6.8...v0.6.9
|
||||
[0.6.8]: https://github.com/wickra-lib/wickra/compare/v0.6.7...v0.6.8
|
||||
|
||||
Generated
+8
-8
@@ -1944,7 +1944,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1955,7 +1955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-bench"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"kand",
|
||||
@@ -1967,7 +1967,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1977,7 +1977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-examples"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -2004,7 +2004,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -2014,7 +2014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -2023,7 +2023,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
@@ -25,7 +25,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.7.0" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.7.3" }
|
||||
|
||||
thiserror = "2"
|
||||
rayon = "1.10"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<a href="https://wickra.org"><img src="https://raw.githubusercontent.com/wickra-lib/.github/main/profile/wickra-banner.webp?v=488" 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=507" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
@@ -48,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 488 indicators; start at the
|
||||
every one of the 507 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),
|
||||
@@ -66,7 +66,7 @@ an afterthought — **live, tick-by-tick data** — without giving up the breadt
|
||||
a full batch library, and without making you reimplement your indicators four
|
||||
times to get there.
|
||||
|
||||
- **The biggest streaming-native catalogue, period.** 488 indicators across 24
|
||||
- **The biggest streaming-native catalogue, period.** 507 indicators across 24
|
||||
families — candlesticks, harmonic & chart patterns, market profile, market
|
||||
breadth, Renko/Kagi/Point&Figure bars, Ehlers DSP cycles, risk/performance
|
||||
metrics — every single one updating in **O(1) per tick**. TA-Lib ships ~150 and
|
||||
@@ -77,7 +77,7 @@ times to get there.
|
||||
- **Correct by construction, not by hope.** Every `update` validates its input,
|
||||
runs a real warmup, and returns an `Option` so a single bad tick can't silently
|
||||
poison state. `batch == streaming` is **bit-exact, fuzzed and 100 %-line-covered
|
||||
for all 488 indicators**.
|
||||
for all 507 indicators**.
|
||||
- **Orders of magnitude faster where it counts.** In streaming Wickra is **11–56×**
|
||||
faster than the only other incremental peer and **thousands of times** faster
|
||||
than recompute-on-every-tick libraries. On batch it wins several rows outright
|
||||
@@ -95,7 +95,7 @@ Every other library forces one of those compromises. Wickra doesn't:
|
||||
|
||||
| Library | Install | Streaming | Languages | Indicators | Active |
|
||||
|------------------|-------------|-------------|-----------------------------|-----------:|--------|
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **488** | **yes** |
|
||||
| **★ Wickra**| **clean** | **yes, O(1)** | **Python · Node · WASM · Rust** | **507** | **yes** |
|
||||
| kand | clean | yes | Python · WASM · Rust | ~60 | yes |
|
||||
| ta-rs | clean | yes | Rust only | ~30 | stale |
|
||||
| yata | clean | partial | Rust only | ~35 | yes |
|
||||
@@ -128,7 +128,7 @@ Full tables (Rust + Python, streaming + batch) and how to reproduce them live in
|
||||
|
||||
## Indicators
|
||||
|
||||
488 streaming-first indicators across twenty-four families. Every one passes the
|
||||
507 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).
|
||||
@@ -154,8 +154,8 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| 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, Order Flow Imbalance, VPIN, Amihud Illiquidity, Roll Measure, Trade-Sign Autocorrelation, Hasbrouck Information Share |
|
||||
| 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 |
|
||||
| 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, Estimated Leverage Ratio, OI-to-Volume Ratio, Perpetual Premium Index, Funding-Implied APR, Open-Interest Momentum |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Volume Profile (histogram), TPO Profile, Initial Balance, Opening Range, Naked POC, Single Prints, Profile Shape, High/Low Volume Nodes, Composite Profile |
|
||||
| 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 |
|
||||
@@ -237,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 488 indicators
|
||||
│ ├── wickra-core/ core engine + all 507 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ ├── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
│ └── wickra-bench/ internal cross-library benchmark harness (not published)
|
||||
|
||||
@@ -28,6 +28,15 @@ function num(v) {
|
||||
// --- Scalar indicators: update(value) vs batch(prices) ---
|
||||
|
||||
const scalarFactories = {
|
||||
M2Measure: () => new wickra.M2Measure(20, 0.0, 0.02),
|
||||
UpsidePotentialRatio: () => new wickra.UpsidePotentialRatio(20, 0.0),
|
||||
GainToPainRatio: () => new wickra.GainToPainRatio(12),
|
||||
CommonSenseRatio: () => new wickra.CommonSenseRatio(20),
|
||||
KRatio: () => new wickra.KRatio(30),
|
||||
TailRatio: () => new wickra.TailRatio(20),
|
||||
MartinRatio: () => new wickra.MartinRatio(14),
|
||||
BurkeRatio: () => new wickra.BurkeRatio(12),
|
||||
SterlingRatio: () => new wickra.SterlingRatio(12),
|
||||
AUTOCORRPGRAM: () => new wickra.AUTOCORRPGRAM(10, 48),
|
||||
EVENBETTERSINE: () => new wickra.EVENBETTERSINE(40, 10),
|
||||
BANDPASS: () => new wickra.BANDPASS(20, 0.3),
|
||||
@@ -392,6 +401,9 @@ const candleScalar = {
|
||||
DumplingTop: { make: () => new wickra.DumplingTop(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
NewPriceLines: { make: () => new wickra.NewPriceLines(5), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
FryPanBottom: { make: () => new wickra.FryPanBottom(9), step: (ind, i) => ind.update(high[i], low[i], close[i]), batch: (ind) => ind.batch(high, low, close) },
|
||||
NakedPoc: { make: () => new wickra.NakedPoc(20, 24), step: (ind, i) => ind.update(high[i], low[i], close[i], volume[i]), batch: (ind) => ind.batch(high, low, close, volume) },
|
||||
SinglePrints: { make: () => new wickra.SinglePrints(20, 24), step: (ind, i) => ind.update(high[i], low[i]), batch: (ind) => ind.batch(high, low) },
|
||||
ProfileShape: { make: () => new wickra.ProfileShape(20, 24), step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(candleScalar)) {
|
||||
@@ -497,6 +509,8 @@ const multi = {
|
||||
SmoothedHeikinAshi: { make: () => new wickra.SmoothedHeikinAshi(5), 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) },
|
||||
Equivolume: { make: () => new wickra.Equivolume(20), fields: ['height', 'width'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
CandleVolume: { make: () => new wickra.CandleVolume(20), fields: ['body', 'width'], step: (ind, i) => ind.update(open[i], close[i], volume[i]), batch: (ind) => ind.batch(open, close, volume) },
|
||||
HighLowVolumeNodes: { make: () => new wickra.HighLowVolumeNodes(20, 24), fields: ['hvn', 'lvn'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
CompositeProfile: { make: () => new wickra.CompositeProfile(20, 24, 0.7), fields: ['poc', 'vah', 'val'], step: (ind, i) => ind.update(high[i], low[i], volume[i]), batch: (ind) => ind.batch(high, low, volume) },
|
||||
};
|
||||
|
||||
for (const [name, d] of Object.entries(multi)) {
|
||||
@@ -1443,6 +1457,56 @@ test('derivatives reject bad input', () => {
|
||||
assert.throws(() => new wickra.FundingBasis().update(100, 0));
|
||||
});
|
||||
|
||||
test('B16 derivatives reference values', () => {
|
||||
// Estimated leverage: oi / (long + short) = 200 / 100 = 2.
|
||||
assert.ok(Math.abs(new wickra.EstimatedLeverageRatio().update(200, 60, 40) - 2.0) < 1e-12);
|
||||
// OI-to-volume: oi / (buy + sell) = 100 / 50 = 2.
|
||||
assert.ok(Math.abs(new wickra.OiToVolumeRatio().update(100, 30, 20) - 2.0) < 1e-12);
|
||||
// Perpetual premium: (mark - index) / index = 0.5 / 100 = 0.005.
|
||||
assert.ok(Math.abs(new wickra.PerpetualPremiumIndex().update(100.5, 100.0) - 0.005) < 1e-12);
|
||||
// Funding-implied APR: rate * intervals = 0.0001 * 1095 = 0.1095.
|
||||
assert.ok(Math.abs(new wickra.FundingImpliedApr(1095).update(0.0001) - 0.1095) < 1e-12);
|
||||
// Open-interest momentum (period 2): warmup then ROC% = 100*(120 - 100)/100 = 20.
|
||||
const oim = new wickra.OpenInterestMomentum(2);
|
||||
assert.equal(oim.update(100), null);
|
||||
assert.equal(oim.update(110), null);
|
||||
assert.ok(Math.abs(oim.update(120) - 20.0) < 1e-12);
|
||||
});
|
||||
|
||||
test('B16 derivatives streaming matches batch', () => {
|
||||
const n = 30;
|
||||
const oi = Array.from({ length: n }, (_, i) => 1000 + 50 * Math.sin(i * 0.3));
|
||||
const longSz = Array.from({ length: n }, (_, i) => 600 + 20 * Math.cos(i * 0.2));
|
||||
const shortSz = Array.from({ length: n }, (_, i) => 400 + 15 * Math.sin(i * 0.4));
|
||||
const buy = Array.from({ length: n }, (_, i) => 300 + 10 * Math.sin(i * 0.5));
|
||||
const sell = Array.from({ length: n }, (_, i) => 250 + 12 * Math.cos(i * 0.35));
|
||||
const index = Array.from({ length: n }, (_, i) => 100 + Math.sin(i * 0.2));
|
||||
const mark = Array.from({ length: n }, (_, i) => index[i] + 0.05 * Math.cos(i * 0.3));
|
||||
const rate = Array.from({ length: n }, (_, i) => 0.0001 * Math.sin(i * 0.3));
|
||||
const cmp = (batch, s, i) =>
|
||||
assert.ok((s === null && Number.isNaN(batch[i])) || Math.abs(s - batch[i]) < 1e-12, `mismatch at ${i}`);
|
||||
|
||||
let b = new wickra.EstimatedLeverageRatio().batch(oi, longSz, shortSz);
|
||||
let st = new wickra.EstimatedLeverageRatio();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i], longSz[i], shortSz[i]), i);
|
||||
|
||||
b = new wickra.OiToVolumeRatio().batch(oi, buy, sell);
|
||||
st = new wickra.OiToVolumeRatio();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i], buy[i], sell[i]), i);
|
||||
|
||||
b = new wickra.PerpetualPremiumIndex().batch(mark, index);
|
||||
st = new wickra.PerpetualPremiumIndex();
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(mark[i], index[i]), i);
|
||||
|
||||
b = new wickra.FundingImpliedApr(1095).batch(rate);
|
||||
st = new wickra.FundingImpliedApr(1095);
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(rate[i]), i);
|
||||
|
||||
b = new wickra.OpenInterestMomentum(10).batch(oi);
|
||||
st = new wickra.OpenInterestMomentum(10);
|
||||
for (let i = 0; i < n; i++) cmp(b, st.update(oi[i]), i);
|
||||
});
|
||||
|
||||
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.
|
||||
|
||||
Vendored
+180
@@ -409,6 +409,15 @@ export interface VolumeProfileValue {
|
||||
priceHigh: number
|
||||
bins: Array<number>
|
||||
}
|
||||
export interface HighLowVolumeNodesValue {
|
||||
hvn: number
|
||||
lvn: number
|
||||
}
|
||||
export interface CompositeProfileValue {
|
||||
poc: number
|
||||
vah: number
|
||||
val: number
|
||||
}
|
||||
export interface TpoProfileValue {
|
||||
priceLow: number
|
||||
priceHigh: number
|
||||
@@ -1167,6 +1176,87 @@ export declare class UNIVERSALOSC {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SterlingRatioNode = SterlingRatio
|
||||
export declare class SterlingRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BurkeRatioNode = BurkeRatio
|
||||
export declare class BurkeRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type MartinRatioNode = MartinRatio
|
||||
export declare class MartinRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type TailRatioNode = TailRatio
|
||||
export declare class TailRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KRatioNode = KRatio
|
||||
export declare class KRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CommonSenseRatioNode = CommonSenseRatio
|
||||
export declare class CommonSenseRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type GainToPainRatioNode = GainToPainRatio
|
||||
export declare class GainToPainRatio {
|
||||
constructor(period: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type UpsidePotentialRatioNode = UpsidePotentialRatio
|
||||
export declare class UpsidePotentialRatio {
|
||||
constructor(period: number, mar: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type M2MeasureNode = M2Measure
|
||||
export declare class M2Measure {
|
||||
constructor(period: number, riskFree: number, benchmarkStddev: number)
|
||||
update(value: number): number | null
|
||||
batch(prices: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type BandpassFilterNode = BANDPASS
|
||||
export declare class BANDPASS {
|
||||
constructor(period: number, bandwidth: number)
|
||||
@@ -3470,6 +3560,51 @@ export declare class ValueArea {
|
||||
update(high: number, low: number, volume: number): ValueAreaValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
}
|
||||
export type NakedPocNode = NakedPoc
|
||||
export declare class NakedPoc {
|
||||
constructor(sessionLen: number, binCount: number)
|
||||
update(high: number, low: number, close: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, close: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SinglePrintsNode = SinglePrints
|
||||
export declare class SinglePrints {
|
||||
constructor(period: number, binCount: number)
|
||||
update(high: number, low: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type ProfileShapeNode = ProfileShape
|
||||
export declare class ProfileShape {
|
||||
constructor(period: number, binCount: number)
|
||||
update(high: number, low: number, volume: number): number | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type HighLowVolumeNodesNode = HighLowVolumeNodes
|
||||
export declare class HighLowVolumeNodes {
|
||||
constructor(period: number, binCount: number)
|
||||
update(high: number, low: number, volume: number): HighLowVolumeNodesValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type CompositeProfileNode = CompositeProfile
|
||||
export declare class CompositeProfile {
|
||||
constructor(period: number, binCount: number, valueAreaPct: number)
|
||||
update(high: number, low: number, volume: number): CompositeProfileValue | null
|
||||
batch(high: Array<number>, low: Array<number>, volume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type VolumeProfileNode = VolumeProfile
|
||||
export declare class VolumeProfile {
|
||||
constructor(period: number, binCount: number)
|
||||
@@ -4544,6 +4679,51 @@ export declare class CalendarSpread {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EstimatedLeverageRatioNode = EstimatedLeverageRatio
|
||||
export declare class EstimatedLeverageRatio {
|
||||
constructor()
|
||||
update(openInterest: number, longSize: number, shortSize: number): number | null
|
||||
batch(openInterest: Array<number>, longSize: Array<number>, shortSize: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OiToVolumeRatioNode = OiToVolumeRatio
|
||||
export declare class OiToVolumeRatio {
|
||||
constructor()
|
||||
update(openInterest: number, takerBuyVolume: number, takerSellVolume: number): number | null
|
||||
batch(openInterest: Array<number>, takerBuyVolume: Array<number>, takerSellVolume: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type PerpetualPremiumIndexNode = PerpetualPremiumIndex
|
||||
export declare class PerpetualPremiumIndex {
|
||||
constructor()
|
||||
update(markPrice: number, indexPrice: number): number | null
|
||||
batch(markPrice: Array<number>, indexPrice: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FundingImpliedAprNode = FundingImpliedApr
|
||||
export declare class FundingImpliedApr {
|
||||
constructor(intervalsPerYear: number)
|
||||
update(fundingRate: number): number | null
|
||||
batch(fundingRate: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OpenInterestMomentumNode = OpenInterestMomentum
|
||||
export declare class OpenInterestMomentum {
|
||||
constructor(period: number)
|
||||
update(openInterest: number): number | null
|
||||
batch(openInterest: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type AdvanceDeclineNode = AdvanceDecline
|
||||
export declare class AdvanceDecline {
|
||||
constructor()
|
||||
|
||||
+20
-1
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-x64",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-arm64-gnu",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-linux-x64-gnu",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-arm64-msvc",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-win32-x64-msvc",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.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": [
|
||||
|
||||
Generated
+20
-20
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.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.7.0",
|
||||
"wickra-darwin-x64": "0.7.0",
|
||||
"wickra-linux-arm64-gnu": "0.7.0",
|
||||
"wickra-linux-x64-gnu": "0.7.0",
|
||||
"wickra-win32-arm64-msvc": "0.7.0",
|
||||
"wickra-win32-x64-msvc": "0.7.0"
|
||||
"wickra-darwin-arm64": "0.7.3",
|
||||
"wickra-darwin-x64": "0.7.3",
|
||||
"wickra-linux-arm64-gnu": "0.7.3",
|
||||
"wickra-linux-x64-gnu": "0.7.3",
|
||||
"wickra-win32-arm64-msvc": "0.7.3",
|
||||
"wickra-win32-x64-msvc": "0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.7.0.tgz",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.7.3.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.7.0.tgz",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.7.3.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.7.0.tgz",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.7.3.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.7.0.tgz",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.7.3.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.7.0.tgz",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.7.3.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.7.0.tgz",
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.7.3.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"description": "Streaming-first technical indicators: incremental, fast, install-free. Node bindings powered by Rust.",
|
||||
"author": "kingchenc <support@wickra.org>",
|
||||
"main": "index.js",
|
||||
@@ -47,12 +47,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-linux-x64-gnu": "0.7.0",
|
||||
"wickra-linux-arm64-gnu": "0.7.0",
|
||||
"wickra-darwin-x64": "0.7.0",
|
||||
"wickra-darwin-arm64": "0.7.0",
|
||||
"wickra-win32-x64-msvc": "0.7.0",
|
||||
"wickra-win32-arm64-msvc": "0.7.0"
|
||||
"wickra-linux-x64-gnu": "0.7.3",
|
||||
"wickra-linux-arm64-gnu": "0.7.3",
|
||||
"wickra-darwin-x64": "0.7.3",
|
||||
"wickra-darwin-arm64": "0.7.3",
|
||||
"wickra-win32-x64-msvc": "0.7.3",
|
||||
"wickra-win32-arm64-msvc": "0.7.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -245,9 +245,91 @@ node_scalar_indicator!(
|
||||
"UNIVERSALOSC",
|
||||
wc::UniversalOscillator
|
||||
);
|
||||
node_scalar_indicator!(SterlingRatioNode, "SterlingRatio", wc::SterlingRatio);
|
||||
node_scalar_indicator!(BurkeRatioNode, "BurkeRatio", wc::BurkeRatio);
|
||||
node_scalar_indicator!(MartinRatioNode, "MartinRatio", wc::MartinRatio);
|
||||
node_scalar_indicator!(TailRatioNode, "TailRatio", wc::TailRatio);
|
||||
node_scalar_indicator!(KRatioNode, "KRatio", wc::KRatio);
|
||||
node_scalar_indicator!(
|
||||
CommonSenseRatioNode,
|
||||
"CommonSenseRatio",
|
||||
wc::CommonSenseRatio
|
||||
);
|
||||
node_scalar_indicator!(GainToPainRatioNode, "GainToPainRatio", wc::GainToPainRatio);
|
||||
|
||||
// Multi-arg Ehlers scalars: hand-written (node_scalar_indicator! is single-period).
|
||||
|
||||
#[napi(js_name = "UpsidePotentialRatio")]
|
||||
pub struct UpsidePotentialRatioNode {
|
||||
inner: wc::UpsidePotentialRatio,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl UpsidePotentialRatioNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, mar: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::UpsidePotentialRatio::new(period as usize, mar).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "M2Measure")]
|
||||
pub struct M2MeasureNode {
|
||||
inner: wc::M2Measure,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl M2MeasureNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, risk_free: f64, benchmark_stddev: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::M2Measure::new(period as usize, risk_free, benchmark_stddev)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, value: f64) -> Option<f64> {
|
||||
self.inner.update(value)
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, prices: Vec<f64>) -> Vec<f64> {
|
||||
flatten(self.inner.batch(&prices))
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "BANDPASS")]
|
||||
pub struct BandpassFilterNode {
|
||||
inner: wc::BandpassFilter,
|
||||
@@ -12874,6 +12956,332 @@ pub struct VolumeProfileValue {
|
||||
pub bins: Vec<f64>,
|
||||
}
|
||||
|
||||
// Naked POC: most recent untouched point-of-control level (Candle -> f64).
|
||||
#[napi(js_name = "NakedPoc")]
|
||||
pub struct NakedPocNode {
|
||||
inner: wc::NakedPoc,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl NakedPocNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(session_len: u32, bin_count: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::NakedPoc::new(session_len as usize, bin_count as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
volume: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(cnd(high, low, close, volume)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
close: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, close, volume must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(cnd(high[i], low[i], close[i], volume[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// Single Prints: count of single-print price levels (Candle -> f64).
|
||||
#[napi(js_name = "SinglePrints")]
|
||||
pub struct SinglePrintsNode {
|
||||
inner: wc::SinglePrints,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl SinglePrintsNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, bin_count: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::SinglePrints::new(period as usize, bin_count as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64) -> napi::Result<Option<f64>> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
Ok(self
|
||||
.inner
|
||||
.update(wc::Candle::new(mid, high, low, mid, 0.0, 0).map_err(map_err)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, high: Vec<f64>, low: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high and low must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
out.push(
|
||||
self.inner
|
||||
.update(wc::Candle::new(mid, high[i], low[i], mid, 0.0, 0).map_err(map_err)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// Profile Shape: b/P/D classification as a numeric code (Candle -> f64).
|
||||
#[napi(js_name = "ProfileShape")]
|
||||
pub struct ProfileShapeNode {
|
||||
inner: wc::ProfileShape,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl ProfileShapeNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, bin_count: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::ProfileShape::new(period as usize, bin_count as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> napi::Result<Option<f64>> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
Ok(self
|
||||
.inner
|
||||
.update(wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, volume must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
out.push(
|
||||
self.inner
|
||||
.update(
|
||||
wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0)
|
||||
.map_err(map_err)?,
|
||||
)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct HighLowVolumeNodesValue {
|
||||
pub hvn: f64,
|
||||
pub lvn: f64,
|
||||
}
|
||||
|
||||
// High/Low Volume Nodes: highest- and lowest-volume price nodes (Candle -> struct).
|
||||
#[napi(js_name = "HighLowVolumeNodes")]
|
||||
pub struct HighLowVolumeNodesNode {
|
||||
inner: wc::HighLowVolumeNodes,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl HighLowVolumeNodesNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, bin_count: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::HighLowVolumeNodes::new(period as usize, bin_count as usize)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
volume: f64,
|
||||
) -> napi::Result<Option<HighLowVolumeNodesValue>> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let candle = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
|
||||
Ok(self.inner.update(candle).map(|o| HighLowVolumeNodesValue {
|
||||
hvn: o.hvn,
|
||||
lvn: o.lvn,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, volume must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let candle =
|
||||
wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * 2] = o.hvn;
|
||||
out[i * 2 + 1] = o.lvn;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct CompositeProfileValue {
|
||||
pub poc: f64,
|
||||
pub vah: f64,
|
||||
pub val: f64,
|
||||
}
|
||||
|
||||
// Composite Profile: multi-session composite volume profile (Candle -> struct).
|
||||
#[napi(js_name = "CompositeProfile")]
|
||||
pub struct CompositeProfileNode {
|
||||
inner: wc::CompositeProfile,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl CompositeProfileNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32, bin_count: u32, value_area_pct: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::CompositeProfile::new(period as usize, bin_count as usize, value_area_pct)
|
||||
.map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
volume: f64,
|
||||
) -> napi::Result<Option<CompositeProfileValue>> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let candle = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
|
||||
Ok(self.inner.update(candle).map(|o| CompositeProfileValue {
|
||||
poc: o.poc,
|
||||
vah: o.vah,
|
||||
val: o.val,
|
||||
}))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: Vec<f64>,
|
||||
low: Vec<f64>,
|
||||
volume: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"high, low, volume must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let candle =
|
||||
wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(candle) {
|
||||
out[i * 3] = o.poc;
|
||||
out[i * 3 + 1] = o.vah;
|
||||
out[i * 3 + 2] = o.val;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(js_name = "VolumeProfile")]
|
||||
pub struct VolumeProfileNode {
|
||||
inner: wc::VolumeProfile,
|
||||
@@ -14450,6 +14858,50 @@ fn deriv_taker(taker_buy_volume: f64, taker_sell_volume: f64) -> napi::Result<wc
|
||||
.map_err(map_err)
|
||||
}
|
||||
|
||||
fn deriv_oi_long_short(
|
||||
open_interest: f64,
|
||||
long_size: f64,
|
||||
short_size: f64,
|
||||
) -> napi::Result<wc::DerivativesTick> {
|
||||
wc::DerivativesTick::new(
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
open_interest,
|
||||
long_size,
|
||||
short_size,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
.map_err(map_err)
|
||||
}
|
||||
|
||||
fn deriv_oi_taker(
|
||||
open_interest: f64,
|
||||
taker_buy_volume: f64,
|
||||
taker_sell_volume: f64,
|
||||
) -> napi::Result<wc::DerivativesTick> {
|
||||
wc::DerivativesTick::new(
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
open_interest,
|
||||
0.0,
|
||||
0.0,
|
||||
taker_buy_volume,
|
||||
taker_sell_volume,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
.map_err(map_err)
|
||||
}
|
||||
|
||||
fn deriv_liquidation(
|
||||
long_liquidation: f64,
|
||||
short_liquidation: f64,
|
||||
@@ -15165,6 +15617,288 @@ impl CalendarSpreadNode {
|
||||
}
|
||||
}
|
||||
|
||||
// Estimated leverage ratio: open interest over aggregate long+short size.
|
||||
#[napi(js_name = "EstimatedLeverageRatio")]
|
||||
pub struct EstimatedLeverageRatioNode {
|
||||
inner: wc::EstimatedLeverageRatio,
|
||||
}
|
||||
|
||||
impl Default for EstimatedLeverageRatioNode {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl EstimatedLeverageRatioNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::EstimatedLeverageRatio::new(),
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open_interest: f64,
|
||||
long_size: f64,
|
||||
short_size: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(deriv_oi_long_short(open_interest, long_size, short_size)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open_interest: Vec<f64>,
|
||||
long_size: Vec<f64>,
|
||||
short_size: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if open_interest.len() != long_size.len() || long_size.len() != short_size.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"open_interest, long_size, short_size must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(open_interest.len());
|
||||
for i in 0..open_interest.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(deriv_oi_long_short(
|
||||
open_interest[i],
|
||||
long_size[i],
|
||||
short_size[i],
|
||||
)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// OI-to-volume ratio: open interest over taker buy+sell volume.
|
||||
#[napi(js_name = "OiToVolumeRatio")]
|
||||
pub struct OiToVolumeRatioNode {
|
||||
inner: wc::OiToVolumeRatio,
|
||||
}
|
||||
|
||||
impl Default for OiToVolumeRatioNode {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl OiToVolumeRatioNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::OiToVolumeRatio::new(),
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open_interest: f64,
|
||||
taker_buy_volume: f64,
|
||||
taker_sell_volume: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(deriv_oi_taker(
|
||||
open_interest,
|
||||
taker_buy_volume,
|
||||
taker_sell_volume,
|
||||
)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
open_interest: Vec<f64>,
|
||||
taker_buy_volume: Vec<f64>,
|
||||
taker_sell_volume: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if open_interest.len() != taker_buy_volume.len()
|
||||
|| taker_buy_volume.len() != taker_sell_volume.len()
|
||||
{
|
||||
return Err(NapiError::from_reason(
|
||||
"open_interest, taker_buy_volume, taker_sell_volume must be equal length"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(open_interest.len());
|
||||
for i in 0..open_interest.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(deriv_oi_taker(
|
||||
open_interest[i],
|
||||
taker_buy_volume[i],
|
||||
taker_sell_volume[i],
|
||||
)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// Perpetual premium index: relative premium of mark over index price.
|
||||
#[napi(js_name = "PerpetualPremiumIndex")]
|
||||
pub struct PerpetualPremiumIndexNode {
|
||||
inner: wc::PerpetualPremiumIndex,
|
||||
}
|
||||
|
||||
impl Default for PerpetualPremiumIndexNode {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl PerpetualPremiumIndexNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: wc::PerpetualPremiumIndex::new(),
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, mark_price: f64, index_price: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(deriv_basis(mark_price, index_price)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, mark_price: Vec<f64>, index_price: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
if mark_price.len() != index_price.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"mark_price and index_price must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(mark_price.len());
|
||||
for i in 0..mark_price.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(deriv_basis(mark_price[i], index_price[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// Funding-implied APR: per-interval funding annualised.
|
||||
#[napi(js_name = "FundingImpliedApr")]
|
||||
pub struct FundingImpliedAprNode {
|
||||
inner: wc::FundingImpliedApr,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl FundingImpliedAprNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(intervals_per_year: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::FundingImpliedApr::new(intervals_per_year).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, funding_rate: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(deriv_funding(funding_rate)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, funding_rate: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
let mut out = Vec::with_capacity(funding_rate.len());
|
||||
for r in funding_rate {
|
||||
out.push(self.inner.update(deriv_funding(r)?).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// Open-interest momentum: rate-of-change of open interest over a window.
|
||||
#[napi(js_name = "OpenInterestMomentum")]
|
||||
pub struct OpenInterestMomentumNode {
|
||||
inner: wc::OpenInterestMomentum,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl OpenInterestMomentumNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(period: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::OpenInterestMomentum::new(period as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(&mut self, open_interest: f64) -> napi::Result<Option<f64>> {
|
||||
Ok(self.inner.update(deriv_oi(open_interest)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(&mut self, open_interest: Vec<f64>) -> napi::Result<Vec<f64>> {
|
||||
let mut out = Vec::with_capacity(open_interest.len());
|
||||
for oi in open_interest {
|
||||
out.push(self.inner.update(deriv_oi(oi)?).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Market Breadth (CrossSection input) ----------
|
||||
//
|
||||
// A breadth tick is the per-symbol state of the whole universe, passed as four
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.7.0"
|
||||
version = "0.7.3"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -25,6 +25,15 @@ from __future__ import annotations
|
||||
|
||||
from ._wickra import (
|
||||
__version__,
|
||||
M2Measure,
|
||||
UpsidePotentialRatio,
|
||||
GainToPainRatio,
|
||||
CommonSenseRatio,
|
||||
KRatio,
|
||||
TailRatio,
|
||||
MartinRatio,
|
||||
BurkeRatio,
|
||||
SterlingRatio,
|
||||
AUTOCORRPGRAM,
|
||||
EVENBETTERSINE,
|
||||
BANDPASS,
|
||||
@@ -350,6 +359,11 @@ from ._wickra import (
|
||||
Equivolume,
|
||||
CandleVolume,
|
||||
# Market Profile
|
||||
CompositeProfile,
|
||||
HighLowVolumeNodes,
|
||||
ProfileShape,
|
||||
SinglePrints,
|
||||
NakedPoc,
|
||||
ValueArea,
|
||||
VolumeProfile,
|
||||
TpoProfile,
|
||||
@@ -480,6 +494,11 @@ from ._wickra import (
|
||||
# Microstructure: footprint
|
||||
Footprint,
|
||||
# Derivatives
|
||||
OpenInterestMomentum,
|
||||
FundingImpliedApr,
|
||||
PerpetualPremiumIndex,
|
||||
OiToVolumeRatio,
|
||||
EstimatedLeverageRatio,
|
||||
FundingRate,
|
||||
FundingRateMean,
|
||||
FundingRateZScore,
|
||||
@@ -542,6 +561,15 @@ from ._wickra import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"M2Measure",
|
||||
"UpsidePotentialRatio",
|
||||
"GainToPainRatio",
|
||||
"CommonSenseRatio",
|
||||
"KRatio",
|
||||
"TailRatio",
|
||||
"MartinRatio",
|
||||
"BurkeRatio",
|
||||
"SterlingRatio",
|
||||
"AUTOCORRPGRAM",
|
||||
"EVENBETTERSINE",
|
||||
"BANDPASS",
|
||||
@@ -868,6 +896,11 @@ __all__ = [
|
||||
"Equivolume",
|
||||
"CandleVolume",
|
||||
# Market Profile
|
||||
"CompositeProfile",
|
||||
"HighLowVolumeNodes",
|
||||
"ProfileShape",
|
||||
"SinglePrints",
|
||||
"NakedPoc",
|
||||
"ValueArea",
|
||||
"VolumeProfile",
|
||||
"TpoProfile",
|
||||
@@ -998,6 +1031,11 @@ __all__ = [
|
||||
# Microstructure: footprint
|
||||
"Footprint",
|
||||
# Derivatives
|
||||
"OpenInterestMomentum",
|
||||
"FundingImpliedApr",
|
||||
"PerpetualPremiumIndex",
|
||||
"OiToVolumeRatio",
|
||||
"EstimatedLeverageRatio",
|
||||
"FundingRate",
|
||||
"FundingRateMean",
|
||||
"FundingRateZScore",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,15 @@ def ohlcv() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
# --- Scalar (f64 -> f64) indicators ---------------------------------------
|
||||
|
||||
SCALAR = [
|
||||
(ta.M2Measure, (20, 0.0, 0.02)),
|
||||
(ta.UpsidePotentialRatio, (20, 0.0)),
|
||||
(ta.GainToPainRatio, (12,)),
|
||||
(ta.CommonSenseRatio, (20,)),
|
||||
(ta.KRatio, (30,)),
|
||||
(ta.TailRatio, (20,)),
|
||||
(ta.MartinRatio, (14,)),
|
||||
(ta.BurkeRatio, (12,)),
|
||||
(ta.SterlingRatio, (12,)),
|
||||
(ta.AUTOCORRPGRAM, (10, 48)),
|
||||
(ta.EVENBETTERSINE, (40, 10)),
|
||||
(ta.BANDPASS, (20, 0.3)),
|
||||
@@ -383,6 +392,18 @@ def test_relative_strength_streaming_matches_batch():
|
||||
# 6-tuple candle; the batch helper takes only the columns it needs.
|
||||
|
||||
CANDLE_SCALAR = {
|
||||
"ProfileShape": (
|
||||
lambda: ta.ProfileShape(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
),
|
||||
"SinglePrints": (
|
||||
lambda: ta.SinglePrints(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l),
|
||||
),
|
||||
"NakedPoc": (
|
||||
lambda: ta.NakedPoc(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c, v),
|
||||
),
|
||||
"FryPanBottom": (
|
||||
lambda: ta.FryPanBottom(9),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, c),
|
||||
@@ -1009,6 +1030,16 @@ def test_candle_scalar_streaming_matches_batch(name, ohlcv):
|
||||
# --- Candle-input, multi-output indicators --------------------------------
|
||||
|
||||
MULTI = {
|
||||
"CompositeProfile": (
|
||||
lambda: ta.CompositeProfile(20, 24, 0.7),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
3,
|
||||
),
|
||||
"HighLowVolumeNodes": (
|
||||
lambda: ta.HighLowVolumeNodes(20, 24),
|
||||
lambda ind, h, l, c, v: ind.batch(h, l, v),
|
||||
2,
|
||||
),
|
||||
"CandleVolume": (
|
||||
lambda: ta.CandleVolume(20),
|
||||
lambda ind, h, l, c, v: ind.batch(c, c, v),
|
||||
@@ -3331,6 +3362,26 @@ def test_hasbrouck_information_share_reference():
|
||||
assert t.update(7.0, 9.0) is None
|
||||
assert t.update(7.0, 9.0) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_naked_poc_reference():
|
||||
t = ta.NakedPoc(20, 24)
|
||||
|
||||
|
||||
def test_single_prints_reference():
|
||||
t = ta.SinglePrints(20, 24)
|
||||
|
||||
|
||||
def test_profile_shape_reference():
|
||||
t = ta.ProfileShape(20, 24)
|
||||
|
||||
|
||||
def test_high_low_volume_nodes_reference():
|
||||
t = ta.HighLowVolumeNodes(20, 24)
|
||||
|
||||
|
||||
def test_composite_profile_reference():
|
||||
t = ta.CompositeProfile(20, 24, 0.7)
|
||||
|
||||
# --- Lifecycle ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -4082,6 +4133,71 @@ def test_basis_indicators_streaming_equals_batch():
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_b16_derivatives_reference():
|
||||
# Estimated leverage: oi / (long + short) = 200 / 100 = 2.
|
||||
assert ta.EstimatedLeverageRatio().update(200.0, 60.0, 40.0) == pytest.approx(2.0)
|
||||
# OI-to-volume: oi / (buy + sell) = 100 / 50 = 2.
|
||||
assert ta.OiToVolumeRatio().update(100.0, 30.0, 20.0) == pytest.approx(2.0)
|
||||
# Perpetual premium: (mark - index) / index = 0.5 / 100 = 0.005.
|
||||
assert ta.PerpetualPremiumIndex().update(100.5, 100.0) == pytest.approx(0.005)
|
||||
# Funding-implied APR: rate * intervals = 0.0001 * 1095 = 0.1095.
|
||||
assert ta.FundingImpliedApr(1095.0).update(0.0001) == pytest.approx(0.1095)
|
||||
# Open-interest momentum (period 2): warmup then ROC% = 100*(120-100)/100 = 20.
|
||||
oim = ta.OpenInterestMomentum(2)
|
||||
assert oim.update(100.0) is None
|
||||
assert oim.update(110.0) is None
|
||||
assert oim.update(120.0) == pytest.approx(20.0)
|
||||
|
||||
|
||||
def test_b16_derivatives_streaming_equals_batch():
|
||||
n = 40
|
||||
oi = np.array([1000.0 + 50.0 * math.sin(i * 0.3) for i in range(n)], dtype=np.float64)
|
||||
long_sz = np.array([600.0 + 20.0 * math.cos(i * 0.2) for i in range(n)], dtype=np.float64)
|
||||
short_sz = np.array([400.0 + 15.0 * math.sin(i * 0.4) for i in range(n)], dtype=np.float64)
|
||||
buy = np.array([300.0 + 10.0 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64)
|
||||
sell = np.array([250.0 + 12.0 * math.cos(i * 0.35) for i in range(n)], dtype=np.float64)
|
||||
index = np.array([100.0 + math.sin(i * 0.2) for i in range(n)], dtype=np.float64)
|
||||
mark = np.array([index[i] + 0.05 * math.cos(i * 0.3) for i in range(n)], dtype=np.float64)
|
||||
rate = np.array([0.0001 * math.sin(i * 0.3) for i in range(n)], dtype=np.float64)
|
||||
|
||||
# EstimatedLeverageRatio; update(open_interest, long_size, short_size).
|
||||
batch = ta.EstimatedLeverageRatio().batch(oi, long_sz, short_sz)
|
||||
streamer = ta.EstimatedLeverageRatio()
|
||||
streamed = np.array(
|
||||
[streamer.update(oi[i], long_sz[i], short_sz[i]) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
assert batch.shape == (n,)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# OiToVolumeRatio; update(open_interest, taker_buy_volume, taker_sell_volume).
|
||||
batch = ta.OiToVolumeRatio().batch(oi, buy, sell)
|
||||
streamer = ta.OiToVolumeRatio()
|
||||
streamed = np.array(
|
||||
[streamer.update(oi[i], buy[i], sell[i]) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# PerpetualPremiumIndex; update(mark_price, index_price).
|
||||
batch = ta.PerpetualPremiumIndex().batch(mark, index)
|
||||
streamer = ta.PerpetualPremiumIndex()
|
||||
streamed = np.array(
|
||||
[streamer.update(mark[i], index[i]) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# FundingImpliedApr; update(funding_rate).
|
||||
batch = ta.FundingImpliedApr(1095.0).batch(rate)
|
||||
streamer = ta.FundingImpliedApr(1095.0)
|
||||
streamed = np.array([streamer.update(rate[i]) for i in range(n)], dtype=np.float64)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
# OpenInterestMomentum; update(open_interest).
|
||||
batch = ta.OpenInterestMomentum(10).batch(oi)
|
||||
streamer = ta.OpenInterestMomentum(10)
|
||||
streamed = np.array([streamer.update(oi[i]) for i in range(n)], dtype=np.float64)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
# --- Alt-Chart Bars ------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -8526,6 +8526,298 @@ impl WasmValueArea {
|
||||
}
|
||||
}
|
||||
|
||||
// Naked POC: most recent untouched point-of-control level (Candle -> f64).
|
||||
#[wasm_bindgen(js_name = NakedPoc)]
|
||||
pub struct WasmNakedPoc {
|
||||
inner: wc::NakedPoc,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = NakedPoc)]
|
||||
impl WasmNakedPoc {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(session_len: usize, bin_count: usize) -> Result<WasmNakedPoc, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::NakedPoc::new(session_len, bin_count).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
high: f64,
|
||||
low: f64,
|
||||
close: f64,
|
||||
volume: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self.inner.update(make_candle(high, low, close, volume)?))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
close: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != close.len() || close.len() != volume.len() {
|
||||
return Err(JsError::new(
|
||||
"high, low, close, volume must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(close.len());
|
||||
for i in 0..close.len() {
|
||||
out.push(
|
||||
self.inner
|
||||
.update(make_candle(high[i], low[i], close[i], volume[i])?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// Single Prints: count of single-print price levels (Candle -> f64).
|
||||
#[wasm_bindgen(js_name = SinglePrints)]
|
||||
pub struct WasmSinglePrints {
|
||||
inner: wc::SinglePrints,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = SinglePrints)]
|
||||
impl WasmSinglePrints {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, bin_count: usize) -> Result<WasmSinglePrints, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::SinglePrints::new(period, bin_count).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64) -> Result<Option<f64>, JsError> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
Ok(self
|
||||
.inner
|
||||
.update(wc::Candle::new(mid, high, low, mid, 0.0, 0).map_err(map_err)?))
|
||||
}
|
||||
pub fn batch(&mut self, high: &[f64], low: &[f64]) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() {
|
||||
return Err(JsError::new("high and low must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
out.push(
|
||||
self.inner
|
||||
.update(wc::Candle::new(mid, high[i], low[i], mid, 0.0, 0).map_err(map_err)?)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// Profile Shape: b/P/D classification as a numeric code (Candle -> f64).
|
||||
#[wasm_bindgen(js_name = ProfileShape)]
|
||||
pub struct WasmProfileShape {
|
||||
inner: wc::ProfileShape,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = ProfileShape)]
|
||||
impl WasmProfileShape {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, bin_count: usize) -> Result<WasmProfileShape, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::ProfileShape::new(period, bin_count).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<Option<f64>, JsError> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
Ok(self
|
||||
.inner
|
||||
.update(wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?))
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(JsError::new("high, low, volume must be equal length"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(high.len());
|
||||
for i in 0..high.len() {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
out.push(
|
||||
self.inner
|
||||
.update(
|
||||
wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0)
|
||||
.map_err(map_err)?,
|
||||
)
|
||||
.unwrap_or(f64::NAN),
|
||||
);
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// High/Low Volume Nodes: highest- and lowest-volume price nodes (Candle -> struct).
|
||||
#[wasm_bindgen(js_name = HighLowVolumeNodes)]
|
||||
pub struct WasmHighLowVolumeNodes {
|
||||
inner: wc::HighLowVolumeNodes,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = HighLowVolumeNodes)]
|
||||
impl WasmHighLowVolumeNodes {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize, bin_count: usize) -> Result<WasmHighLowVolumeNodes, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::HighLowVolumeNodes::new(period, bin_count).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(JsError::new("high, low, volume must be equal length"));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 2];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let c = wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 2] = o.hvn;
|
||||
out[i * 2 + 1] = o.lvn;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
/// Streaming update. Returns `{ hvn, lvn }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<JsValue, JsError> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let c = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"hvn".into(), &o.hvn.into()).ok();
|
||||
Reflect::set(&obj, &"lvn".into(), &o.lvn.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// Composite Profile: multi-session composite volume profile (Candle -> struct).
|
||||
#[wasm_bindgen(js_name = CompositeProfile)]
|
||||
pub struct WasmCompositeProfile {
|
||||
inner: wc::CompositeProfile,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = CompositeProfile)]
|
||||
impl WasmCompositeProfile {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(
|
||||
period: usize,
|
||||
bin_count: usize,
|
||||
value_area_pct: f64,
|
||||
) -> Result<WasmCompositeProfile, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::CompositeProfile::new(period, bin_count, value_area_pct).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
high: &[f64],
|
||||
low: &[f64],
|
||||
volume: &[f64],
|
||||
) -> Result<Float64Array, JsError> {
|
||||
if high.len() != low.len() || low.len() != volume.len() {
|
||||
return Err(JsError::new("high, low, volume must be equal length"));
|
||||
}
|
||||
let n = high.len();
|
||||
let mut out = vec![f64::NAN; n * 3];
|
||||
for i in 0..n {
|
||||
let mid = f64::midpoint(high[i], low[i]);
|
||||
let c = wc::Candle::new(mid, high[i], low[i], mid, volume[i], 0).map_err(map_err)?;
|
||||
if let Some(o) = self.inner.update(c) {
|
||||
out[i * 3] = o.poc;
|
||||
out[i * 3 + 1] = o.vah;
|
||||
out[i * 3 + 2] = o.val;
|
||||
}
|
||||
}
|
||||
Ok(Float64Array::from(out.as_slice()))
|
||||
}
|
||||
/// Streaming update. Returns `{ poc, vah, val }` once warm, else `null`.
|
||||
pub fn update(&mut self, high: f64, low: f64, volume: f64) -> Result<JsValue, JsError> {
|
||||
let mid = f64::midpoint(high, low);
|
||||
let c = wc::Candle::new(mid, high, low, mid, volume, 0).map_err(map_err)?;
|
||||
Ok(match self.inner.update(c) {
|
||||
Some(o) => {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"poc".into(), &o.poc.into()).ok();
|
||||
Reflect::set(&obj, &"vah".into(), &o.vah.into()).ok();
|
||||
Reflect::set(&obj, &"val".into(), &o.val.into()).ok();
|
||||
obj.into()
|
||||
}
|
||||
None => JsValue::NULL,
|
||||
})
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_name = VolumeProfile)]
|
||||
pub struct WasmVolumeProfile {
|
||||
inner: wc::VolumeProfile,
|
||||
@@ -9933,6 +10225,50 @@ fn deriv_taker(
|
||||
.map_err(map_err)
|
||||
}
|
||||
|
||||
fn deriv_oi_long_short(
|
||||
open_interest: f64,
|
||||
long_size: f64,
|
||||
short_size: f64,
|
||||
) -> Result<wc::DerivativesTick, JsError> {
|
||||
wc::DerivativesTick::new(
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
open_interest,
|
||||
long_size,
|
||||
short_size,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
.map_err(map_err)
|
||||
}
|
||||
|
||||
fn deriv_oi_taker(
|
||||
open_interest: f64,
|
||||
taker_buy_volume: f64,
|
||||
taker_sell_volume: f64,
|
||||
) -> Result<wc::DerivativesTick, JsError> {
|
||||
wc::DerivativesTick::new(
|
||||
0.0,
|
||||
1.0,
|
||||
1.0,
|
||||
1.0,
|
||||
open_interest,
|
||||
0.0,
|
||||
0.0,
|
||||
taker_buy_volume,
|
||||
taker_sell_volume,
|
||||
0.0,
|
||||
0.0,
|
||||
0,
|
||||
)
|
||||
.map_err(map_err)
|
||||
}
|
||||
|
||||
fn deriv_liquidation(
|
||||
long_liquidation: f64,
|
||||
short_liquidation: f64,
|
||||
@@ -10256,6 +10592,195 @@ impl WasmCalendarSpread {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Estimated Leverage Ratio ----------
|
||||
|
||||
#[wasm_bindgen(js_name = EstimatedLeverageRatio)]
|
||||
pub struct WasmEstimatedLeverageRatio {
|
||||
inner: wc::EstimatedLeverageRatio,
|
||||
}
|
||||
|
||||
impl Default for WasmEstimatedLeverageRatio {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = EstimatedLeverageRatio)]
|
||||
impl WasmEstimatedLeverageRatio {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmEstimatedLeverageRatio {
|
||||
Self {
|
||||
inner: wc::EstimatedLeverageRatio::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open_interest: f64,
|
||||
long_size: f64,
|
||||
short_size: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(deriv_oi_long_short(open_interest, long_size, short_size)?))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- OI-to-Volume Ratio ----------
|
||||
|
||||
#[wasm_bindgen(js_name = OiToVolumeRatio)]
|
||||
pub struct WasmOiToVolumeRatio {
|
||||
inner: wc::OiToVolumeRatio,
|
||||
}
|
||||
|
||||
impl Default for WasmOiToVolumeRatio {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = OiToVolumeRatio)]
|
||||
impl WasmOiToVolumeRatio {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmOiToVolumeRatio {
|
||||
Self {
|
||||
inner: wc::OiToVolumeRatio::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
open_interest: f64,
|
||||
taker_buy_volume: f64,
|
||||
taker_sell_volume: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self.inner.update(deriv_oi_taker(
|
||||
open_interest,
|
||||
taker_buy_volume,
|
||||
taker_sell_volume,
|
||||
)?))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Perpetual Premium Index ----------
|
||||
|
||||
#[wasm_bindgen(js_name = PerpetualPremiumIndex)]
|
||||
pub struct WasmPerpetualPremiumIndex {
|
||||
inner: wc::PerpetualPremiumIndex,
|
||||
}
|
||||
|
||||
impl Default for WasmPerpetualPremiumIndex {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = PerpetualPremiumIndex)]
|
||||
impl WasmPerpetualPremiumIndex {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> WasmPerpetualPremiumIndex {
|
||||
Self {
|
||||
inner: wc::PerpetualPremiumIndex::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(&mut self, mark_price: f64, index_price: f64) -> Result<Option<f64>, JsError> {
|
||||
Ok(self.inner.update(deriv_basis(mark_price, index_price)?))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Funding-Implied APR ----------
|
||||
|
||||
#[wasm_bindgen(js_name = FundingImpliedApr)]
|
||||
pub struct WasmFundingImpliedApr {
|
||||
inner: wc::FundingImpliedApr,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = FundingImpliedApr)]
|
||||
impl WasmFundingImpliedApr {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(intervals_per_year: f64) -> Result<WasmFundingImpliedApr, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::FundingImpliedApr::new(intervals_per_year).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, funding_rate: f64) -> Result<Option<f64>, JsError> {
|
||||
Ok(self.inner.update(deriv_funding(funding_rate)?))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Open-Interest Momentum ----------
|
||||
|
||||
#[wasm_bindgen(js_name = OpenInterestMomentum)]
|
||||
pub struct WasmOpenInterestMomentum {
|
||||
inner: wc::OpenInterestMomentum,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = OpenInterestMomentum)]
|
||||
impl WasmOpenInterestMomentum {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(period: usize) -> Result<WasmOpenInterestMomentum, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::OpenInterestMomentum::new(period).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, open_interest: f64) -> Result<Option<f64>, JsError> {
|
||||
Ok(self.inner.update(deriv_oi(open_interest)?))
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Heikin-Ashi Oscillator ----------
|
||||
|
||||
#[wasm_bindgen(js_name = HeikinAshiOscillator)]
|
||||
@@ -12230,6 +12755,15 @@ wasm_scalar_indicator!(WasmUniversalOscillator, "UNIVERSALOSC", wc::UniversalOsc
|
||||
wasm_scalar_indicator!(WasmBandpassFilter, "BANDPASS", wc::BandpassFilter, period: usize, bandwidth: f64);
|
||||
wasm_scalar_indicator!(WasmEvenBetterSinewave, "EVENBETTERSINE", wc::EvenBetterSinewave, hp_period: usize, ssf_length: usize);
|
||||
wasm_scalar_indicator!(WasmAutocorrelationPeriodogram, "AUTOCORRPGRAM", wc::AutocorrelationPeriodogram, min_period: usize, max_period: usize);
|
||||
wasm_scalar_indicator!(WasmSterlingRatio, "SterlingRatio", wc::SterlingRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmBurkeRatio, "BurkeRatio", wc::BurkeRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmMartinRatio, "MartinRatio", wc::MartinRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmTailRatio, "TailRatio", wc::TailRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmKRatio, "KRatio", wc::KRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmCommonSenseRatio, "CommonSenseRatio", wc::CommonSenseRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmGainToPainRatio, "GainToPainRatio", wc::GainToPainRatio, period: usize);
|
||||
wasm_scalar_indicator!(WasmUpsidePotentialRatio, "UpsidePotentialRatio", wc::UpsidePotentialRatio, period: usize, mar: f64);
|
||||
wasm_scalar_indicator!(WasmM2Measure, "M2Measure", wc::M2Measure, period: usize, risk_free: f64, benchmark_stddev: f64);
|
||||
|
||||
// --- VolatilityCone: Candle in, struct out (current/min/median/max/percentile) ---
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Burke Ratio — mean return over the square root of the summed squared drawdowns.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Burke Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||
/// peak_t = max_{s<=t} equity_s
|
||||
/// dd_t = (peak_t − equity_t) / peak_t (fractional drawdown, >= 0)
|
||||
/// Burke = mean(returns) / sqrt( Σ dd_t² )
|
||||
/// ```
|
||||
///
|
||||
/// The Burke Ratio divides the average per-period return by the **Euclidean norm of
|
||||
/// the drawdowns** — the square root of the *sum* of squared drawdowns. Squaring
|
||||
/// penalises deep drawdowns far more than shallow ones, and summing (rather than
|
||||
/// averaging) means the denominator grows with both the depth and the *number* of
|
||||
/// drawdowns. This makes Burke the most outlier-sensitive of Wickra's three
|
||||
/// drawdown ratios: where the [`SterlingRatio`](crate::SterlingRatio) averages raw
|
||||
/// drawdowns and shrugs off a single crater, Burke makes that crater dominate.
|
||||
/// The [`MartinRatio`](crate::MartinRatio) sits between them with a root-*mean*
|
||||
/// square of percentage drawdowns. A window that never draws down has a zero
|
||||
/// denominator and the indicator reports `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||
/// series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, BurkeRatio};
|
||||
///
|
||||
/// let mut indicator = BurkeRatio::new(12).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..24 {
|
||||
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BurkeRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl BurkeRatio {
|
||||
/// Construct a Burke Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "burke ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let length = self.window.len() as f64;
|
||||
let mut sum_return = 0.0;
|
||||
let mut sum_drawdown_sq = 0.0;
|
||||
let mut equity = 1.0;
|
||||
let mut peak: f64 = 1.0;
|
||||
for ret in &self.window {
|
||||
sum_return += *ret;
|
||||
equity *= 1.0 + *ret;
|
||||
peak = peak.max(equity);
|
||||
let drawdown = (peak - equity) / peak;
|
||||
sum_drawdown_sq += drawdown * drawdown;
|
||||
}
|
||||
let denom = sum_drawdown_sq.sqrt();
|
||||
if denom > 0.0 {
|
||||
(sum_return / length) / denom
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for BurkeRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
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 {
|
||||
"BurkeRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
BurkeRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let br = BurkeRatio::new(12).unwrap();
|
||||
assert_eq!(br.period(), 12);
|
||||
assert_eq!(br.warmup_period(), 12);
|
||||
assert_eq!(br.name(), "BurkeRatio");
|
||||
assert!(!br.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.1, -0.1, 0.1]: dd = [0, 0.1, 0.01].
|
||||
// Σ dd² = 0.01 + 0.0001 = 0.0101; denom = sqrt(0.0101).
|
||||
// Burke = (0.1/3) / sqrt(0.0101).
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let out = br.batch(&[0.1, -0.1, 0.1]);
|
||||
let expected = (0.1_f64 / 3.0) / (0.0101_f64).sqrt();
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drawdown_is_zero() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let last = br
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_window_is_negative() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
let last = br
|
||||
.batch(&[-0.05, -0.02, -0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
assert_eq!(br.update(0.1), None);
|
||||
assert_eq!(br.update(f64::NAN), None);
|
||||
assert_eq!(br.update(-0.1), None);
|
||||
assert!(br.update(0.1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut br = BurkeRatio::new(3).unwrap();
|
||||
br.batch(&[0.1, -0.1, 0.1]);
|
||||
assert!(br.is_ready());
|
||||
br.reset();
|
||||
assert!(!br.is_ready());
|
||||
assert_eq!(br.update(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||
.collect();
|
||||
let batch = BurkeRatio::new(12).unwrap().batch(&rets);
|
||||
let mut streamer = BurkeRatio::new(12).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! Common Sense Ratio (Schwager / Carver) — profit factor multiplied by the tail ratio.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Common Sense Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// ProfitFactor = Σ gains / Σ |losses| over the window
|
||||
/// TailRatio = P95(returns) / |P5(returns)| over the window
|
||||
/// CSR = ProfitFactor · TailRatio
|
||||
/// ```
|
||||
///
|
||||
/// The Common Sense Ratio fuses two views of a return series into one number. The
|
||||
/// [profit factor](crate::ProfitFactor) captures the *body* of the distribution —
|
||||
/// how much you make per unit you lose on the average bar. The
|
||||
/// [`TailRatio`](crate::TailRatio) captures the *extremes* — whether the largest
|
||||
/// gains outweigh the largest losses. Multiplying them produces a ratio that is
|
||||
/// only comfortably above `1.0` when a strategy wins on both fronts: a respectable
|
||||
/// profit factor can still hide catastrophic left-tail risk, and a fat right tail
|
||||
/// means little if the body bleeds. Above `1.0` the strategy is sound on a
|
||||
/// common-sense basis; below `1.0` something — body or tail — is working against it.
|
||||
///
|
||||
/// Percentiles use linear interpolation over the sorted window. A window with no
|
||||
/// losses (zero profit-factor denominator) or no left tail (zero P5) reports `0.0`
|
||||
/// rather than dividing by zero.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` re-sorts the window
|
||||
/// (O(period log period)), which is O(1) in the length of the overall series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, CommonSenseRatio};
|
||||
///
|
||||
/// let mut indicator = CommonSenseRatio::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommonSenseRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl CommonSenseRatio {
|
||||
/// Construct a Common Sense Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
|
||||
/// two observations).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "common sense ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let mut gains = 0.0;
|
||||
let mut losses = 0.0;
|
||||
for ret in &self.window {
|
||||
gains += ret.max(0.0);
|
||||
losses += (-ret).max(0.0);
|
||||
}
|
||||
if losses <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
|
||||
sorted.sort_unstable_by(f64::total_cmp);
|
||||
let lower_tail = percentile(&sorted, 5.0).abs();
|
||||
if lower_tail <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let profit_factor = gains / losses;
|
||||
let tail_ratio = percentile(&sorted, 95.0) / lower_tail;
|
||||
profit_factor * tail_ratio
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear-interpolation percentile of an ascending, non-empty slice.
|
||||
fn percentile(sorted: &[f64], pct: f64) -> f64 {
|
||||
let last_index = sorted.len() - 1;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let rank = pct / 100.0 * last_index as f64;
|
||||
let floor = rank.floor();
|
||||
// `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let lower = floor as usize;
|
||||
if lower >= last_index {
|
||||
return sorted[last_index];
|
||||
}
|
||||
let frac = rank - floor;
|
||||
sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
|
||||
}
|
||||
|
||||
impl Indicator for CommonSenseRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
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 {
|
||||
"CommonSenseRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
CommonSenseRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let csr = CommonSenseRatio::new(20).unwrap();
|
||||
assert_eq!(csr.period(), 20);
|
||||
assert_eq!(csr.warmup_period(), 20);
|
||||
assert_eq!(csr.name(), "CommonSenseRatio");
|
||||
assert!(!csr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// window [-0.04, -0.02, 0.0, 0.02, 0.04].
|
||||
// gains = 0.06, losses = 0.06 -> profit factor 1.0.
|
||||
// P95 = 0.036, |P5| = 0.036 -> tail ratio 1.0. CSR = 1.0.
|
||||
let mut csr = CommonSenseRatio::new(5).unwrap();
|
||||
let out = csr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
|
||||
assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_losses_is_zero() {
|
||||
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||
let last = csr
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
// All zeros: no losses denominator -> zero (the gains/losses guard fires).
|
||||
let mut csr = CommonSenseRatio::new(4).unwrap();
|
||||
let last = csr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||
assert_eq!(csr.update(0.01), None);
|
||||
assert_eq!(csr.update(f64::NAN), None);
|
||||
assert_eq!(csr.update(-0.02), None);
|
||||
assert!(csr.update(0.03).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut csr = CommonSenseRatio::new(3).unwrap();
|
||||
csr.batch(&[-0.01, 0.0, 0.02]);
|
||||
assert!(csr.is_ready());
|
||||
csr.reset();
|
||||
assert!(!csr.is_ready());
|
||||
assert_eq!(csr.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.02)
|
||||
.collect();
|
||||
let batch = CommonSenseRatio::new(15).unwrap().batch(&rets);
|
||||
let mut streamer = CommonSenseRatio::new(15).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentile_at_top_returns_last() {
|
||||
// The rank floor reaching the final index returns the largest element.
|
||||
assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_lower_tail_is_zero() {
|
||||
// One loss but a 5th percentile of exactly zero: the tail term collapses
|
||||
// and the indicator reports 0.0 rather than dividing by zero. With period
|
||||
// 21 the 5% rank lands on sorted index 1, which is 0.0 here.
|
||||
let mut returns = vec![0.0; 21];
|
||||
returns[0] = -0.1;
|
||||
let mut csr = CommonSenseRatio::new(21).unwrap();
|
||||
let last = csr.batch(&returns).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Composite Profile — POC and value area over a long composite window.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`CompositeProfile`]: the point of control and the value-area bounds.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CompositeProfileOutput {
|
||||
/// Point of Control — the price (bin centre) with the most volume.
|
||||
pub poc: f64,
|
||||
/// Value-Area High — top of the band holding `value_area_pct` of volume.
|
||||
pub vah: f64,
|
||||
/// Value-Area Low — bottom of that band.
|
||||
pub val: f64,
|
||||
}
|
||||
|
||||
/// Composite Profile — a multi-session volume profile reduced to its **point of
|
||||
/// control** and **value area**, built over a long composite window.
|
||||
///
|
||||
/// ```text
|
||||
/// build a `bins`-bucket volume profile over the last `period` candles
|
||||
/// POC = bin with the most volume
|
||||
/// expand from the POC, always adding the heavier adjacent bin, until the
|
||||
/// accumulated volume reaches `value_area_pct` of the total
|
||||
/// VAH / VAL = the highest / lowest price included
|
||||
/// ```
|
||||
///
|
||||
/// A composite profile merges many sessions into one structure to reveal the
|
||||
/// dominant value area and control price across a longer horizon — the levels that
|
||||
/// matter for swing positioning rather than a single day. The point of control is
|
||||
/// the fairest price (heaviest trade); the value area (classically 70% of volume)
|
||||
/// brackets where the market spent most of its time. Price inside the value area is
|
||||
/// "in balance"; acceptance outside it signals a value migration.
|
||||
///
|
||||
/// The first value lands after `period` candles; each `update` rebuilds the
|
||||
/// profile in O(`period · bins`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, CompositeProfile};
|
||||
///
|
||||
/// let mut indicator = CompositeProfile::new(100, 50, 0.70).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..150 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.1).sin() * 8.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompositeProfile {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
value_area_pct: f64,
|
||||
window: VecDeque<Candle>,
|
||||
last: Option<CompositeProfileOutput>,
|
||||
}
|
||||
|
||||
impl CompositeProfile {
|
||||
/// Construct a Composite Profile.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `bins` is zero, or
|
||||
/// [`Error::InvalidParameter`] if `value_area_pct` is not in `(0, 1]`.
|
||||
pub fn new(period: usize, bins: usize, value_area_pct: f64) -> Result<Self> {
|
||||
if period == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if !value_area_pct.is_finite() || value_area_pct <= 0.0 || value_area_pct > 1.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "value_area_pct must be in (0, 1]",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
value_area_pct,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins, value_area_pct)`.
|
||||
pub const fn params(&self) -> (usize, usize, f64) {
|
||||
(self.period, self.bins, self.value_area_pct)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<CompositeProfileOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn compute(&self) -> CompositeProfileOutput {
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = f64::NEG_INFINITY;
|
||||
for c in &self.window {
|
||||
low = low.min(c.low);
|
||||
high = high.max(c.high);
|
||||
}
|
||||
let span = high - low;
|
||||
if span <= 0.0 {
|
||||
return CompositeProfileOutput {
|
||||
poc: low,
|
||||
vah: low,
|
||||
val: low,
|
||||
};
|
||||
}
|
||||
let width = span / self.bins as f64;
|
||||
let centre = |idx: usize| low + (idx as f64 + 0.5) * width;
|
||||
let mut hist = vec![0.0; self.bins];
|
||||
for c in &self.window {
|
||||
if c.volume == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let share = c.volume / (hi_idx - lo_idx + 1) as f64;
|
||||
for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
|
||||
*bin += share;
|
||||
}
|
||||
}
|
||||
let total: f64 = hist.iter().sum();
|
||||
let mut poc = 0;
|
||||
let mut poc_vol = f64::NEG_INFINITY;
|
||||
for (idx, &vol) in hist.iter().enumerate() {
|
||||
if vol > poc_vol {
|
||||
poc_vol = vol;
|
||||
poc = idx;
|
||||
}
|
||||
}
|
||||
let target = total * self.value_area_pct;
|
||||
let mut acc = hist[poc];
|
||||
let mut top = poc;
|
||||
let mut bottom = poc;
|
||||
while acc < target && (top < self.bins - 1 || bottom > 0) {
|
||||
let above = if top < self.bins - 1 {
|
||||
hist[top + 1]
|
||||
} else {
|
||||
f64::NEG_INFINITY
|
||||
};
|
||||
let below = if bottom > 0 {
|
||||
hist[bottom - 1]
|
||||
} else {
|
||||
f64::NEG_INFINITY
|
||||
};
|
||||
if above >= below {
|
||||
top += 1;
|
||||
acc += hist[top];
|
||||
} else {
|
||||
bottom -= 1;
|
||||
acc += hist[bottom];
|
||||
}
|
||||
}
|
||||
CompositeProfileOutput {
|
||||
poc: centre(poc),
|
||||
vah: centre(top),
|
||||
val: centre(bottom),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for CompositeProfile {
|
||||
type Input = Candle;
|
||||
type Output = CompositeProfileOutput;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<CompositeProfileOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let out = self.compute();
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"CompositeProfile"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
volume,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(0, 50, 0.7),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(100, 0, 0.7),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(100, 50, 0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
CompositeProfile::new(100, 50, 1.5),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = CompositeProfile::new(100, 50, 0.7).unwrap();
|
||||
assert_eq!(p.params(), (100, 50, 0.7));
|
||||
assert_eq!(p.warmup_period(), 100);
|
||||
assert_eq!(p.name(), "CompositeProfile");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
|
||||
let out = p.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_area_brackets_poc() {
|
||||
let mut p = CompositeProfile::new(20, 30, 0.7).unwrap();
|
||||
let candles: Vec<Candle> = (0..40)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.3).sin() * 8.0,
|
||||
90.0 + (f64::from(i) * 0.3).cos() * 8.0,
|
||||
1_000.0,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for o in p.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.val <= o.poc && o.poc <= o.vah);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn poc_at_heavy_cluster() {
|
||||
// Volume clustered at ~100; thin pokes elsewhere -> POC near 100.
|
||||
let mut p = CompositeProfile::new(6, 30, 0.7).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
|
||||
candles.push(c(140.0, 60.0, 50.0));
|
||||
let out = p.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
(out.poc - 100.0).abs() < 5.0,
|
||||
"POC should sit at the cluster, got {}",
|
||||
out.poc
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = CompositeProfile::new(4, 8, 0.7).unwrap();
|
||||
p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..120)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = CompositeProfile::new(50, 50, 0.7).unwrap().batch(&candles);
|
||||
let mut b = CompositeProfile::new(50, 50, 0.7).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_collapses_to_price() {
|
||||
// Zero high-low span returns the price for POC, VAH and VAL.
|
||||
let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
|
||||
cp.update(c(50.0, 50.0, 10.0));
|
||||
let out = cp.update(c(50.0, 50.0, 10.0)).unwrap();
|
||||
assert_eq!(out.poc, out.vah);
|
||||
assert_eq!(out.poc, out.val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_is_handled() {
|
||||
// Non-flat window of zero-volume candles hits the skip path.
|
||||
let mut cp = CompositeProfile::new(2, 4, 0.7).unwrap();
|
||||
cp.update(c(60.0, 40.0, 0.0));
|
||||
assert!(cp.update(c(60.0, 40.0, 0.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_area_expands_down_from_top_poc() {
|
||||
// POC sits in the top bin; with a wide value-area target the area runs
|
||||
// out of bins above (the ceiling branch) and keeps expanding downward.
|
||||
let mut cp = CompositeProfile::new(2, 3, 0.9).unwrap();
|
||||
cp.update(c(100.0, 0.0, 30.0)); // thin spread across all three bins
|
||||
let out = cp.update(c(100.0, 67.0, 60.0)).unwrap(); // heavy in the top bin
|
||||
assert!(out.val <= out.poc && out.poc <= out.vah);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Estimated Leverage Ratio — open interest per unit of aggregate position size.
|
||||
|
||||
use crate::derivatives::DerivativesTick;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Estimated Leverage Ratio (ELR) — open interest relative to the aggregate
|
||||
/// long+short position size, a proxy for how leveraged outstanding positions are.
|
||||
///
|
||||
/// ```text
|
||||
/// ELR = open_interest / (long_size + short_size)
|
||||
/// ```
|
||||
///
|
||||
/// The classic estimated leverage ratio compares open interest (the notional of
|
||||
/// outstanding contracts) to the capital backing it. With the size fields of a
|
||||
/// [`DerivativesTick`] standing in for the position base, the ratio rises when a
|
||||
/// given pool of positions controls more open interest — i.e. when the market is
|
||||
/// running hotter leverage. Spikes in ELR mark crowded, fragile conditions where a
|
||||
/// move can cascade into liquidations; a falling ELR marks deleveraging.
|
||||
///
|
||||
/// The ratio is non-negative; a tick with zero aggregate size reports `0` rather
|
||||
/// than dividing by zero. It is stateless — each tick yields one value (no warmup).
|
||||
/// Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativesTick, Indicator, EstimatedLeverageRatio};
|
||||
///
|
||||
/// let mut indicator = EstimatedLeverageRatio::new();
|
||||
/// let tick = DerivativesTick::new(0.0001, 100.0, 100.0, 100.0, 1_000.0, 400.0, 600.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
|
||||
/// let elr = indicator.update(tick).unwrap();
|
||||
/// assert!((elr - 1.0).abs() < 1e-12); // 1000 / (400 + 600)
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EstimatedLeverageRatio {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl EstimatedLeverageRatio {
|
||||
/// Construct a new Estimated Leverage Ratio. The indicator is parameter-free.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EstimatedLeverageRatio {
|
||||
type Input = DerivativesTick;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
|
||||
let base = tick.long_size + tick.short_size;
|
||||
let elr = if base > 0.0 {
|
||||
tick.open_interest / base
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.ready = true;
|
||||
Some(elr)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EstimatedLeverageRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn tick(oi: f64, long: f64, short: f64) -> DerivativesTick {
|
||||
DerivativesTick::new_unchecked(
|
||||
0.0, 100.0, 100.0, 100.0, oi, long, short, 0.0, 0.0, 0.0, 0.0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let e = EstimatedLeverageRatio::new();
|
||||
assert_eq!(e.warmup_period(), 1);
|
||||
assert_eq!(e.name(), "EstimatedLeverageRatio");
|
||||
assert!(!e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_reference_value() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
// 1000 / (400 + 600) = 1.0.
|
||||
assert_relative_eq!(
|
||||
e.update(tick(1_000.0, 400.0, 600.0)).unwrap(),
|
||||
1.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_oi_raises_ratio() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
let low = e.update(tick(1_000.0, 500.0, 500.0)).unwrap();
|
||||
let high = e.update(tick(3_000.0, 500.0, 500.0)).unwrap();
|
||||
assert!(high > low);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_base_is_zero() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
assert_relative_eq!(
|
||||
e.update(tick(1_000.0, 0.0, 0.0)).unwrap(),
|
||||
0.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
assert!(!e.is_ready());
|
||||
e.update(tick(1_000.0, 500.0, 500.0));
|
||||
assert!(e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut e = EstimatedLeverageRatio::new();
|
||||
e.update(tick(1_000.0, 500.0, 500.0));
|
||||
assert!(e.is_ready());
|
||||
e.reset();
|
||||
assert!(!e.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let ticks: Vec<DerivativesTick> = (0..40)
|
||||
.map(|i| tick(1_000.0 + f64::from(i) * 10.0, 500.0, 500.0))
|
||||
.collect();
|
||||
let batch = EstimatedLeverageRatio::new().batch(&ticks);
|
||||
let mut b = EstimatedLeverageRatio::new();
|
||||
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Funding-Implied APR — the per-interval funding rate annualised.
|
||||
|
||||
use crate::derivatives::DerivativesTick;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Funding-Implied APR — the perpetual's per-interval funding rate scaled to an
|
||||
/// annualised rate.
|
||||
///
|
||||
/// ```text
|
||||
/// APR = funding_rate · intervals_per_year
|
||||
/// ```
|
||||
///
|
||||
/// Funding is paid in small per-interval amounts (commonly every 8 hours, i.e.
|
||||
/// `1095` intervals per year). Annualising it converts the headline funding number
|
||||
/// into the carry cost (or yield) of holding the position for a year, which is far
|
||||
/// easier to reason about and to compare against spot lending rates, basis trades,
|
||||
/// and other yields. A large positive APR means longs pay a steep carry to shorts
|
||||
/// (and vice versa) — the economic incentive behind cash-and-carry and
|
||||
/// funding-arbitrage strategies.
|
||||
///
|
||||
/// The output is a fraction (multiply by `100` for percent) and may be negative.
|
||||
/// It is stateless — each tick yields one value (no warmup). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativesTick, Indicator, FundingImpliedApr};
|
||||
///
|
||||
/// // 0.01% per 8h funding -> 0.0001 * 1095 ≈ 10.95% APR.
|
||||
/// let mut indicator = FundingImpliedApr::new(1095.0).unwrap();
|
||||
/// let tick = DerivativesTick::new(0.0001, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
|
||||
/// let apr = indicator.update(tick).unwrap();
|
||||
/// assert!((apr - 0.1095).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FundingImpliedApr {
|
||||
intervals_per_year: f64,
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl FundingImpliedApr {
|
||||
/// Construct a Funding-Implied APR with the number of funding intervals per
|
||||
/// year (e.g. `1095` for 8-hour funding, `365` for daily).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidParameter`] if `intervals_per_year` is not finite
|
||||
/// and positive.
|
||||
pub fn new(intervals_per_year: f64) -> Result<Self> {
|
||||
if !intervals_per_year.is_finite() || intervals_per_year <= 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "intervals_per_year must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
intervals_per_year,
|
||||
ready: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured intervals per year.
|
||||
pub const fn intervals_per_year(&self) -> f64 {
|
||||
self.intervals_per_year
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for FundingImpliedApr {
|
||||
type Input = DerivativesTick;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
|
||||
self.ready = true;
|
||||
Some(tick.funding_rate * self.intervals_per_year)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"FundingImpliedApr"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn tick(funding: f64) -> DerivativesTick {
|
||||
DerivativesTick::new_unchecked(
|
||||
funding, 100.0, 100.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_intervals() {
|
||||
assert!(matches!(
|
||||
FundingImpliedApr::new(0.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
FundingImpliedApr::new(-1.0),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
assert_relative_eq!(f.intervals_per_year(), 1095.0, epsilon = 1e-12);
|
||||
assert_eq!(f.warmup_period(), 1);
|
||||
assert_eq!(f.name(), "FundingImpliedApr");
|
||||
assert!(!f.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apr_reference_value() {
|
||||
let mut f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
assert_relative_eq!(f.update(tick(0.0001)).unwrap(), 0.1095, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_funding_is_negative_apr() {
|
||||
let mut f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
assert!(f.update(tick(-0.0001)).unwrap() < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_funding_is_zero() {
|
||||
let mut f = FundingImpliedApr::new(365.0).unwrap();
|
||||
assert_relative_eq!(f.update(tick(0.0)).unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut f = FundingImpliedApr::new(1095.0).unwrap();
|
||||
f.update(tick(0.0001));
|
||||
assert!(f.is_ready());
|
||||
f.reset();
|
||||
assert!(!f.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let ticks: Vec<DerivativesTick> = (0..40)
|
||||
.map(|i| tick(0.0001 * (f64::from(i) * 0.3).sin()))
|
||||
.collect();
|
||||
let batch = FundingImpliedApr::new(1095.0).unwrap().batch(&ticks);
|
||||
let mut b = FundingImpliedApr::new(1095.0).unwrap();
|
||||
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Gain-to-Pain Ratio (Schwager) — sum of returns over the sum of losses.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Gain-to-Pain Ratio — Jack Schwager's measure of return per unit of downside:
|
||||
/// the sum of all returns divided by the sum of the absolute *negative* returns.
|
||||
///
|
||||
/// ```text
|
||||
/// GPR = Σ returns / Σ |negative returns| over the window
|
||||
/// ```
|
||||
///
|
||||
/// Where the [`GainLossRatio`](crate::GainLossRatio) compares *average* win to
|
||||
/// *average* loss and the [`ProfitFactor`](crate::ProfitFactor) compares gross
|
||||
/// profit to gross loss, the Gain-to-Pain Ratio puts the **net** result over the
|
||||
/// total pain endured to earn it. Schwager treats a GPR above `1.0` as good and
|
||||
/// above `2.0` as excellent for a monthly return series: the strategy made more
|
||||
/// than it lost on the way, and twice as much when GPR is `2`. A flat series, or
|
||||
/// one with no losses, has no measurable pain and reports `0` (undefined).
|
||||
///
|
||||
/// The output is unbounded and may be negative (a net-losing window). The first
|
||||
/// value lands after `period` returns; each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, GainToPainRatio};
|
||||
///
|
||||
/// let mut indicator = GainToPainRatio::new(12).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..24 {
|
||||
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.02);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GainToPainRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
sum_all: f64,
|
||||
sum_pain: f64,
|
||||
}
|
||||
|
||||
impl GainToPainRatio {
|
||||
/// Construct a Gain-to-Pain Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_all: 0.0,
|
||||
sum_pain: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for GainToPainRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return if self.window.len() == self.period {
|
||||
Some(self.compute())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum_all -= old;
|
||||
if old < 0.0 {
|
||||
self.sum_pain -= -old;
|
||||
}
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
self.sum_all += ret;
|
||||
if ret < 0.0 {
|
||||
self.sum_pain += -ret;
|
||||
}
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_all = 0.0;
|
||||
self.sum_pain = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"GainToPainRatio"
|
||||
}
|
||||
}
|
||||
|
||||
impl GainToPainRatio {
|
||||
fn compute(&self) -> f64 {
|
||||
if self.sum_pain > 0.0 {
|
||||
self.sum_all / self.sum_pain
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(GainToPainRatio::new(0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let g = GainToPainRatio::new(12).unwrap();
|
||||
assert_eq!(g.period(), 12);
|
||||
assert_eq!(g.warmup_period(), 12);
|
||||
assert_eq!(g.name(), "GainToPainRatio");
|
||||
assert!(!g.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut g = GainToPainRatio::new(4).unwrap();
|
||||
let out = g.batch(&[0.01, -0.01, 0.02, -0.01, 0.03]);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns: +0.04, -0.02 -> sum_all = 0.02, pain = 0.02 -> GPR = 1.0.
|
||||
let mut g = GainToPainRatio::new(2).unwrap();
|
||||
let out = g.batch(&[0.04, -0.02]);
|
||||
assert_relative_eq!(out[1].unwrap(), 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn net_losing_window_is_negative() {
|
||||
let mut g = GainToPainRatio::new(3).unwrap();
|
||||
let last = g
|
||||
.batch(&[-0.03, 0.01, -0.02])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_pain_is_zero() {
|
||||
let mut g = GainToPainRatio::new(3).unwrap();
|
||||
let last = g
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite() {
|
||||
let mut g = GainToPainRatio::new(2).unwrap();
|
||||
let ready = g
|
||||
.batch(&[0.04, -0.02])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(g.update(f64::NAN), Some(ready));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_before_ready_is_none() {
|
||||
// A non-finite value arriving before the window fills yields None.
|
||||
let mut g = GainToPainRatio::new(3).unwrap();
|
||||
assert_eq!(g.update(0.02), None);
|
||||
assert_eq!(g.update(f64::NAN), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut g = GainToPainRatio::new(2).unwrap();
|
||||
g.batch(&[0.04, -0.02]);
|
||||
assert!(g.is_ready());
|
||||
g.reset();
|
||||
assert!(!g.is_ready());
|
||||
assert_eq!(g.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60).map(|i| (f64::from(i) * 0.3).sin() * 0.02).collect();
|
||||
let batch = GainToPainRatio::new(12).unwrap().batch(&rets);
|
||||
let mut b = GainToPainRatio::new(12).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| b.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! High/Low Volume Nodes (HVN / LVN) — the busiest and quietest price levels.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Output of [`HighLowVolumeNodes`]: the price of the highest- and lowest-volume
|
||||
/// node in the profile.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct HighLowVolumeNodesOutput {
|
||||
/// High Volume Node — the price level (bin centre) with the most volume.
|
||||
pub hvn: f64,
|
||||
/// Low Volume Node — the traded price level with the least volume.
|
||||
pub lvn: f64,
|
||||
}
|
||||
|
||||
/// High/Low Volume Nodes — the price levels of greatest and least acceptance in a
|
||||
/// rolling volume profile.
|
||||
///
|
||||
/// ```text
|
||||
/// build a `bins`-bucket volume profile over the last `period` candles
|
||||
/// HVN = bin centre of the bucket with the most volume
|
||||
/// LVN = bin centre of the traded bucket with the least volume
|
||||
/// ```
|
||||
///
|
||||
/// A volume profile reveals where the market spent the most effort. A **High Volume
|
||||
/// Node** (HVN) is a price the market accepted and traded heavily — it acts as a
|
||||
/// magnet and as strong support/resistance. A **Low Volume Node** (LVN) is a price
|
||||
/// the market rejected quickly — moves tend to accelerate through LVNs and they
|
||||
/// often mark the edges between balance areas. Each candle's volume is spread
|
||||
/// across the price bins its high-low range spans (as in
|
||||
/// [`VolumeProfile`](crate::VolumeProfile)).
|
||||
///
|
||||
/// The first value lands after `period` candles; each `update` rebuilds the profile
|
||||
/// in O(`period · bins`). A degenerate flat window puts both nodes at the price.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, HighLowVolumeNodes};
|
||||
///
|
||||
/// let mut indicator = HighLowVolumeNodes::new(20, 24).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HighLowVolumeNodes {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
window: VecDeque<Candle>,
|
||||
last: Option<HighLowVolumeNodesOutput>,
|
||||
}
|
||||
|
||||
impl HighLowVolumeNodes {
|
||||
/// Construct a High/Low Volume Nodes indicator.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `bins` is zero.
|
||||
pub fn new(period: usize, bins: usize) -> Result<Self> {
|
||||
if period == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.period, self.bins)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<HighLowVolumeNodesOutput> {
|
||||
self.last
|
||||
}
|
||||
|
||||
/// Build the volume histogram; returns `(low, bin_width, bins)`.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn profile(&self) -> (f64, f64, Vec<f64>) {
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = f64::NEG_INFINITY;
|
||||
for c in &self.window {
|
||||
low = low.min(c.low);
|
||||
high = high.max(c.high);
|
||||
}
|
||||
let mut hist = vec![0.0; self.bins];
|
||||
let span = high - low;
|
||||
if span <= 0.0 {
|
||||
hist[0] = self.window.iter().map(|c| c.volume).sum();
|
||||
return (low, 0.0, hist);
|
||||
}
|
||||
let width = span / self.bins as f64;
|
||||
for c in &self.window {
|
||||
if c.volume == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let touched = hi_idx - lo_idx + 1;
|
||||
let share = c.volume / touched as f64;
|
||||
for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
|
||||
*bin += share;
|
||||
}
|
||||
}
|
||||
(low, width, hist)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for HighLowVolumeNodes {
|
||||
type Input = Candle;
|
||||
type Output = HighLowVolumeNodesOutput;
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn update(&mut self, candle: Candle) -> Option<HighLowVolumeNodesOutput> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let (low, width, hist) = self.profile();
|
||||
let centre = |idx: usize| low + (idx as f64 + 0.5) * width;
|
||||
|
||||
let mut hvn_idx = 0;
|
||||
let mut hvn_vol = f64::NEG_INFINITY;
|
||||
let mut lvn_idx = 0;
|
||||
let mut lvn_vol = f64::INFINITY;
|
||||
for (idx, &vol) in hist.iter().enumerate() {
|
||||
if vol > hvn_vol {
|
||||
hvn_vol = vol;
|
||||
hvn_idx = idx;
|
||||
}
|
||||
if vol > 0.0 && vol < lvn_vol {
|
||||
lvn_vol = vol;
|
||||
lvn_idx = idx;
|
||||
}
|
||||
}
|
||||
// If no traded bin was found (all zero volume), both default to bin 0.
|
||||
if !lvn_vol.is_finite() {
|
||||
lvn_idx = hvn_idx;
|
||||
}
|
||||
let out = HighLowVolumeNodesOutput {
|
||||
hvn: centre(hvn_idx),
|
||||
lvn: centre(lvn_idx),
|
||||
};
|
||||
self.last = Some(out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"HighLowVolumeNodes"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
volume,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(
|
||||
HighLowVolumeNodes::new(0, 24),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
assert!(matches!(
|
||||
HighLowVolumeNodes::new(20, 0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let h = HighLowVolumeNodes::new(20, 24).unwrap();
|
||||
assert_eq!(h.params(), (20, 24));
|
||||
assert_eq!(h.warmup_period(), 20);
|
||||
assert_eq!(h.name(), "HighLowVolumeNodes");
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut h = HighLowVolumeNodes::new(4, 8).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
|
||||
let out = h.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvn_at_heavy_price() {
|
||||
// Most bars cluster at ~100 (heavy volume); one bar pokes up to 120 lightly.
|
||||
let mut h = HighLowVolumeNodes::new(6, 24).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
|
||||
candles.push(c(121.0, 119.0, 100.0));
|
||||
let out = h.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
// HVN should sit near the heavy 100 cluster, well below the light 120 poke.
|
||||
assert!(
|
||||
out.hvn < 110.0,
|
||||
"HVN should be at the heavy cluster, got {}",
|
||||
out.hvn
|
||||
);
|
||||
assert!(out.lvn >= out.hvn - 1e9); // lvn is a valid level
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvn_at_or_above_low() {
|
||||
let mut h = HighLowVolumeNodes::new(10, 24).unwrap();
|
||||
let candles: Vec<Candle> = (0..30)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.3).sin() * 5.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for o in h.batch(&candles).into_iter().flatten() {
|
||||
assert!(o.hvn.is_finite() && o.lvn.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut h = HighLowVolumeNodes::new(4, 8).unwrap();
|
||||
h.batch(&[c(110.0, 90.0, 1_000.0); 6]);
|
||||
assert!(h.is_ready());
|
||||
h.reset();
|
||||
assert!(!h.is_ready());
|
||||
assert_eq!(h.value(), None);
|
||||
assert_eq!(h.update(c(110.0, 90.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = HighLowVolumeNodes::new(20, 24).unwrap().batch(&candles);
|
||||
let mut b = HighLowVolumeNodes::new(20, 24).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_handled() {
|
||||
// Zero high-low span dumps all volume into bin 0 and returns early.
|
||||
let mut h = HighLowVolumeNodes::new(2, 4).unwrap();
|
||||
h.update(c(50.0, 50.0, 10.0));
|
||||
assert!(h.update(c(50.0, 50.0, 10.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_falls_back() {
|
||||
// All-zero volume leaves no traded bin; the LVN falls back to the HVN.
|
||||
let mut h = HighLowVolumeNodes::new(2, 4).unwrap();
|
||||
h.update(c(60.0, 40.0, 0.0));
|
||||
let out = h.update(c(60.0, 40.0, 0.0)).unwrap();
|
||||
assert_eq!(out.hvn, out.lvn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! K-Ratio (Kestner) — slope of the cumulative-return curve over the standard error of that slope.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// K-Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// Lars Kestner's K-Ratio measures the *consistency* of an equity curve, not just
|
||||
/// its return. It builds the cumulative-return curve over the window, fits an
|
||||
/// ordinary-least-squares trend line through it against time, and divides the
|
||||
/// fitted slope by the standard error of that slope:
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = Σ_{i<=t} return_i (cumulative curve, t = 1..period)
|
||||
/// slope, intercept = OLS(equity_t ~ t)
|
||||
/// SE(slope) = sqrt( (Σ residual² / (period − 2)) / Σ(t − t̄)² )
|
||||
/// K-Ratio = slope / SE(slope)
|
||||
/// ```
|
||||
///
|
||||
/// A high K-Ratio means the equity curve climbs *steadily* — a steep slope with
|
||||
/// little scatter around the trend. A strategy that earns the same total return in
|
||||
/// a few lucky jumps scores lower because its residual scatter inflates the
|
||||
/// standard error. This is the original 1996 form; later Kestner revisions scale by
|
||||
/// the number of periods (`slope / (SE · period)` in 2003, `slope / (SE · √period)`
|
||||
/// in 2013) — apply that scaling downstream if you need to compare across window
|
||||
/// lengths.
|
||||
///
|
||||
/// A perfectly straight window (e.g. constant returns) has zero residual scatter,
|
||||
/// so the slope's standard error is zero and the K-Ratio is undefined; the
|
||||
/// indicator reports `0.0` in that degenerate case. The statistic therefore needs
|
||||
/// some dispersion in the returns to be meaningful.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` re-fits the line
|
||||
/// over the window (O(period)), which is O(1) in the length of the overall series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, KRatio};
|
||||
///
|
||||
/// let mut indicator = KRatio::new(30).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// last = indicator.update(0.001 + (f64::from(i) * 0.3).sin() * 0.01);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl KRatio {
|
||||
/// Construct a K-Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 3` (the slope's standard error
|
||||
/// divides by `period − 2`).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 3 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "k-ratio needs period >= 3",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let count = self.window.len();
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let length = count as f64;
|
||||
// Build the cumulative-equity curve and its mean.
|
||||
let mut equity = 0.0;
|
||||
let mut curve: Vec<f64> = Vec::with_capacity(count);
|
||||
let mut sum_equity = 0.0;
|
||||
for ret in &self.window {
|
||||
equity += *ret;
|
||||
curve.push(equity);
|
||||
sum_equity += equity;
|
||||
}
|
||||
// Times are 1..=count, so Σt = count(count+1)/2 in closed form.
|
||||
let mean_time = f64::midpoint(length, 1.0);
|
||||
let mean_equity = sum_equity / length;
|
||||
let mut sxx = 0.0;
|
||||
let mut sxy = 0.0;
|
||||
for (index, value) in curve.iter().enumerate() {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let time = (index + 1) as f64;
|
||||
let dt = time - mean_time;
|
||||
sxx += dt * dt;
|
||||
sxy += dt * (value - mean_equity);
|
||||
}
|
||||
// sxx > 0 for count >= 2 (distinct integer times), guaranteed by period >= 3.
|
||||
let slope = sxy / sxx;
|
||||
let intercept = mean_equity - slope * mean_time;
|
||||
let mut sse = 0.0;
|
||||
for (index, value) in curve.iter().enumerate() {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let time = (index + 1) as f64;
|
||||
let residual = value - (intercept + slope * time);
|
||||
sse += residual * residual;
|
||||
}
|
||||
if sse <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let se_slope = (sse / (length - 2.0) / sxx).sqrt();
|
||||
slope / se_slope
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
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 {
|
||||
"KRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_three() {
|
||||
assert!(matches!(KRatio::new(2), Err(Error::InvalidPeriod { .. })));
|
||||
assert!(matches!(KRatio::new(0), Err(Error::InvalidPeriod { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let kr = KRatio::new(30).unwrap();
|
||||
assert_eq!(kr.period(), 30);
|
||||
assert_eq!(kr.warmup_period(), 30);
|
||||
assert_eq!(kr.name(), "KRatio");
|
||||
assert!(!kr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.01, 0.02, 0.03] -> equity curve [0.01, 0.03, 0.06].
|
||||
// slope = 0.025, SE(slope) = sqrt((1/60000)/1/2) = 1/sqrt(120000).
|
||||
// K-Ratio = 0.025 * sqrt(120000) = 5*sqrt(3) ≈ 8.660254.
|
||||
let mut kr = KRatio::new(3).unwrap();
|
||||
let out = kr.batch(&[0.01, 0.02, 0.03]);
|
||||
let expected = 0.025_f64 / (1.0_f64 / 120_000.0).sqrt();
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_returns_are_degenerate_zero() {
|
||||
// A perfectly linear equity curve has zero residual scatter -> undefined.
|
||||
let mut kr = KRatio::new(4).unwrap();
|
||||
let last = kr.batch(&[0.01; 4]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rising_curve_is_positive() {
|
||||
let mut kr = KRatio::new(5).unwrap();
|
||||
let last = kr
|
||||
.batch(&[0.01, 0.012, 0.009, 0.011, 0.013])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut kr = KRatio::new(3).unwrap();
|
||||
assert_eq!(kr.update(0.01), None);
|
||||
assert_eq!(kr.update(f64::NAN), None);
|
||||
assert_eq!(kr.update(0.02), None);
|
||||
assert!(kr.update(0.03).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut kr = KRatio::new(3).unwrap();
|
||||
kr.batch(&[0.01, 0.02, 0.03]);
|
||||
assert!(kr.is_ready());
|
||||
kr.reset();
|
||||
assert!(!kr.is_ready());
|
||||
assert_eq!(kr.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| 0.001 + (f64::from(i) * 0.25).sin() * 0.01)
|
||||
.collect();
|
||||
let batch = KRatio::new(20).unwrap().batch(&rets);
|
||||
let mut streamer = KRatio::new(20).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! M² / Modigliani–Modigliani measure — Sharpe expressed in benchmark return units.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// M² (Modigliani–Modigliani) measure over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// Sharpe = (mean(returns) − risk_free) / stddev(returns)
|
||||
/// M² = risk_free + Sharpe · benchmark_stddev
|
||||
/// ```
|
||||
///
|
||||
/// The [`SharpeRatio`](crate::SharpeRatio) is dimensionless, which makes it hard to
|
||||
/// communicate: "0.8" means little to a client. M² rescales the Sharpe ratio back
|
||||
/// into *return units* by levering (or de-levering) the portfolio to the
|
||||
/// benchmark's volatility. The result answers a concrete question: "if this
|
||||
/// strategy had run at the market's risk level, what return would it have
|
||||
/// produced?" Two portfolios can then be ranked on the same risk-adjusted scale,
|
||||
/// and M² preserves the Sharpe ordering while being quoted as a percentage.
|
||||
///
|
||||
/// `stddev` is the sample standard deviation (Bessel's `n − 1`).
|
||||
/// `risk_free` is the per-period risk-free rate and `benchmark_stddev` the
|
||||
/// per-period volatility of the benchmark, both supplied by the caller at the
|
||||
/// return frequency. A flat window has zero volatility and the Sharpe ratio is
|
||||
/// undefined; the indicator returns `0.0` in that case rather than producing `NaN`.
|
||||
///
|
||||
/// Each `update` is O(1) — running sums maintain `Σr` and `Σr²` as the window slides.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, M2Measure};
|
||||
///
|
||||
/// let mut indicator = M2Measure::new(20, 0.0, 0.02).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update(0.001 + (f64::from(i) * 0.1).sin() * 0.01);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct M2Measure {
|
||||
period: usize,
|
||||
risk_free: f64,
|
||||
benchmark_stddev: f64,
|
||||
window: VecDeque<f64>,
|
||||
sum: f64,
|
||||
sum_sq: f64,
|
||||
}
|
||||
|
||||
impl M2Measure {
|
||||
/// Construct an M² measure over `period` returns with the given per-period
|
||||
/// risk-free rate and benchmark standard deviation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
|
||||
/// [`Error::InvalidParameter`] if `risk_free` is not finite or
|
||||
/// `benchmark_stddev` is negative or not finite.
|
||||
pub fn new(period: usize, risk_free: f64, benchmark_stddev: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "m2 measure needs period >= 2",
|
||||
});
|
||||
}
|
||||
if !risk_free.is_finite() || !benchmark_stddev.is_finite() || benchmark_stddev < 0.0 {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "risk_free must be finite and benchmark_stddev finite and non-negative",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
risk_free,
|
||||
benchmark_stddev,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum: 0.0,
|
||||
sum_sq: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured per-period risk-free rate.
|
||||
pub const fn risk_free(&self) -> f64 {
|
||||
self.risk_free
|
||||
}
|
||||
|
||||
/// Configured per-period benchmark standard deviation.
|
||||
pub const fn benchmark_stddev(&self) -> f64 {
|
||||
self.benchmark_stddev
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for M2Measure {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
self.sum -= old;
|
||||
self.sum_sq -= old * old;
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
self.sum += ret;
|
||||
self.sum_sq += ret * ret;
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let mean = self.sum / n;
|
||||
let var = (self.sum_sq - n * mean * mean).max(0.0) / (n - 1.0);
|
||||
let sd = var.sqrt();
|
||||
if sd == 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let sharpe = (mean - self.risk_free) / sd;
|
||||
Some(self.risk_free + sharpe * self.benchmark_stddev)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum = 0.0;
|
||||
self.sum_sq = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"M2Measure"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
M2Measure::new(1, 0.0, 0.02),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_benchmark_stddev() {
|
||||
assert!(matches!(
|
||||
M2Measure::new(10, 0.0, -0.01),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
M2Measure::new(10, f64::NAN, 0.02),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let m2 = M2Measure::new(20, 0.001, 0.02).unwrap();
|
||||
assert_eq!(m2.period(), 20);
|
||||
assert_relative_eq!(m2.risk_free(), 0.001, epsilon = 1e-12);
|
||||
assert_relative_eq!(m2.benchmark_stddev(), 0.02, epsilon = 1e-12);
|
||||
assert_eq!(m2.warmup_period(), 20);
|
||||
assert_eq!(m2.name(), "M2Measure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.01, 0.02, 0.03, 0.04], rf = 0, benchmark_stddev = 0.02.
|
||||
// mean = 0.025, sd = sqrt(0.000166666...), Sharpe = 0.025 / sd.
|
||||
// M2 = 0 + Sharpe * 0.02.
|
||||
let mut m2 = M2Measure::new(4, 0.0, 0.02).unwrap();
|
||||
let out = m2.batch(&[0.01, 0.02, 0.03, 0.04]);
|
||||
let sharpe = 0.025_f64 / (0.000_166_666_666_666_666_67_f64).sqrt();
|
||||
assert_relative_eq!(out[3].unwrap(), sharpe * 0.02, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_returns_yield_zero() {
|
||||
let mut m2 = M2Measure::new(5, 0.0, 0.02).unwrap();
|
||||
for v in m2.batch(&[0.01; 10]).into_iter().flatten() {
|
||||
assert_relative_eq!(v, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
|
||||
assert_eq!(m2.update(0.01), None);
|
||||
assert_eq!(m2.update(f64::NAN), None);
|
||||
assert_eq!(m2.update(0.02), None);
|
||||
assert!(m2.update(0.03).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut m2 = M2Measure::new(3, 0.0, 0.02).unwrap();
|
||||
m2.batch(&[0.01, 0.02, 0.03]);
|
||||
assert!(m2.is_ready());
|
||||
m2.reset();
|
||||
assert!(!m2.is_ready());
|
||||
assert_eq!(m2.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..50)
|
||||
.map(|i| 0.001 + (f64::from(i) * 0.2).sin() * 0.01)
|
||||
.collect();
|
||||
let batch = M2Measure::new(10, 0.0, 0.02).unwrap().batch(&rets);
|
||||
let mut streamer = M2Measure::new(10, 0.0, 0.02).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Martin Ratio (Ulcer Performance Index) — mean return over the Ulcer Index.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Martin Ratio — also called the Ulcer Performance Index (UPI) — over a trailing
|
||||
/// window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||
/// peak_t = max_{s<=t} equity_s
|
||||
/// dd_t% = 100 · (peak_t − equity_t) / peak_t (percentage drawdown)
|
||||
/// UlcerIdx = sqrt( mean( dd_t%² ) )
|
||||
/// Martin = mean(returns) / UlcerIdx
|
||||
/// ```
|
||||
///
|
||||
/// The Martin Ratio divides the average per-period return by the **Ulcer Index** —
|
||||
/// the root-mean-square of the *percentage* drawdowns. The Ulcer Index, by
|
||||
/// construction, measures the depth *and* duration of the time spent under water:
|
||||
/// a long shallow slump and a short deep one can score the same. Compared to
|
||||
/// Wickra's other drawdown ratios, Martin uses the RMS (not the average as in the
|
||||
/// [`SterlingRatio`](crate::SterlingRatio), nor the un-normalised sum-norm as in the
|
||||
/// [`BurkeRatio`](crate::BurkeRatio)) and expresses drawdowns in **percent**, so its
|
||||
/// denominator is on a `0..100` scale and its output is numerically smaller than
|
||||
/// the fractional-drawdown ratios. A window that never draws down has an Ulcer Index
|
||||
/// of zero and the indicator reports `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||
/// series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, MartinRatio};
|
||||
///
|
||||
/// let mut indicator = MartinRatio::new(14).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..28 {
|
||||
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MartinRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl MartinRatio {
|
||||
/// Construct a Martin Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "martin ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let length = self.window.len() as f64;
|
||||
let mut sum_return = 0.0;
|
||||
let mut sum_drawdown_pct_sq = 0.0;
|
||||
let mut equity = 1.0;
|
||||
let mut peak: f64 = 1.0;
|
||||
for ret in &self.window {
|
||||
sum_return += *ret;
|
||||
equity *= 1.0 + *ret;
|
||||
peak = peak.max(equity);
|
||||
let drawdown_pct = 100.0 * (peak - equity) / peak;
|
||||
sum_drawdown_pct_sq += drawdown_pct * drawdown_pct;
|
||||
}
|
||||
let ulcer_index = (sum_drawdown_pct_sq / length).sqrt();
|
||||
if ulcer_index > 0.0 {
|
||||
(sum_return / length) / ulcer_index
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for MartinRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
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 {
|
||||
"MartinRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
MartinRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let mr = MartinRatio::new(14).unwrap();
|
||||
assert_eq!(mr.period(), 14);
|
||||
assert_eq!(mr.warmup_period(), 14);
|
||||
assert_eq!(mr.name(), "MartinRatio");
|
||||
assert!(!mr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.1, -0.1, 0.1]: drawdowns% = [0, 10, 1].
|
||||
// Ulcer Index = sqrt((0 + 100 + 1)/3) = sqrt(101/3).
|
||||
// Martin = (0.1/3) / sqrt(101/3).
|
||||
let mut mr = MartinRatio::new(3).unwrap();
|
||||
let out = mr.batch(&[0.1, -0.1, 0.1]);
|
||||
let expected = (0.1_f64 / 3.0) / (101.0_f64 / 3.0).sqrt();
|
||||
assert_relative_eq!(out[2].unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drawdown_is_zero() {
|
||||
let mut mr = MartinRatio::new(3).unwrap();
|
||||
let last = mr
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_window_is_negative() {
|
||||
let mut mr = MartinRatio::new(3).unwrap();
|
||||
let last = mr
|
||||
.batch(&[-0.05, -0.02, -0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut mr = MartinRatio::new(3).unwrap();
|
||||
assert_eq!(mr.update(0.1), None);
|
||||
assert_eq!(mr.update(f64::NAN), None);
|
||||
assert_eq!(mr.update(-0.1), None);
|
||||
assert!(mr.update(0.1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut mr = MartinRatio::new(3).unwrap();
|
||||
mr.batch(&[0.1, -0.1, 0.1]);
|
||||
assert!(mr.is_ready());
|
||||
mr.reset();
|
||||
assert!(!mr.is_ready());
|
||||
assert_eq!(mr.update(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||
.collect();
|
||||
let batch = MartinRatio::new(14).unwrap().batch(&rets);
|
||||
let mut streamer = MartinRatio::new(14).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ mod bomar_bands;
|
||||
mod breadth_thrust;
|
||||
mod breakaway;
|
||||
mod bullish_percent_index;
|
||||
mod burke_ratio;
|
||||
mod butterfly;
|
||||
mod calendar_spread;
|
||||
mod calmar_ratio;
|
||||
@@ -84,6 +85,8 @@ mod cmf;
|
||||
mod cmo;
|
||||
mod coefficient_of_variation;
|
||||
mod cointegration;
|
||||
mod common_sense_ratio;
|
||||
mod composite_profile;
|
||||
mod concealing_baby_swallow;
|
||||
mod conditional_value_at_risk;
|
||||
mod connors_rsi;
|
||||
@@ -131,6 +134,7 @@ mod ema;
|
||||
mod empirical_mode_decomposition;
|
||||
mod engulfing;
|
||||
mod equivolume;
|
||||
mod estimated_leverage_ratio;
|
||||
mod even_better_sinewave;
|
||||
mod evening_doji_star;
|
||||
mod evwma;
|
||||
@@ -156,10 +160,12 @@ mod fractal_chaos_bands;
|
||||
mod frama;
|
||||
mod fry_pan_bottom;
|
||||
mod funding_basis;
|
||||
mod funding_implied_apr;
|
||||
mod funding_rate;
|
||||
mod funding_rate_mean;
|
||||
mod funding_rate_zscore;
|
||||
mod gain_loss_ratio;
|
||||
mod gain_to_pain_ratio;
|
||||
mod gap_side_by_side_white;
|
||||
mod garch11;
|
||||
mod garman_klass;
|
||||
@@ -180,6 +186,7 @@ mod heikin_ashi;
|
||||
mod heikin_ashi_oscillator;
|
||||
mod high_low_index;
|
||||
mod high_low_range;
|
||||
mod high_low_volume_nodes;
|
||||
mod high_wave;
|
||||
mod highpass_filter;
|
||||
mod hikkake;
|
||||
@@ -210,6 +217,7 @@ mod inverted_hammer;
|
||||
mod jarque_bera;
|
||||
mod jma;
|
||||
mod jump_indicator;
|
||||
mod k_ratio;
|
||||
mod kagi_bars;
|
||||
mod kalman_hedge_ratio;
|
||||
mod kama;
|
||||
@@ -237,6 +245,7 @@ mod log_return;
|
||||
mod long_legged_doji;
|
||||
mod long_line;
|
||||
mod long_short_ratio;
|
||||
mod m2_measure;
|
||||
mod ma_envelope;
|
||||
mod macd;
|
||||
mod macd_ext;
|
||||
@@ -244,6 +253,7 @@ mod macd_fix;
|
||||
mod macd_histogram;
|
||||
mod mama;
|
||||
mod market_facilitation_index;
|
||||
mod martin_ratio;
|
||||
mod marubozu;
|
||||
mod mass_index;
|
||||
mod mat_hold;
|
||||
@@ -267,6 +277,7 @@ mod mom;
|
||||
mod morning_doji_star;
|
||||
mod morning_evening_star;
|
||||
mod murrey_math_lines;
|
||||
mod naked_poc;
|
||||
mod natr;
|
||||
mod new_highs_new_lows;
|
||||
mod new_price_lines;
|
||||
@@ -278,9 +289,11 @@ mod ob_imbalance_topn;
|
||||
mod obv;
|
||||
mod oi_delta;
|
||||
mod oi_price_divergence;
|
||||
mod oi_to_volume_ratio;
|
||||
mod oi_weighted;
|
||||
mod omega_ratio;
|
||||
mod on_neck;
|
||||
mod open_interest_momentum;
|
||||
mod opening_marubozu;
|
||||
mod opening_range;
|
||||
mod order_flow_imbalance;
|
||||
@@ -295,6 +308,7 @@ mod pearson_correlation;
|
||||
mod percent_above_ma;
|
||||
mod percent_b;
|
||||
mod percentage_trailing_stop;
|
||||
mod perpetual_premium_index;
|
||||
mod pgo;
|
||||
mod piercing_dark_cloud;
|
||||
mod pin;
|
||||
@@ -306,6 +320,7 @@ mod point_and_figure_bars;
|
||||
mod polarized_fractal_efficiency;
|
||||
mod ppo;
|
||||
mod ppo_histogram;
|
||||
mod profile_shape;
|
||||
mod profit_factor;
|
||||
mod projection_bands;
|
||||
mod projection_oscillator;
|
||||
@@ -361,6 +376,7 @@ mod short_line;
|
||||
mod signed_volume;
|
||||
mod sine_wave;
|
||||
mod sine_weighted_ma;
|
||||
mod single_prints;
|
||||
mod skewness;
|
||||
mod sma;
|
||||
mod smi;
|
||||
@@ -379,6 +395,7 @@ mod starc_bands;
|
||||
mod stc;
|
||||
mod std_dev;
|
||||
mod step_trailing_stop;
|
||||
mod sterling_ratio;
|
||||
mod stick_sandwich;
|
||||
mod stoch_rsi;
|
||||
mod stochastic;
|
||||
@@ -386,6 +403,7 @@ mod stochastic_cci;
|
||||
mod super_smoother;
|
||||
mod super_trend;
|
||||
mod t3;
|
||||
mod tail_ratio;
|
||||
mod taker_buy_sell_ratio;
|
||||
mod takuri;
|
||||
mod tasuki_gap;
|
||||
@@ -456,6 +474,7 @@ mod universal_oscillator;
|
||||
mod up_down_volume_ratio;
|
||||
mod upside_gap_three_methods;
|
||||
mod upside_gap_two_crows;
|
||||
mod upside_potential_ratio;
|
||||
mod value_area;
|
||||
mod value_at_risk;
|
||||
mod variance;
|
||||
@@ -551,6 +570,7 @@ pub use bomar_bands::{BomarBands, BomarBandsOutput};
|
||||
pub use breadth_thrust::BreadthThrust;
|
||||
pub use breakaway::Breakaway;
|
||||
pub use bullish_percent_index::BullishPercentIndex;
|
||||
pub use burke_ratio::BurkeRatio;
|
||||
pub use butterfly::Butterfly;
|
||||
pub use calendar_spread::CalendarSpread;
|
||||
pub use calmar_ratio::CalmarRatio;
|
||||
@@ -572,6 +592,8 @@ pub use cmf::ChaikinMoneyFlow;
|
||||
pub use cmo::Cmo;
|
||||
pub use coefficient_of_variation::CoefficientOfVariation;
|
||||
pub use cointegration::{Cointegration, CointegrationOutput};
|
||||
pub use common_sense_ratio::CommonSenseRatio;
|
||||
pub use composite_profile::{CompositeProfile, CompositeProfileOutput};
|
||||
pub use concealing_baby_swallow::ConcealingBabySwallow;
|
||||
pub use conditional_value_at_risk::ConditionalValueAtRisk;
|
||||
pub use connors_rsi::ConnorsRsi;
|
||||
@@ -619,6 +641,7 @@ pub use ema::Ema;
|
||||
pub use empirical_mode_decomposition::EmpiricalModeDecomposition;
|
||||
pub use engulfing::Engulfing;
|
||||
pub use equivolume::{Equivolume, EquivolumeOutput};
|
||||
pub use estimated_leverage_ratio::EstimatedLeverageRatio;
|
||||
pub use even_better_sinewave::EvenBetterSinewave;
|
||||
pub use evening_doji_star::EveningDojiStar;
|
||||
pub use evwma::Evwma;
|
||||
@@ -644,10 +667,12 @@ pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
pub use frama::Frama;
|
||||
pub use fry_pan_bottom::FryPanBottom;
|
||||
pub use funding_basis::FundingBasis;
|
||||
pub use funding_implied_apr::FundingImpliedApr;
|
||||
pub use funding_rate::FundingRate;
|
||||
pub use funding_rate_mean::FundingRateMean;
|
||||
pub use funding_rate_zscore::FundingRateZScore;
|
||||
pub use gain_loss_ratio::GainLossRatio;
|
||||
pub use gain_to_pain_ratio::GainToPainRatio;
|
||||
pub use gap_side_by_side_white::GapSideBySideWhite;
|
||||
pub use garch11::Garch11;
|
||||
pub use garman_klass::GarmanKlassVolatility;
|
||||
@@ -668,6 +693,7 @@ pub use heikin_ashi::{HeikinAshi, HeikinAshiOutput};
|
||||
pub use heikin_ashi_oscillator::HeikinAshiOscillator;
|
||||
pub use high_low_index::HighLowIndex;
|
||||
pub use high_low_range::HighLowRange;
|
||||
pub use high_low_volume_nodes::{HighLowVolumeNodes, HighLowVolumeNodesOutput};
|
||||
pub use high_wave::HighWave;
|
||||
pub use highpass_filter::HighpassFilter;
|
||||
pub use hikkake::Hikkake;
|
||||
@@ -698,6 +724,7 @@ pub use inverted_hammer::InvertedHammer;
|
||||
pub use jarque_bera::JarqueBera;
|
||||
pub use jma::Jma;
|
||||
pub use jump_indicator::JumpIndicator;
|
||||
pub use k_ratio::KRatio;
|
||||
pub use kagi_bars::{KagiBar, KagiBars};
|
||||
pub use kalman_hedge_ratio::{KalmanHedgeRatio, KalmanHedgeRatioOutput};
|
||||
pub use kama::Kama;
|
||||
@@ -725,6 +752,7 @@ pub use log_return::LogReturn;
|
||||
pub use long_legged_doji::LongLeggedDoji;
|
||||
pub use long_line::LongLine;
|
||||
pub use long_short_ratio::LongShortRatio;
|
||||
pub use m2_measure::M2Measure;
|
||||
pub use ma_envelope::{MaEnvelope, MaEnvelopeOutput};
|
||||
pub use macd::{MacdIndicator, MacdOutput};
|
||||
pub use macd_ext::{MaType, MacdExt};
|
||||
@@ -732,6 +760,7 @@ pub use macd_fix::MacdFix;
|
||||
pub use macd_histogram::MacdHistogram;
|
||||
pub use mama::{Mama, MamaOutput};
|
||||
pub use market_facilitation_index::MarketFacilitationIndex;
|
||||
pub use martin_ratio::MartinRatio;
|
||||
pub use marubozu::Marubozu;
|
||||
pub use mass_index::MassIndex;
|
||||
pub use mat_hold::MatHold;
|
||||
@@ -755,6 +784,7 @@ pub use mom::Mom;
|
||||
pub use morning_doji_star::MorningDojiStar;
|
||||
pub use morning_evening_star::MorningEveningStar;
|
||||
pub use murrey_math_lines::{MurreyMathLines, MurreyMathLinesOutput};
|
||||
pub use naked_poc::NakedPoc;
|
||||
pub use natr::Natr;
|
||||
pub use new_highs_new_lows::NewHighsNewLows;
|
||||
pub use new_price_lines::NewPriceLines;
|
||||
@@ -766,9 +796,11 @@ pub use ob_imbalance_topn::OrderBookImbalanceTopN;
|
||||
pub use obv::Obv;
|
||||
pub use oi_delta::OpenInterestDelta;
|
||||
pub use oi_price_divergence::OIPriceDivergence;
|
||||
pub use oi_to_volume_ratio::OiToVolumeRatio;
|
||||
pub use oi_weighted::OIWeighted;
|
||||
pub use omega_ratio::OmegaRatio;
|
||||
pub use on_neck::OnNeck;
|
||||
pub use open_interest_momentum::OpenInterestMomentum;
|
||||
pub use opening_marubozu::OpeningMarubozu;
|
||||
pub use opening_range::{OpeningRange, OpeningRangeOutput};
|
||||
pub use order_flow_imbalance::OrderFlowImbalance;
|
||||
@@ -783,6 +815,7 @@ pub use pearson_correlation::PearsonCorrelation;
|
||||
pub use percent_above_ma::PercentAboveMa;
|
||||
pub use percent_b::PercentB;
|
||||
pub use percentage_trailing_stop::PercentageTrailingStop;
|
||||
pub use perpetual_premium_index::PerpetualPremiumIndex;
|
||||
pub use pgo::Pgo;
|
||||
pub use piercing_dark_cloud::PiercingDarkCloud;
|
||||
pub use pin::Pin;
|
||||
@@ -794,6 +827,7 @@ pub use point_and_figure_bars::{PnfColumn, PointAndFigureBars};
|
||||
pub use polarized_fractal_efficiency::PolarizedFractalEfficiency;
|
||||
pub use ppo::Ppo;
|
||||
pub use ppo_histogram::PpoHistogram;
|
||||
pub use profile_shape::ProfileShape;
|
||||
pub use profit_factor::ProfitFactor;
|
||||
pub use projection_bands::{ProjectionBands, ProjectionBandsOutput};
|
||||
pub use projection_oscillator::ProjectionOscillator;
|
||||
@@ -849,6 +883,7 @@ pub use short_line::ShortLine;
|
||||
pub use signed_volume::SignedVolume;
|
||||
pub use sine_wave::SineWave;
|
||||
pub use sine_weighted_ma::SineWeightedMa;
|
||||
pub use single_prints::SinglePrints;
|
||||
pub use skewness::Skewness;
|
||||
pub use sma::Sma;
|
||||
pub use smi::Smi;
|
||||
@@ -867,6 +902,7 @@ pub use starc_bands::{StarcBands, StarcBandsOutput};
|
||||
pub use stc::Stc;
|
||||
pub use std_dev::StdDev;
|
||||
pub use step_trailing_stop::StepTrailingStop;
|
||||
pub use sterling_ratio::SterlingRatio;
|
||||
pub use stick_sandwich::StickSandwich;
|
||||
pub use stoch_rsi::StochRsi;
|
||||
pub use stochastic::{Stochastic, StochasticOutput};
|
||||
@@ -874,6 +910,7 @@ pub use stochastic_cci::StochasticCci;
|
||||
pub use super_smoother::SuperSmoother;
|
||||
pub use super_trend::{SuperTrend, SuperTrendOutput};
|
||||
pub use t3::T3;
|
||||
pub use tail_ratio::TailRatio;
|
||||
pub use taker_buy_sell_ratio::TakerBuySellRatio;
|
||||
pub use takuri::Takuri;
|
||||
pub use tasuki_gap::TasukiGap;
|
||||
@@ -944,6 +981,7 @@ pub use universal_oscillator::UniversalOscillator;
|
||||
pub use up_down_volume_ratio::UpDownVolumeRatio;
|
||||
pub use upside_gap_three_methods::UpsideGapThreeMethods;
|
||||
pub use upside_gap_two_crows::UpsideGapTwoCrows;
|
||||
pub use upside_potential_ratio::UpsidePotentialRatio;
|
||||
pub use value_area::{ValueArea, ValueAreaOutput};
|
||||
pub use value_at_risk::ValueAtRisk;
|
||||
pub use variance::Variance;
|
||||
@@ -1478,6 +1516,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"LiquidationFeatures",
|
||||
"TermStructureBasis",
|
||||
"CalendarSpread",
|
||||
"EstimatedLeverageRatio",
|
||||
"OiToVolumeRatio",
|
||||
"PerpetualPremiumIndex",
|
||||
"FundingImpliedApr",
|
||||
"OpenInterestMomentum",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1488,6 +1531,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"OpeningRange",
|
||||
"VolumeProfile",
|
||||
"TpoProfile",
|
||||
"NakedPoc",
|
||||
"SinglePrints",
|
||||
"ProfileShape",
|
||||
"HighLowVolumeNodes",
|
||||
"CompositeProfile",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1512,6 +1560,15 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"Alpha",
|
||||
"WinRate",
|
||||
"Expectancy",
|
||||
"SterlingRatio",
|
||||
"BurkeRatio",
|
||||
"MartinRatio",
|
||||
"TailRatio",
|
||||
"KRatio",
|
||||
"CommonSenseRatio",
|
||||
"GainToPainRatio",
|
||||
"UpsidePotentialRatio",
|
||||
"M2Measure",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1624,6 +1681,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, 488, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 507, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Naked POC — the nearest prior-session point of control price has not yet revisited.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Naked (Virgin) POC — the nearest **untested** point of control from a prior
|
||||
/// session: a heavily-traded price the market has not traded back through since.
|
||||
///
|
||||
/// ```text
|
||||
/// every `session_len` candles forms a session; its POC (heaviest-volume price) is
|
||||
/// recorded as "naked"
|
||||
/// a naked POC becomes "tested" once a later candle's high-low range covers it
|
||||
/// output = the nearest still-naked POC to the current close (or the close itself
|
||||
/// if every prior POC has been revisited)
|
||||
/// ```
|
||||
///
|
||||
/// A point of control is a magnet — price tends to return to fair value. A *naked*
|
||||
/// (or virgin) POC is one that has not yet been revisited, so it carries an
|
||||
/// outstanding "pull": untested POCs are high-probability targets and
|
||||
/// support/resistance on the approach. This indicator records each completed
|
||||
/// session's POC, marks them tested as price trades through them, and reports the
|
||||
/// closest one still outstanding.
|
||||
///
|
||||
/// The first value lands after `session_len` candles (the first session's POC).
|
||||
/// Each `update` is O(`session_len · bins` + naked-count).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, NakedPoc};
|
||||
///
|
||||
/// let mut indicator = NakedPoc::new(20, 24).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..60 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.2).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NakedPoc {
|
||||
session_len: usize,
|
||||
bins: usize,
|
||||
session: VecDeque<Candle>,
|
||||
naked: Vec<f64>,
|
||||
last_close: f64,
|
||||
ready: bool,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl NakedPoc {
|
||||
/// Construct a Naked POC tracker with the given `session_len` and profile
|
||||
/// `bins`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `session_len` or `bins` is zero.
|
||||
pub fn new(session_len: usize, bins: usize) -> Result<Self> {
|
||||
if session_len == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
session_len,
|
||||
bins,
|
||||
session: VecDeque::with_capacity(session_len),
|
||||
naked: Vec::new(),
|
||||
last_close: 0.0,
|
||||
ready: false,
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(session_len, bins)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.session_len, self.bins)
|
||||
}
|
||||
|
||||
/// Number of currently-naked POCs.
|
||||
pub fn naked_count(&self) -> usize {
|
||||
self.naked.len()
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn session_poc(&self) -> f64 {
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = f64::NEG_INFINITY;
|
||||
for c in &self.session {
|
||||
low = low.min(c.low);
|
||||
high = high.max(c.high);
|
||||
}
|
||||
let span = high - low;
|
||||
if span <= 0.0 {
|
||||
return low;
|
||||
}
|
||||
let width = span / self.bins as f64;
|
||||
let mut hist = vec![0.0; self.bins];
|
||||
for c in &self.session {
|
||||
if c.volume == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let share = c.volume / (hi_idx - lo_idx + 1) as f64;
|
||||
for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
|
||||
*bin += share;
|
||||
}
|
||||
}
|
||||
let mut poc = 0;
|
||||
let mut poc_vol = f64::NEG_INFINITY;
|
||||
for (idx, &vol) in hist.iter().enumerate() {
|
||||
if vol > poc_vol {
|
||||
poc_vol = vol;
|
||||
poc = idx;
|
||||
}
|
||||
}
|
||||
low + (poc as f64 + 0.5) * width
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for NakedPoc {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
// Test outstanding naked POCs against this candle's range.
|
||||
self.naked
|
||||
.retain(|&poc| !(candle.low <= poc && poc <= candle.high));
|
||||
self.last_close = candle.close;
|
||||
|
||||
// Accumulate the session; finalize a POC at the boundary.
|
||||
self.session.push_back(candle);
|
||||
if self.session.len() == self.session_len {
|
||||
let poc = self.session_poc();
|
||||
self.naked.push(poc);
|
||||
self.session.clear();
|
||||
self.ready = true;
|
||||
}
|
||||
|
||||
if !self.ready {
|
||||
return None;
|
||||
}
|
||||
let nearest = self
|
||||
.naked
|
||||
.iter()
|
||||
.copied()
|
||||
.min_by(|a, b| {
|
||||
(a - self.last_close)
|
||||
.abs()
|
||||
.total_cmp(&(b - self.last_close).abs())
|
||||
})
|
||||
.unwrap_or(self.last_close);
|
||||
self.last = Some(nearest);
|
||||
Some(nearest)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.session.clear();
|
||||
self.naked.clear();
|
||||
self.last_close = 0.0;
|
||||
self.ready = false;
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.session_len
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"NakedPoc"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, close: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(f64::midpoint(high, low), high, low, close, volume, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(NakedPoc::new(0, 24), Err(Error::PeriodZero)));
|
||||
assert!(matches!(NakedPoc::new(20, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let n = NakedPoc::new(20, 24).unwrap();
|
||||
assert_eq!(n.params(), (20, 24));
|
||||
assert_eq!(n.naked_count(), 0);
|
||||
assert_eq!(n.warmup_period(), 20);
|
||||
assert_eq!(n.name(), "NakedPoc");
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_session_end() {
|
||||
let mut n = NakedPoc::new(4, 8).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(101.0, 99.0, 100.0, 1_000.0)).collect();
|
||||
let out = n.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_session_poc() {
|
||||
let mut n = NakedPoc::new(4, 16).unwrap();
|
||||
// A session clustered around 100 -> POC near 100.
|
||||
n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
|
||||
assert_eq!(n.naked_count(), 1);
|
||||
let poc = n.value().unwrap();
|
||||
assert!(
|
||||
(poc - 100.0).abs() < 2.0,
|
||||
"POC should be near 100, got {poc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revisit_marks_poc_tested() {
|
||||
let mut n = NakedPoc::new(4, 16).unwrap();
|
||||
// Session 1 around 100 -> naked POC ~100.
|
||||
n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
|
||||
assert_eq!(n.naked_count(), 1);
|
||||
// Trade away at 120 (does not cover 100) -> still naked.
|
||||
n.update(c(121.0, 119.0, 120.0, 1_000.0));
|
||||
assert_eq!(n.naked_count(), 1);
|
||||
// A candle whose range covers 100 -> POC tested -> removed.
|
||||
n.update(c(121.0, 95.0, 100.0, 1_000.0));
|
||||
assert_eq!(n.naked_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_naked_reports_close() {
|
||||
let mut n = NakedPoc::new(4, 16).unwrap();
|
||||
n.batch(&[c(101.0, 99.0, 100.0, 5_000.0); 4]);
|
||||
// Wipe the naked POC with a covering candle.
|
||||
let out = n.update(c(121.0, 95.0, 117.0, 1_000.0)).unwrap();
|
||||
assert_eq!(n.naked_count(), 0);
|
||||
assert!(
|
||||
(out - 117.0).abs() < 1e-9,
|
||||
"with no naked POC, output is the close"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut n = NakedPoc::new(4, 8).unwrap();
|
||||
n.batch(&[c(101.0, 99.0, 100.0, 1_000.0); 6]);
|
||||
assert!(n.is_ready());
|
||||
n.reset();
|
||||
assert!(!n.is_ready());
|
||||
assert_eq!(n.value(), None);
|
||||
assert_eq!(n.naked_count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
let b = 100.0 + (f64::from(i) * 0.25).sin() * 9.0;
|
||||
c(b + 1.0, b - 1.0, b, 1_000.0 + f64::from(i))
|
||||
})
|
||||
.collect();
|
||||
let batch = NakedPoc::new(20, 24).unwrap().batch(&candles);
|
||||
let mut b = NakedPoc::new(20, 24).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_session_reports_price() {
|
||||
// A session with zero high-low span returns the session price directly.
|
||||
let mut n = NakedPoc::new(2, 4).unwrap();
|
||||
n.update(c(50.0, 50.0, 50.0, 10.0));
|
||||
assert_eq!(n.update(c(50.0, 50.0, 50.0, 10.0)), Some(50.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_session_is_handled() {
|
||||
// Zero-volume candles are skipped in the histogram; a POC still emits.
|
||||
let mut n = NakedPoc::new(2, 4).unwrap();
|
||||
n.update(c(60.0, 40.0, 50.0, 0.0));
|
||||
assert!(n.update(c(60.0, 40.0, 50.0, 0.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_of_two_naked_pocs() {
|
||||
// Two untouched POCs at distant prices accumulate; the one nearest the
|
||||
// last close is reported (exercises the min-by comparison).
|
||||
let mut n = NakedPoc::new(2, 4).unwrap();
|
||||
n.update(c(11.0, 9.0, 10.0, 100.0));
|
||||
n.update(c(11.0, 9.0, 10.0, 100.0)); // POC near 10
|
||||
n.update(c(101.0, 99.0, 100.0, 100.0));
|
||||
let v = n.update(c(101.0, 99.0, 100.0, 100.0)).unwrap(); // POC near 100
|
||||
assert!(
|
||||
v > 50.0,
|
||||
"nearest to close 100 should be the upper POC, got {v}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! OI-to-Volume Ratio — open interest relative to traded volume.
|
||||
|
||||
use crate::derivatives::DerivativesTick;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// OI-to-Volume Ratio — open interest divided by the tick's total taker volume, a
|
||||
/// measure of how much position is *held* versus *turned over*.
|
||||
///
|
||||
/// ```text
|
||||
/// OIVR = open_interest / (taker_buy_volume + taker_sell_volume)
|
||||
/// ```
|
||||
///
|
||||
/// A high ratio means open interest dwarfs the volume trading it — positions are
|
||||
/// being held, not churned (low participation, potential complacency or a coiling
|
||||
/// market). A low ratio means heavy volume relative to outstanding interest —
|
||||
/// active churn, often around breakouts or capitulation. Watching the ratio change
|
||||
/// distinguishes new-money trends (OI and volume both rising) from short-covering
|
||||
/// or position rolls.
|
||||
///
|
||||
/// The ratio is non-negative; a tick with zero taker volume reports `0` rather than
|
||||
/// dividing by zero. It is stateless — each tick yields one value (no warmup). Each
|
||||
/// `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativesTick, Indicator, OiToVolumeRatio};
|
||||
///
|
||||
/// let mut indicator = OiToVolumeRatio::new();
|
||||
/// let tick = DerivativesTick::new(0.0, 100.0, 100.0, 100.0, 5_000.0, 0.0, 0.0, 400.0, 600.0, 0.0, 0.0, 0).unwrap();
|
||||
/// let oivr = indicator.update(tick).unwrap();
|
||||
/// assert!((oivr - 5.0).abs() < 1e-12); // 5000 / (400 + 600)
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OiToVolumeRatio {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl OiToVolumeRatio {
|
||||
/// Construct a new OI-to-Volume Ratio. The indicator is parameter-free.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OiToVolumeRatio {
|
||||
type Input = DerivativesTick;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
|
||||
let volume = tick.taker_buy_volume + tick.taker_sell_volume;
|
||||
let ratio = if volume > 0.0 {
|
||||
tick.open_interest / volume
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.ready = true;
|
||||
Some(ratio)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OiToVolumeRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn tick(oi: f64, buy: f64, sell: f64) -> DerivativesTick {
|
||||
DerivativesTick::new_unchecked(
|
||||
0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, buy, sell, 0.0, 0.0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let o = OiToVolumeRatio::new();
|
||||
assert_eq!(o.warmup_period(), 1);
|
||||
assert_eq!(o.name(), "OiToVolumeRatio");
|
||||
assert!(!o.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ratio_reference_value() {
|
||||
let mut o = OiToVolumeRatio::new();
|
||||
assert_relative_eq!(
|
||||
o.update(tick(5_000.0, 400.0, 600.0)).unwrap(),
|
||||
5.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_volume_lowers_ratio() {
|
||||
let mut o = OiToVolumeRatio::new();
|
||||
let held = o.update(tick(5_000.0, 100.0, 100.0)).unwrap();
|
||||
let churned = o.update(tick(5_000.0, 1_000.0, 1_000.0)).unwrap();
|
||||
assert!(churned < held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_is_zero() {
|
||||
let mut o = OiToVolumeRatio::new();
|
||||
assert_relative_eq!(
|
||||
o.update(tick(5_000.0, 0.0, 0.0)).unwrap(),
|
||||
0.0,
|
||||
epsilon = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update() {
|
||||
let mut o = OiToVolumeRatio::new();
|
||||
assert!(!o.is_ready());
|
||||
o.update(tick(5_000.0, 100.0, 100.0));
|
||||
assert!(o.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut o = OiToVolumeRatio::new();
|
||||
o.update(tick(5_000.0, 100.0, 100.0));
|
||||
assert!(o.is_ready());
|
||||
o.reset();
|
||||
assert!(!o.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let ticks: Vec<DerivativesTick> = (0..40)
|
||||
.map(|i| tick(5_000.0, 100.0 + f64::from(i), 100.0))
|
||||
.collect();
|
||||
let batch = OiToVolumeRatio::new().batch(&ticks);
|
||||
let mut b = OiToVolumeRatio::new();
|
||||
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! Open-Interest Momentum — the rate of change of open interest over a lookback.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::derivatives::DerivativesTick;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Open-Interest Momentum — the percentage rate of change of open interest over a
|
||||
/// `period`-tick lookback.
|
||||
///
|
||||
/// ```text
|
||||
/// OIM = 100 · (OI_t − OI_{t−period}) / OI_{t−period}
|
||||
/// ```
|
||||
///
|
||||
/// Where [`OIDelta`](crate::OIDelta) reports the single-tick change in open
|
||||
/// interest, OI Momentum measures the trend in positioning over a window: positive
|
||||
/// values mean open interest is expanding (new money entering — a position build
|
||||
/// that fuels the prevailing move), negative values mean it is contracting
|
||||
/// (positions being closed — deleveraging or short-covering). Read alongside price:
|
||||
/// rising OI with rising price is a strong new-long trend, while rising price with
|
||||
/// falling OI is a short-covering rally on borrowed time.
|
||||
///
|
||||
/// The output is a percentage and may be negative. A zero base open interest
|
||||
/// `period` ticks ago reports `0` rather than dividing by zero. The first value
|
||||
/// lands after `period + 1` inputs. Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativesTick, Indicator, OpenInterestMomentum};
|
||||
///
|
||||
/// let mut indicator = OpenInterestMomentum::new(5).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let oi = 1_000.0 + f64::from(i) * 100.0;
|
||||
/// let tick = DerivativesTick::new(0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
|
||||
/// last = indicator.update(tick);
|
||||
/// }
|
||||
/// assert!(last.unwrap() > 0.0); // expanding OI
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenInterestMomentum {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl OpenInterestMomentum {
|
||||
/// Construct an OI Momentum over a `period`-tick lookback.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period == 0`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period + 1),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured lookback period.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for OpenInterestMomentum {
|
||||
type Input = DerivativesTick;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
|
||||
if self.window.len() == self.period + 1 {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(tick.open_interest);
|
||||
if self.window.len() < self.period + 1 {
|
||||
return None;
|
||||
}
|
||||
let base = *self.window.front().expect("non-empty");
|
||||
let current = tick.open_interest;
|
||||
let oim = if base > 0.0 {
|
||||
100.0 * (current - base) / base
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(oim);
|
||||
Some(oim)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"OpenInterestMomentum"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn tick(oi: f64) -> DerivativesTick {
|
||||
DerivativesTick::new_unchecked(
|
||||
0.0, 100.0, 100.0, 100.0, oi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_period() {
|
||||
assert!(matches!(
|
||||
OpenInterestMomentum::new(0),
|
||||
Err(Error::PeriodZero)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let o = OpenInterestMomentum::new(5).unwrap();
|
||||
assert_eq!(o.period(), 5);
|
||||
assert_eq!(o.warmup_period(), 6);
|
||||
assert_eq!(o.name(), "OpenInterestMomentum");
|
||||
assert!(!o.is_ready());
|
||||
assert_eq!(o.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut o = OpenInterestMomentum::new(3).unwrap();
|
||||
let ticks: Vec<DerivativesTick> = (0..6)
|
||||
.map(|i| tick(1_000.0 + f64::from(i) * 100.0))
|
||||
.collect();
|
||||
let out = o.batch(&ticks);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// period 2: OI 1000 -> 1200 over the window -> +20%.
|
||||
let mut o = OpenInterestMomentum::new(2).unwrap();
|
||||
let out = o.batch(&[tick(1_000.0), tick(1_100.0), tick(1_200.0)]);
|
||||
assert_relative_eq!(out[2].unwrap(), 20.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_oi_is_positive() {
|
||||
let mut o = OpenInterestMomentum::new(5).unwrap();
|
||||
let ticks: Vec<DerivativesTick> = (0..20)
|
||||
.map(|i| tick(1_000.0 + f64::from(i) * 100.0))
|
||||
.collect();
|
||||
let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
|
||||
assert!(last > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contracting_oi_is_negative() {
|
||||
let mut o = OpenInterestMomentum::new(5).unwrap();
|
||||
let ticks: Vec<DerivativesTick> = (0..20)
|
||||
.map(|i| tick(3_000.0 - f64::from(i) * 100.0))
|
||||
.collect();
|
||||
let last = o.batch(&ticks).into_iter().flatten().last().unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_base_is_zero() {
|
||||
let mut o = OpenInterestMomentum::new(2).unwrap();
|
||||
let out = o.batch(&[tick(0.0), tick(100.0), tick(200.0)]);
|
||||
assert_relative_eq!(out[2].unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut o = OpenInterestMomentum::new(3).unwrap();
|
||||
o.batch(
|
||||
&(0..10)
|
||||
.map(|i| tick(1_000.0 + f64::from(i) * 50.0))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(o.is_ready());
|
||||
o.reset();
|
||||
assert!(!o.is_ready());
|
||||
assert_eq!(o.value(), None);
|
||||
assert_eq!(o.update(tick(1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let ticks: Vec<DerivativesTick> = (0..80)
|
||||
.map(|i| tick(1_000.0 + (f64::from(i) * 0.25).sin() * 300.0))
|
||||
.collect();
|
||||
let batch = OpenInterestMomentum::new(10).unwrap().batch(&ticks);
|
||||
let mut b = OpenInterestMomentum::new(10).unwrap();
|
||||
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Perpetual Premium Index — the perp mark price relative to spot.
|
||||
|
||||
use crate::derivatives::DerivativesTick;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Perpetual Premium Index — the perpetual's mark price relative to the spot index
|
||||
/// it tracks, as a fraction.
|
||||
///
|
||||
/// ```text
|
||||
/// premium = (mark_price − index_price) / index_price
|
||||
/// ```
|
||||
///
|
||||
/// A perpetual swap is pegged to spot by the funding mechanism, but it can still
|
||||
/// trade at a premium (above spot) or discount (below). A positive premium signals
|
||||
/// net long demand willing to pay up to hold the perp — bullish positioning, and
|
||||
/// the proximate driver of positive funding; a negative premium signals the
|
||||
/// reverse. Sustained extremes flag crowded positioning ripe for a funding-driven
|
||||
/// mean reversion.
|
||||
///
|
||||
/// The output is centred on zero and dimensionless (a fraction; multiply by `100`
|
||||
/// for percent). `index_price` is validated strictly positive on the tick, so the
|
||||
/// division is always defined. It is stateless — each tick yields one value (no
|
||||
/// warmup). Each `update` is O(1).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DerivativesTick, Indicator, PerpetualPremiumIndex};
|
||||
///
|
||||
/// let mut indicator = PerpetualPremiumIndex::new();
|
||||
/// // Mark 101 vs index 100 -> +1% premium.
|
||||
/// let tick = DerivativesTick::new(0.0, 101.0, 100.0, 101.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0).unwrap();
|
||||
/// let premium = indicator.update(tick).unwrap();
|
||||
/// assert!((premium - 0.01).abs() < 1e-12);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PerpetualPremiumIndex {
|
||||
ready: bool,
|
||||
}
|
||||
|
||||
impl PerpetualPremiumIndex {
|
||||
/// Construct a new Perpetual Premium Index. The indicator is parameter-free.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self { ready: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for PerpetualPremiumIndex {
|
||||
type Input = DerivativesTick;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, tick: DerivativesTick) -> Option<f64> {
|
||||
let premium = (tick.mark_price - tick.index_price) / tick.index_price;
|
||||
self.ready = true;
|
||||
Some(premium)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.ready = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.ready
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"PerpetualPremiumIndex"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn tick(mark: f64, index: f64) -> DerivativesTick {
|
||||
DerivativesTick::new_unchecked(0.0, mark, index, mark, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = PerpetualPremiumIndex::new();
|
||||
assert_eq!(p.warmup_period(), 1);
|
||||
assert_eq!(p.name(), "PerpetualPremiumIndex");
|
||||
assert!(!p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_reference_value() {
|
||||
let mut p = PerpetualPremiumIndex::new();
|
||||
assert_relative_eq!(p.update(tick(101.0, 100.0)).unwrap(), 0.01, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discount_is_negative() {
|
||||
let mut p = PerpetualPremiumIndex::new();
|
||||
assert!(p.update(tick(99.0, 100.0)).unwrap() < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_par_is_zero() {
|
||||
let mut p = PerpetualPremiumIndex::new();
|
||||
assert_relative_eq!(p.update(tick(100.0, 100.0)).unwrap(), 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_after_first_update() {
|
||||
let mut p = PerpetualPremiumIndex::new();
|
||||
assert!(!p.is_ready());
|
||||
p.update(tick(100.0, 100.0));
|
||||
assert!(p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = PerpetualPremiumIndex::new();
|
||||
p.update(tick(101.0, 100.0));
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let ticks: Vec<DerivativesTick> = (0..40)
|
||||
.map(|i| tick(100.0 + (f64::from(i) * 0.3).sin(), 100.0))
|
||||
.collect();
|
||||
let batch = PerpetualPremiumIndex::new().batch(&ticks);
|
||||
let mut b = PerpetualPremiumIndex::new();
|
||||
let streamed: Vec<_> = ticks.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Profile Shape — classifies the volume profile as b-shape, P-shape, or D/normal.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Profile Shape — classifies a rolling volume profile by where its point of
|
||||
/// control (POC) sits within the range: `b`, `P`, or `D` (normal).
|
||||
///
|
||||
/// ```text
|
||||
/// build a `bins`-bucket volume profile over the last `period` candles
|
||||
/// poc_idx = bin with the most volume
|
||||
/// +1 P-shape : POC in the upper third (heavy top, thin tail down) — short-covering / accumulation
|
||||
/// −1 b-shape : POC in the lower third (heavy bottom, thin tail up) — long-liquidation / distribution
|
||||
/// 0 D/normal: POC in the middle third (balanced bell)
|
||||
/// ```
|
||||
///
|
||||
/// Market Profile readers classify the day's shape by the location of the heaviest
|
||||
/// trading. A **P-shape** (control high, a thin tail beneath) typically marks
|
||||
/// short-covering or the start of accumulation; a **b-shape** (control low, thin
|
||||
/// tail above) marks long liquidation or distribution; a **D-shape** is a balanced,
|
||||
/// two-sided day. Reducing the profile to this three-way code gives a compact,
|
||||
/// streaming read of market posture.
|
||||
///
|
||||
/// The output is `+1` / `0` / `−1`. The first value lands after `period` candles;
|
||||
/// each `update` rebuilds the profile in O(`period · bins`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, ProfileShape};
|
||||
///
|
||||
/// let mut indicator = ProfileShape::new(20, 24).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + (f64::from(i) * 0.3).sin() * 5.0;
|
||||
/// let c = Candle::new(base, base + 1.0, base - 1.0, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProfileShape {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
window: VecDeque<Candle>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl ProfileShape {
|
||||
/// Construct a Profile Shape classifier.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` is zero, or
|
||||
/// [`Error::InvalidPeriod`] if `bins < 3` (the three-way split needs three
|
||||
/// zones).
|
||||
pub fn new(period: usize, bins: usize) -> Result<Self> {
|
||||
if period == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
if bins < 3 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "profile shape needs bins >= 3",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.period, self.bins)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn poc_index(&self) -> usize {
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = f64::NEG_INFINITY;
|
||||
for c in &self.window {
|
||||
low = low.min(c.low);
|
||||
high = high.max(c.high);
|
||||
}
|
||||
let mut hist = vec![0.0; self.bins];
|
||||
let span = high - low;
|
||||
if span > 0.0 {
|
||||
let width = span / self.bins as f64;
|
||||
for c in &self.window {
|
||||
if c.volume == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let share = c.volume / (hi_idx - lo_idx + 1) as f64;
|
||||
for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
|
||||
*bin += share;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut poc_idx = 0;
|
||||
let mut poc_vol = f64::NEG_INFINITY;
|
||||
for (idx, &vol) in hist.iter().enumerate() {
|
||||
if vol > poc_vol {
|
||||
poc_vol = vol;
|
||||
poc_idx = idx;
|
||||
}
|
||||
}
|
||||
poc_idx
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for ProfileShape {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let poc = self.poc_index();
|
||||
let lower = self.bins / 3;
|
||||
let upper = self.bins - self.bins / 3;
|
||||
let shape = if poc >= upper {
|
||||
1.0
|
||||
} else if poc < lower {
|
||||
-1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
self.last = Some(shape);
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"ProfileShape"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64, volume: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
volume,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_params() {
|
||||
assert!(matches!(ProfileShape::new(0, 24), Err(Error::PeriodZero)));
|
||||
assert!(matches!(
|
||||
ProfileShape::new(20, 2),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let p = ProfileShape::new(20, 24).unwrap();
|
||||
assert_eq!(p.params(), (20, 24));
|
||||
assert_eq!(p.warmup_period(), 20);
|
||||
assert_eq!(p.name(), "ProfileShape");
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut p = ProfileShape::new(4, 9).unwrap();
|
||||
let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
|
||||
let out = p.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_top_is_p_shape() {
|
||||
// Volume concentrated near the top of the range -> P-shape -> +1.
|
||||
let mut p = ProfileShape::new(6, 9).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..5).map(|_| c(119.0, 117.0, 5_000.0)).collect();
|
||||
candles.push(c(119.0, 80.0, 50.0)); // a thin tail down to 80
|
||||
let last = p.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavy_bottom_is_b_shape() {
|
||||
let mut p = ProfileShape::new(6, 9).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..5).map(|_| c(83.0, 81.0, 5_000.0)).collect();
|
||||
candles.push(c(120.0, 81.0, 50.0)); // a thin tail up to 120
|
||||
let last = p.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balanced_is_d_shape() {
|
||||
// Volume concentrated in the middle -> D/normal -> 0.
|
||||
let mut p = ProfileShape::new(6, 9).unwrap();
|
||||
let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
|
||||
candles.push(c(120.0, 80.0, 50.0)); // thin tails both ways, POC in the middle
|
||||
let last = p.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut p = ProfileShape::new(4, 9).unwrap();
|
||||
p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
|
||||
assert!(p.is_ready());
|
||||
p.reset();
|
||||
assert!(!p.is_ready());
|
||||
assert_eq!(p.value(), None);
|
||||
assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| {
|
||||
c(
|
||||
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
|
||||
90.0,
|
||||
1_000.0 + f64::from(i),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let batch = ProfileShape::new(20, 24).unwrap().batch(&candles);
|
||||
let mut b = ProfileShape::new(20, 24).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_handled() {
|
||||
// Zero high-low span skips the histogram pass entirely.
|
||||
let mut p = ProfileShape::new(2, 4).unwrap();
|
||||
p.update(c(50.0, 50.0, 10.0));
|
||||
assert!(p.update(c(50.0, 50.0, 10.0)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_volume_window_is_handled() {
|
||||
// Non-flat window of zero-volume candles hits the skip path.
|
||||
let mut p = ProfileShape::new(2, 4).unwrap();
|
||||
p.update(c(60.0, 40.0, 0.0));
|
||||
assert!(p.update(c(60.0, 40.0, 0.0)).is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Single Prints — count of price levels touched by exactly one bar (low acceptance).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ohlcv::Candle;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Single Prints — the number of price levels (bins) in the rolling profile that
|
||||
/// were touched by **exactly one** bar, marking zones of low acceptance / fast
|
||||
/// movement.
|
||||
///
|
||||
/// ```text
|
||||
/// for each of `bins` price levels over the last `period` candles:
|
||||
/// touches = number of bars whose high-low range covers that level
|
||||
/// SinglePrints = count of levels with touches == 1
|
||||
/// ```
|
||||
///
|
||||
/// In Market Profile a "single print" is a price the market traded through so
|
||||
/// quickly that only one time-period printed there — a footprint of an aggressive,
|
||||
/// one-sided move with little two-way trade. Single prints often act as support or
|
||||
/// resistance on a retest (the imbalance gets "repaired") and mark the edges of
|
||||
/// rapid moves. Counting them per profile gives a streaming gauge of how much of
|
||||
/// the recent range was traversed without acceptance: a high count means a fast,
|
||||
/// trending, low-rotation market; a low count means a balanced, well-traded range.
|
||||
///
|
||||
/// The output is a non-negative count. The first value lands after `period`
|
||||
/// candles; each `update` rebuilds the touch histogram in O(`period · bins`).
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Candle, Indicator, SinglePrints};
|
||||
///
|
||||
/// let mut indicator = SinglePrints::new(20, 24).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// let base = 100.0 + f64::from(i); // a one-directional ramp -> many single prints
|
||||
/// let c = Candle::new(base, base + 0.5, base - 0.5, base, 1_000.0, 0).unwrap();
|
||||
/// last = indicator.update(c);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SinglePrints {
|
||||
period: usize,
|
||||
bins: usize,
|
||||
window: VecDeque<Candle>,
|
||||
last: Option<f64>,
|
||||
}
|
||||
|
||||
impl SinglePrints {
|
||||
/// Construct a Single Prints counter.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `period` or `bins` is zero.
|
||||
pub fn new(period: usize, bins: usize) -> Result<Self> {
|
||||
if period == 0 || bins == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
bins,
|
||||
window: VecDeque::with_capacity(period),
|
||||
last: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured `(period, bins)`.
|
||||
pub const fn params(&self) -> (usize, usize) {
|
||||
(self.period, self.bins)
|
||||
}
|
||||
|
||||
/// Current value if available.
|
||||
pub const fn value(&self) -> Option<f64> {
|
||||
self.last
|
||||
}
|
||||
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
fn count_single_prints(&self) -> usize {
|
||||
let mut low = f64::INFINITY;
|
||||
let mut high = f64::NEG_INFINITY;
|
||||
for c in &self.window {
|
||||
low = low.min(c.low);
|
||||
high = high.max(c.high);
|
||||
}
|
||||
let span = high - low;
|
||||
if span <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let width = span / self.bins as f64;
|
||||
let mut touches = vec![0u32; self.bins];
|
||||
for c in &self.window {
|
||||
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
|
||||
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
|
||||
for t in touches.iter_mut().take(hi_idx + 1).skip(lo_idx) {
|
||||
*t += 1;
|
||||
}
|
||||
}
|
||||
touches.iter().filter(|&&t| t == 1).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SinglePrints {
|
||||
type Input = Candle;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, candle: Candle) -> Option<f64> {
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(candle);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let count = self.count_single_prints() as f64;
|
||||
self.last = Some(count);
|
||||
Some(count)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.last = None;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.last.is_some()
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"SinglePrints"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn c(high: f64, low: f64) -> Candle {
|
||||
Candle::new_unchecked(
|
||||
f64::midpoint(high, low),
|
||||
high,
|
||||
low,
|
||||
f64::midpoint(high, low),
|
||||
1_000.0,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_params() {
|
||||
assert!(matches!(SinglePrints::new(0, 24), Err(Error::PeriodZero)));
|
||||
assert!(matches!(SinglePrints::new(20, 0), Err(Error::PeriodZero)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let s = SinglePrints::new(20, 24).unwrap();
|
||||
assert_eq!(s.params(), (20, 24));
|
||||
assert_eq!(s.warmup_period(), 20);
|
||||
assert_eq!(s.name(), "SinglePrints");
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_emission_at_warmup_period() {
|
||||
let mut s = SinglePrints::new(4, 8).unwrap();
|
||||
let candles: Vec<Candle> = (0..6)
|
||||
.map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
|
||||
.collect();
|
||||
let out = s.batch(&candles);
|
||||
for v in out.iter().take(3) {
|
||||
assert!(v.is_none());
|
||||
}
|
||||
assert!(out[3].is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_range_has_no_single_prints() {
|
||||
// Every bar covers the same single price -> zero span -> 0.
|
||||
let mut s = SinglePrints::new(4, 8).unwrap();
|
||||
let last = s
|
||||
.batch(&[c(100.0, 100.0); 6])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_eq!(last, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ramp_has_many_single_prints() {
|
||||
// A one-directional ramp visits most levels exactly once.
|
||||
let mut s = SinglePrints::new(10, 24).unwrap();
|
||||
let candles: Vec<Candle> = (0..10)
|
||||
.map(|i| c(100.5 + f64::from(i), 99.5 + f64::from(i)))
|
||||
.collect();
|
||||
let last = s.batch(&candles).into_iter().flatten().last().unwrap();
|
||||
assert!(
|
||||
last > 0.0,
|
||||
"a ramp should produce single prints, got {last}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_non_negative() {
|
||||
let mut s = SinglePrints::new(14, 24).unwrap();
|
||||
for v in s
|
||||
.batch(
|
||||
&(0..60)
|
||||
.map(|i| c(110.0 + (f64::from(i) * 0.3).sin() * 8.0, 90.0))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(v >= 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut s = SinglePrints::new(4, 8).unwrap();
|
||||
s.batch(
|
||||
&(0..6)
|
||||
.map(|i| c(101.0 + f64::from(i), 99.0 + f64::from(i)))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert!(s.is_ready());
|
||||
s.reset();
|
||||
assert!(!s.is_ready());
|
||||
assert_eq!(s.value(), None);
|
||||
assert_eq!(s.update(c(101.0, 99.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let candles: Vec<Candle> = (0..80)
|
||||
.map(|i| c(110.0 + (f64::from(i) * 0.25).sin() * 9.0, 90.0))
|
||||
.collect();
|
||||
let batch = SinglePrints::new(20, 24).unwrap().batch(&candles);
|
||||
let mut b = SinglePrints::new(20, 24).unwrap();
|
||||
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Sterling Ratio — mean return over the average drawdown of the equity curve.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Sterling Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// equity_t = Π_{i<=t} (1 + return_i) (compounded curve)
|
||||
/// peak_t = max_{s<=t} equity_s
|
||||
/// dd_t = (peak_t − equity_t) / peak_t (fractional drawdown, >= 0)
|
||||
/// Sterling = mean(returns) / mean(dd_t)
|
||||
/// ```
|
||||
///
|
||||
/// The Sterling Ratio rewards return per unit of *typical* pain: it divides the
|
||||
/// average per-period return by the **average drawdown** experienced along the
|
||||
/// compounded equity curve. Of the three drawdown-based ratios Wickra ships it is
|
||||
/// the gentlest on outliers — averaging the drawdowns means one deep crater does
|
||||
/// not dominate the way it does in the [`BurkeRatio`](crate::BurkeRatio) (which
|
||||
/// sums squared drawdowns) or the [`MartinRatio`](crate::MartinRatio) (which uses
|
||||
/// the root-mean-square percentage drawdown). A window that never draws down has
|
||||
/// zero average drawdown and the indicator reports `0.0`.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` rebuilds the equity
|
||||
/// curve over the window (O(period)), which is O(1) in the length of the overall
|
||||
/// series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, SterlingRatio};
|
||||
///
|
||||
/// let mut indicator = SterlingRatio::new(12).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..24 {
|
||||
/// last = indicator.update((f64::from(i) * 0.5).sin() * 0.05);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SterlingRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl SterlingRatio {
|
||||
/// Construct a Sterling Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`.
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "sterling ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let length = self.window.len() as f64;
|
||||
let mut sum_return = 0.0;
|
||||
let mut sum_drawdown = 0.0;
|
||||
let mut equity = 1.0;
|
||||
let mut peak: f64 = 1.0;
|
||||
for ret in &self.window {
|
||||
sum_return += *ret;
|
||||
equity *= 1.0 + *ret;
|
||||
peak = peak.max(equity);
|
||||
sum_drawdown += (peak - equity) / peak;
|
||||
}
|
||||
let avg_drawdown = sum_drawdown / length;
|
||||
if avg_drawdown > 0.0 {
|
||||
(sum_return / length) / avg_drawdown
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for SterlingRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
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 {
|
||||
"SterlingRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
SterlingRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let sr = SterlingRatio::new(12).unwrap();
|
||||
assert_eq!(sr.period(), 12);
|
||||
assert_eq!(sr.warmup_period(), 12);
|
||||
assert_eq!(sr.name(), "SterlingRatio");
|
||||
assert!(!sr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.1, -0.1, 0.1]:
|
||||
// equity 1.1, 0.99, 1.089; peak stays 1.1.
|
||||
// dd = [0, 0.1, 0.01]; avg_dd = 0.11/3; mean_return = 0.1/3.
|
||||
// Sterling = (0.1/3) / (0.11/3) = 0.1/0.11.
|
||||
let mut sr = SterlingRatio::new(3).unwrap();
|
||||
let out = sr.batch(&[0.1, -0.1, 0.1]);
|
||||
assert_relative_eq!(out[2].unwrap(), 0.1_f64 / 0.11, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_drawdown_is_zero() {
|
||||
// Monotonically rising equity never draws down.
|
||||
let mut sr = SterlingRatio::new(3).unwrap();
|
||||
let last = sr
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn losing_window_is_negative() {
|
||||
let mut sr = SterlingRatio::new(3).unwrap();
|
||||
let last = sr
|
||||
.batch(&[-0.05, -0.02, -0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert!(last < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut sr = SterlingRatio::new(3).unwrap();
|
||||
assert_eq!(sr.update(0.1), None);
|
||||
assert_eq!(sr.update(f64::NAN), None);
|
||||
assert_eq!(sr.update(-0.1), None);
|
||||
assert!(sr.update(0.1).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut sr = SterlingRatio::new(3).unwrap();
|
||||
sr.batch(&[0.1, -0.1, 0.1]);
|
||||
assert!(sr.is_ready());
|
||||
sr.reset();
|
||||
assert!(!sr.is_ready());
|
||||
assert_eq!(sr.update(0.1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.05)
|
||||
.collect();
|
||||
let batch = SterlingRatio::new(12).unwrap().batch(&rets);
|
||||
let mut streamer = SterlingRatio::new(12).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Tail Ratio — the right tail (95th percentile) over the absolute left tail (5th percentile).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Tail Ratio over a trailing window of `period` returns.
|
||||
///
|
||||
/// ```text
|
||||
/// TailRatio = P95(returns) / |P5(returns)|
|
||||
/// ```
|
||||
///
|
||||
/// The Tail Ratio contrasts the magnitude of the best outcomes against the worst:
|
||||
/// the 95th percentile of the return distribution divided by the absolute value of
|
||||
/// the 5th percentile. A value above `1.0` means the right tail (upside surprises)
|
||||
/// is fatter than the left tail (downside surprises); below `1.0` means crashes are
|
||||
/// larger than rallies. It is a distribution-shape statistic, distinct from the
|
||||
/// average-based [`SharpeRatio`](crate::SharpeRatio): two series with the same mean
|
||||
/// and variance can have very different tail ratios.
|
||||
///
|
||||
/// Percentiles are computed by linear interpolation over the sorted window
|
||||
/// (the same rule `NumPy` uses by default). A window whose 5th percentile is exactly
|
||||
/// zero has no measurable left tail and the indicator reports `0.0` rather than
|
||||
/// dividing by zero.
|
||||
///
|
||||
/// The first value lands after `period` returns; each `update` re-sorts the window
|
||||
/// (O(period log period)), which is O(1) in the length of the overall series.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, TailRatio};
|
||||
///
|
||||
/// let mut indicator = TailRatio::new(20).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TailRatio {
|
||||
period: usize,
|
||||
window: VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl TailRatio {
|
||||
/// Construct a Tail Ratio over `period` returns.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2` (percentiles need at least
|
||||
/// two observations to interpolate).
|
||||
pub fn new(period: usize) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "tail ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
window: VecDeque::with_capacity(period),
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn compute(&self) -> f64 {
|
||||
let mut sorted: Vec<f64> = self.window.iter().copied().collect();
|
||||
sorted.sort_unstable_by(f64::total_cmp);
|
||||
let upper = percentile(&sorted, 95.0);
|
||||
let lower = percentile(&sorted, 5.0).abs();
|
||||
if lower > 0.0 {
|
||||
upper / lower
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear-interpolation percentile of an ascending, non-empty slice.
|
||||
fn percentile(sorted: &[f64], pct: f64) -> f64 {
|
||||
let last_index = sorted.len() - 1;
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let rank = pct / 100.0 * last_index as f64;
|
||||
let floor = rank.floor();
|
||||
// `rank` lies in `[0, last_index]`, so its floor is a valid in-bounds index.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let lower = floor as usize;
|
||||
if lower >= last_index {
|
||||
return sorted[last_index];
|
||||
}
|
||||
let frac = rank - floor;
|
||||
sorted[lower] + frac * (sorted[lower + 1] - sorted[lower])
|
||||
}
|
||||
|
||||
impl Indicator for TailRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
self.window.pop_front();
|
||||
}
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
Some(self.compute())
|
||||
}
|
||||
|
||||
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 {
|
||||
"TailRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
TailRatio::new(1),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
TailRatio::new(0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let tr = TailRatio::new(20).unwrap();
|
||||
assert_eq!(tr.period(), 20);
|
||||
assert_eq!(tr.warmup_period(), 20);
|
||||
assert_eq!(tr.name(), "TailRatio");
|
||||
assert!(!tr.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// sorted window [-0.04, -0.02, 0.0, 0.02, 0.04], last_index = 4.
|
||||
// P95: rank 3.8 -> 0.02 + 0.8*(0.04-0.02) = 0.036.
|
||||
// P5: rank 0.2 -> -0.04 + 0.2*(0.02) = -0.036, abs 0.036.
|
||||
// ratio = 0.036 / 0.036 = 1.0.
|
||||
let mut tr = TailRatio::new(5).unwrap();
|
||||
let out = tr.batch(&[-0.04, -0.02, 0.0, 0.02, 0.04]);
|
||||
assert_relative_eq!(out[4].unwrap(), 1.0, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fatter_right_tail_exceeds_one() {
|
||||
let mut tr = TailRatio::new(5).unwrap();
|
||||
let out = tr.batch(&[-0.01, 0.0, 0.01, 0.02, 0.10]);
|
||||
assert!(out[4].unwrap() > 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_window_is_zero() {
|
||||
let mut tr = TailRatio::new(4).unwrap();
|
||||
let last = tr.batch(&[0.0; 4]).into_iter().flatten().last().unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut tr = TailRatio::new(3).unwrap();
|
||||
assert_eq!(tr.update(0.01), None);
|
||||
assert_eq!(tr.update(f64::NAN), None);
|
||||
assert_eq!(tr.update(0.02), None);
|
||||
assert!(tr.update(0.03).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut tr = TailRatio::new(3).unwrap();
|
||||
tr.batch(&[-0.01, 0.0, 0.02]);
|
||||
assert!(tr.is_ready());
|
||||
tr.reset();
|
||||
assert!(!tr.is_ready());
|
||||
assert_eq!(tr.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.02)
|
||||
.collect();
|
||||
let batch = TailRatio::new(15).unwrap().batch(&rets);
|
||||
let mut streamer = TailRatio::new(15).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentile_at_top_returns_last() {
|
||||
// When the rank floor reaches the final index (the 100th percentile), the
|
||||
// helper returns the largest element without interpolating past the end.
|
||||
assert_relative_eq!(percentile(&[1.0, 2.0, 3.0], 100.0), 3.0, epsilon = 1e-12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Upside Potential Ratio (Sortino, van der Meer & Plantinga) — upside mean over downside deviation.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Upside Potential Ratio over a trailing window of `period` returns, measured
|
||||
/// relative to a minimal acceptable return (`mar`).
|
||||
///
|
||||
/// ```text
|
||||
/// upside = mean( max(r − mar, 0) ) over the window
|
||||
/// downside = sqrt( mean( min(r − mar, 0)² ) ) over the window
|
||||
/// UPR = upside / downside
|
||||
/// ```
|
||||
///
|
||||
/// Where the [`SharpeRatio`](crate::SharpeRatio) divides excess return by *total*
|
||||
/// volatility (penalising upside and downside symmetrically), the Upside Potential
|
||||
/// Ratio rewards only the average outperformance above the threshold while
|
||||
/// penalising solely the downside deviation below it. It is the purest expression
|
||||
/// of the Sortino philosophy: investors do not dislike upside variance, only
|
||||
/// shortfall risk.
|
||||
///
|
||||
/// `mar` (minimal acceptable return) is the per-period hurdle the caller supplies
|
||||
/// (e.g. `0.0` for break-even, or a target rate matching the return frequency). A
|
||||
/// window that never breaches the threshold has zero downside deviation; the
|
||||
/// indicator then reports `0.0` rather than dividing by zero.
|
||||
///
|
||||
/// Each `update` is O(1) — running sums maintain the upside total and the
|
||||
/// downside sum-of-squares as the window slides.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, UpsidePotentialRatio};
|
||||
///
|
||||
/// let mut indicator = UpsidePotentialRatio::new(20, 0.0).unwrap();
|
||||
/// let mut last = None;
|
||||
/// for i in 0..40 {
|
||||
/// last = indicator.update((f64::from(i) * 0.3).sin() * 0.02);
|
||||
/// }
|
||||
/// assert!(last.is_some());
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UpsidePotentialRatio {
|
||||
period: usize,
|
||||
mar: f64,
|
||||
window: VecDeque<f64>,
|
||||
sum_upside: f64,
|
||||
sum_downside_sq: f64,
|
||||
}
|
||||
|
||||
impl UpsidePotentialRatio {
|
||||
/// Construct an Upside Potential Ratio over `period` returns with minimal
|
||||
/// acceptable return `mar`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `period < 2`, or
|
||||
/// [`Error::InvalidParameter`] if `mar` is not finite.
|
||||
pub fn new(period: usize, mar: f64) -> Result<Self> {
|
||||
if period < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "upside potential ratio needs period >= 2",
|
||||
});
|
||||
}
|
||||
if !mar.is_finite() {
|
||||
return Err(Error::InvalidParameter {
|
||||
message: "mar must be finite",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
period,
|
||||
mar,
|
||||
window: VecDeque::with_capacity(period),
|
||||
sum_upside: 0.0,
|
||||
sum_downside_sq: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Configured window of returns.
|
||||
pub const fn period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
/// Configured minimal acceptable return.
|
||||
pub const fn mar(&self) -> f64 {
|
||||
self.mar
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for UpsidePotentialRatio {
|
||||
type Input = f64;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, ret: f64) -> Option<f64> {
|
||||
if !ret.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if self.window.len() == self.period {
|
||||
let old = self.window.pop_front().expect("non-empty");
|
||||
let excess = old - self.mar;
|
||||
self.sum_upside -= excess.max(0.0);
|
||||
self.sum_downside_sq -= excess.min(0.0).powi(2);
|
||||
}
|
||||
let excess = ret - self.mar;
|
||||
self.sum_upside += excess.max(0.0);
|
||||
self.sum_downside_sq += excess.min(0.0).powi(2);
|
||||
self.window.push_back(ret);
|
||||
if self.window.len() < self.period {
|
||||
return None;
|
||||
}
|
||||
let n = self.period as f64;
|
||||
let upside_mean = self.sum_upside / n;
|
||||
let downside_dev = (self.sum_downside_sq / n).sqrt();
|
||||
if downside_dev > 0.0 {
|
||||
Some(upside_mean / downside_dev)
|
||||
} else {
|
||||
Some(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.window.clear();
|
||||
self.sum_upside = 0.0;
|
||||
self.sum_downside_sq = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.period
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.window.len() == self.period
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"UpsidePotentialRatio"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
#[test]
|
||||
fn rejects_period_less_than_two() {
|
||||
assert!(matches!(
|
||||
UpsidePotentialRatio::new(1, 0.0),
|
||||
Err(Error::InvalidPeriod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_finite_mar() {
|
||||
assert!(matches!(
|
||||
UpsidePotentialRatio::new(10, f64::NAN),
|
||||
Err(Error::InvalidParameter { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let upr = UpsidePotentialRatio::new(20, 0.001).unwrap();
|
||||
assert_eq!(upr.period(), 20);
|
||||
assert_relative_eq!(upr.mar(), 0.001, epsilon = 1e-12);
|
||||
assert_eq!(upr.warmup_period(), 20);
|
||||
assert_eq!(upr.name(), "UpsidePotentialRatio");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_value() {
|
||||
// returns [0.02, -0.01, 0.03, -0.02], mar = 0.
|
||||
// upside = (0.02 + 0 + 0.03 + 0)/4 = 0.0125.
|
||||
// downside = sqrt((0 + 0.0001 + 0 + 0.0004)/4) = sqrt(0.000125).
|
||||
// UPR = 0.0125 / sqrt(0.000125).
|
||||
let mut upr = UpsidePotentialRatio::new(4, 0.0).unwrap();
|
||||
let out = upr.batch(&[0.02, -0.01, 0.03, -0.02]);
|
||||
let expected = 0.0125_f64 / (0.000_125_f64).sqrt();
|
||||
assert_relative_eq!(out[3].unwrap(), expected, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_downside_is_zero() {
|
||||
let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
|
||||
let last = upr
|
||||
.batch(&[0.01, 0.02, 0.03])
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_finite_input() {
|
||||
let mut upr = UpsidePotentialRatio::new(3, 0.0).unwrap();
|
||||
assert_eq!(upr.update(0.01), None);
|
||||
assert_eq!(upr.update(f64::INFINITY), None);
|
||||
assert_eq!(upr.update(-0.02), None);
|
||||
assert!(upr.update(0.03).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut upr = UpsidePotentialRatio::new(2, 0.0).unwrap();
|
||||
upr.batch(&[0.02, -0.01]);
|
||||
assert!(upr.is_ready());
|
||||
upr.reset();
|
||||
assert!(!upr.is_ready());
|
||||
assert_eq!(upr.update(0.01), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let rets: Vec<f64> = (0..60)
|
||||
.map(|i| (f64::from(i) * 0.25).sin() * 0.02)
|
||||
.collect();
|
||||
let batch = UpsidePotentialRatio::new(12, 0.0).unwrap().batch(&rets);
|
||||
let mut streamer = UpsidePotentialRatio::new(12, 0.0).unwrap();
|
||||
let streamed: Vec<_> = rets.iter().map(|r| streamer.update(*r)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
}
|
||||
@@ -66,95 +66,99 @@ pub use indicators::{
|
||||
AverageDrawdown, AvgPrice, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower,
|
||||
BandpassFilter, Bat, BeltHold, Beta, BetaNeutralSpread, BetterVolume, BipowerVariation,
|
||||
BodySizePct, BollingerBands, BollingerBandwidth, BollingerOutput, BomarBands, BomarBandsOutput,
|
||||
BreadthThrust, Breakaway, BullishPercentIndex, Butterfly, CalendarSpread, CalmarRatio,
|
||||
Camarilla, CamarillaPivotsOutput, CandleVolume, CandleVolumeOutput, Cci, CenterOfGravity,
|
||||
CentralPivotRange, CentralPivotRangeOutput, Cfo, ChaikinMoneyFlow, ChaikinOscillator,
|
||||
ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||
BreadthThrust, Breakaway, BullishPercentIndex, BurkeRatio, Butterfly, CalendarSpread,
|
||||
CalmarRatio, Camarilla, CamarillaPivotsOutput, CandleVolume, CandleVolumeOutput, Cci,
|
||||
CenterOfGravity, CentralPivotRange, CentralPivotRangeOutput, Cfo, ChaikinMoneyFlow,
|
||||
ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandeKrollStopOutput, ChandelierExit,
|
||||
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, CloseVsOpen,
|
||||
ClosingMarubozu, Cmo, CoefficientOfVariation, Cointegration, CointegrationOutput,
|
||||
ConcealingBabySwallow, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator,
|
||||
Counterattack, Crab, CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle,
|
||||
CyberneticCycle, Cypher, DayOfWeekProfile, DayOfWeekProfileOutput, Decycler,
|
||||
DecyclerOscillator, Dema, DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope,
|
||||
DerivativeOscillator, DetrendedStdDev, DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian,
|
||||
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput,
|
||||
DoubleTopBottom, DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, DumplingTop,
|
||||
Dx, DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma,
|
||||
ElderImpulse, ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema,
|
||||
EmpiricalModeDecomposition, Engulfing, Equivolume, EquivolumeOutput, EvenBetterSinewave,
|
||||
CommonSenseRatio, CompositeProfile, CompositeProfileOutput, ConcealingBabySwallow,
|
||||
ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, Counterattack, Crab,
|
||||
CumulativeVolumeDelta, CumulativeVolumeIndex, CupAndHandle, CyberneticCycle, Cypher,
|
||||
DayOfWeekProfile, DayOfWeekProfileOutput, Decycler, DecyclerOscillator, Dema, DemandIndex,
|
||||
DemarkPivots, DemarkPivotsOutput, DepthSlope, DerivativeOscillator, DetrendedStdDev,
|
||||
DisparityIndex, DistanceSsd, Doji, DojiStar, Donchian, DonchianOutput, DonchianStop,
|
||||
DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, DoubleTopBottom,
|
||||
DownsideGapThreeMethods, Dpo, DragonflyDoji, DrawdownDuration, DumplingTop, Dx,
|
||||
DynamicMomentumIndex, EaseOfMovement, EffectiveSpread, EhlersStochastic, Ehma, ElderImpulse,
|
||||
ElderRay, ElderRayOutput, ElderSafeZone, ElderSafeZoneOutput, Ema, EmpiricalModeDecomposition,
|
||||
Engulfing, Equivolume, EquivolumeOutput, EstimatedLeverageRatio, EvenBetterSinewave,
|
||||
EveningDojiStar, Evwma, EwmaVolatility, Expectancy, FallingThreeMethods, Fama, FibArcs,
|
||||
FibArcsOutput, FibChannel, FibChannelOutput, FibConfluence, FibConfluenceOutput, FibExtension,
|
||||
FibExtensionOutput, FibFan, FibFanOutput, FibProjection, FibProjectionOutput, FibRetracement,
|
||||
FibRetracementOutput, FibTimeZones, FibTimeZonesOutput, FibonacciPivots, FibonacciPivotsOutput,
|
||||
FisherRsi, FisherTransform, FlagPennant, Footprint, FootprintOutput, ForceIndex,
|
||||
FractalChaosBands, FractalChaosBandsOutput, Frama, FryPanBottom, FundingBasis, FundingRate,
|
||||
FundingRateMean, FundingRateZScore, GainLossRatio, GapSideBySideWhite, Garch11,
|
||||
GarmanKlassVolatility, Gartley, GatorOscillator, GatorOscillatorOutput, GeneralizedDema,
|
||||
GeometricMa, GoldenPocket, GoldenPocketOutput, GrangerCausality, GravestoneDoji, Hammer,
|
||||
HangingMan, Harami, HaramiCross, HasbrouckInformationShare, HeadAndShoulders, HeikinAshi,
|
||||
HeikinAshiOscillator, HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighWave,
|
||||
HighpassFilter, Hikkake, HikkakeModified, HilbertDominantCycle, HistoricalVolatility, Hma,
|
||||
HoltWinters, HomingPigeon, HtDcPhase, HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel,
|
||||
HurstChannelOutput, HurstExponent, Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck,
|
||||
Inertia, InformationRatio, InitialBalance, InitialBalanceOutput, InstantaneousTrendline,
|
||||
IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile,
|
||||
IntradayVolatilityProfileOutput, InverseFisherTransform, InvertedHammer, JarqueBera, Jma,
|
||||
JumpIndicator, KagiBars, KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop,
|
||||
KaseDevStopOutput, KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion,
|
||||
Keltner, KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo,
|
||||
FractalChaosBands, FractalChaosBandsOutput, Frama, FryPanBottom, FundingBasis,
|
||||
FundingImpliedApr, FundingRate, FundingRateMean, FundingRateZScore, GainLossRatio,
|
||||
GainToPainRatio, GapSideBySideWhite, Garch11, GarmanKlassVolatility, Gartley, GatorOscillator,
|
||||
GatorOscillatorOutput, GeneralizedDema, GeometricMa, GoldenPocket, GoldenPocketOutput,
|
||||
GrangerCausality, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross,
|
||||
HasbrouckInformationShare, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator,
|
||||
HeikinAshiOutput, HiLoActivator, HighLowIndex, HighLowRange, HighLowVolumeNodes,
|
||||
HighLowVolumeNodesOutput, HighWave, HighpassFilter, Hikkake, HikkakeModified,
|
||||
HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HomingPigeon, HtDcPhase,
|
||||
HtPhasor, HtPhasorOutput, HtTrendMode, HurstChannel, HurstChannelOutput, HurstExponent,
|
||||
Ichimoku, IchimokuOutput, IdenticalThreeCrows, InNeck, Inertia, InformationRatio,
|
||||
InitialBalance, InitialBalanceOutput, InstantaneousTrendline, IntradayIntensity,
|
||||
IntradayMomentumIndex, IntradayVolatilityProfile, IntradayVolatilityProfileOutput,
|
||||
InverseFisherTransform, InvertedHammer, JarqueBera, Jma, JumpIndicator, KRatio, KagiBars,
|
||||
KalmanHedgeRatio, KalmanHedgeRatioOutput, Kama, KaseDevStop, KaseDevStopOutput,
|
||||
KasePermissionStochastic, KasePermissionStochasticOutput, KellyCriterion, Keltner,
|
||||
KeltnerOutput, KendallTau, Kicking, KickingByLength, Kst, KstOutput, Kurtosis, Kvo,
|
||||
KylesLambda, LadderBottom, LaguerreRsi, LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput,
|
||||
LinRegAngle, LinRegChannel, LinRegChannelOutput, LinRegIntercept, LinRegSlope,
|
||||
LinearRegression, LiquidationFeatures, LiquidationFeaturesOutput, LogReturn, LongLeggedDoji,
|
||||
LongLine, LongShortRatio, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix, MacdHistogram,
|
||||
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
|
||||
MatHold, MatchingLow, MaxDrawdown, McClellanOscillator, McClellanSummationIndex,
|
||||
McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianChannelOutput, MedianMa,
|
||||
MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi, MinusDm, ModifiedMaStop,
|
||||
ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar, MurreyMathLines,
|
||||
MurreyMathLinesOutput, Natr, NewHighsNewLows, NewPriceLines, Nrtr, NrtrOutput, Nvi,
|
||||
OIPriceDivergence, OIWeighted, Obv, OmegaRatio, OnNeck, OpenInterestDelta, OpeningMarubozu,
|
||||
OpeningRange, OpeningRangeOutput, OrderBookImbalanceFull, OrderBookImbalanceTop1,
|
||||
OrderBookImbalanceTopN, OrderFlowImbalance, OuHalfLife, OvernightGap, OvernightIntradayReturn,
|
||||
OvernightIntradayReturnOutput, PainIndex, PairSpreadZScore, PairwiseBeta, ParkinsonVolatility,
|
||||
PearsonCorrelation, PercentAboveMa, PercentB, PercentageTrailingStop, Pgo, PiercingDarkCloud,
|
||||
Pin, PivotReversal, PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo,
|
||||
PpoHistogram, ProfitFactor, ProjectionBands, ProjectionBandsOutput, ProjectionOscillator, Psar,
|
||||
Pvi, Qqe, QqeOutput, Qstick, QuartileBands, QuartileBandsOutput, QuotedSpread, RSquared,
|
||||
RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange, Reflex, RegimeLabel,
|
||||
RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop, RickshawMan,
|
||||
RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility, RollMeasure,
|
||||
RollingCorrelation, RollingCovariance, RollingIqr, RollingMinMaxScaler, RollingPercentileRank,
|
||||
RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi, RviVolatility, Rwi, RwiOutput,
|
||||
SampleEntropy, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionHighLowOutput,
|
||||
SessionRange, SessionRangeOutput, SessionVwap, ShannonEntropy, Shark, SharpeRatio,
|
||||
ShootingStar, ShortLine, SignedVolume, SineWave, SineWeightedMa, Skewness, Sma, Smi, Smma,
|
||||
SmoothedHeikinAshi, SmoothedHeikinAshiOutput, SortinoRatio, SpearmanCorrelation, SpinningTop,
|
||||
SpreadAr1Coefficient, SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst,
|
||||
StalledPattern, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
|
||||
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StickSandwich, StochRsi, Stochastic,
|
||||
StochasticCci, StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput,
|
||||
TakerBuySellRatio, Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown,
|
||||
TdDWave, TdDeMarker, TdDifferential, TdLines, TdLinesOutput, TdMovingAverage,
|
||||
TdMovingAverageOutput, TdOpen, TdPressure, TdPropulsion, TdRangeProjection,
|
||||
TdRangeProjectionOutput, TdRei, TdRiskLevel, TdRiskLevelOutput, TdSequential,
|
||||
TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis, ThreeDrives, ThreeInside,
|
||||
ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth,
|
||||
Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile, TimeOfDayReturnProfileOutput,
|
||||
TowerTopBottom, TpoProfile, TpoProfileOutput, TradeImbalance, TradeSignAutocorrelation,
|
||||
TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex, TreynorRatio, Triangle, Trima,
|
||||
Trin, TripleTopBottom, Tristar, Trix, TrueRange, Tsf, TsfOscillator, Tsi, Tsv, TtmSqueeze,
|
||||
TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice,
|
||||
UlcerIndex, UltimateOscillator, UniqueThreeRiver, UniversalOscillator, UpDownVolumeRatio,
|
||||
UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, ValueAreaOutput, ValueAtRisk, Variance,
|
||||
VarianceRatio, VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput,
|
||||
VolatilityOfVolatility, VolatilityRatio, VoltyStop, VolumeByTimeProfile,
|
||||
VolumeByTimeProfileOutput, VolumeOscillator, VolumePriceTrend, VolumeProfile,
|
||||
VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd, VolumeWeightedMacdOutput, VolumeWeightedSr,
|
||||
VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend, WaveTrendOutput, Wedge,
|
||||
WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput, WilliamsR, WinRate, Wma,
|
||||
WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit, ZScore, ZeroLagMacd,
|
||||
ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
LongLine, LongShortRatio, M2Measure, MaEnvelope, MaEnvelopeOutput, MacdExt, MacdFix,
|
||||
MacdHistogram, MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex,
|
||||
MartinRatio, Marubozu, MassIndex, MatHold, MatchingLow, MaxDrawdown, McClellanOscillator,
|
||||
McClellanSummationIndex, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel,
|
||||
MedianChannelOutput, MedianMa, MedianPrice, Mfi, Microprice, MidPoint, MidPrice, MinusDi,
|
||||
MinusDm, ModifiedMaStop, ModifiedMaStopOutput, Mom, MorningDojiStar, MorningEveningStar,
|
||||
MurreyMathLines, MurreyMathLinesOutput, NakedPoc, Natr, NewHighsNewLows, NewPriceLines, Nrtr,
|
||||
NrtrOutput, Nvi, OIPriceDivergence, OIWeighted, Obv, OiToVolumeRatio, OmegaRatio, OnNeck,
|
||||
OpenInterestDelta, OpenInterestMomentum, OpeningMarubozu, OpeningRange, OpeningRangeOutput,
|
||||
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, OrderFlowImbalance,
|
||||
OuHalfLife, OvernightGap, OvernightIntradayReturn, OvernightIntradayReturnOutput, PainIndex,
|
||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentAboveMa,
|
||||
PercentB, PercentageTrailingStop, PerpetualPremiumIndex, Pgo, PiercingDarkCloud, Pin,
|
||||
PivotReversal, PlusDi, PlusDm, Pmo, PointAndFigureBars, PolarizedFractalEfficiency, Ppo,
|
||||
PpoHistogram, ProfileShape, ProfitFactor, ProjectionBands, ProjectionBandsOutput,
|
||||
ProjectionOscillator, Psar, Pvi, Qqe, QqeOutput, Qstick, QuartileBands, QuartileBandsOutput,
|
||||
QuotedSpread, RSquared, RealizedSpread, RealizedVolatility, RecoveryFactor, RectangleRange,
|
||||
Reflex, RegimeLabel, RelativeStrengthAB, RelativeStrengthOutput, RenkoBars, RenkoTrailingStop,
|
||||
RickshawMan, RisingThreeMethods, Rmi, Roc, Rocp, Rocr, Rocr100, RogersSatchellVolatility,
|
||||
RollMeasure, RollingCorrelation, RollingCovariance, RollingIqr, RollingMinMaxScaler,
|
||||
RollingPercentileRank, RollingQuantile, RollingVwap, RoofingFilter, Rsi, Rsx, Rvi,
|
||||
RviVolatility, Rwi, RwiOutput, SampleEntropy, SarExt, SeasonalZScore, SeparatingLines,
|
||||
SessionHighLow, SessionHighLowOutput, SessionRange, SessionRangeOutput, SessionVwap,
|
||||
ShannonEntropy, Shark, SharpeRatio, ShootingStar, ShortLine, SignedVolume, SineWave,
|
||||
SineWeightedMa, SinglePrints, Skewness, Sma, Smi, Smma, SmoothedHeikinAshi,
|
||||
SmoothedHeikinAshiOutput, SortinoRatio, SpearmanCorrelation, SpinningTop, SpreadAr1Coefficient,
|
||||
SpreadBollingerBands, SpreadBollingerBandsOutput, SpreadHurst, StalledPattern, StandardError,
|
||||
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
|
||||
StepTrailingStop, SterlingRatio, StickSandwich, StochRsi, Stochastic, StochasticCci,
|
||||
StochasticOutput, SuperSmoother, SuperTrend, SuperTrendOutput, TailRatio, TakerBuySellRatio,
|
||||
Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker,
|
||||
TdDifferential, TdLines, TdLinesOutput, TdMovingAverage, TdMovingAverageOutput, TdOpen,
|
||||
TdPressure, TdPropulsion, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
|
||||
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, TdTrap, Tema, TermStructureBasis,
|
||||
ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows,
|
||||
ThreeStarsInSouth, Thrusting, TickIndex, Tii, TimeBasedStop, TimeOfDayReturnProfile,
|
||||
TimeOfDayReturnProfileOutput, TowerTopBottom, TpoProfile, TpoProfileOutput, TradeImbalance,
|
||||
TradeSignAutocorrelation, TradeVolumeIndex, TrendLabel, TrendStrengthIndex, Trendflex,
|
||||
TreynorRatio, Triangle, Trima, Trin, TripleTopBottom, Tristar, Trix, TrueRange, Tsf,
|
||||
TsfOscillator, Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, TtmTrend, TurnOfMonth, Tweezer,
|
||||
TwiggsMoneyFlow, TwoCrows, TypicalPrice, UlcerIndex, UltimateOscillator, UniqueThreeRiver,
|
||||
UniversalOscillator, UpDownVolumeRatio, UpsideGapThreeMethods, UpsideGapTwoCrows,
|
||||
UpsidePotentialRatio, ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VarianceRatio,
|
||||
VerticalHorizontalFilter, Vidya, VolatilityCone, VolatilityConeOutput, VolatilityOfVolatility,
|
||||
VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeByTimeProfileOutput, VolumeOscillator,
|
||||
VolumePriceTrend, VolumeProfile, VolumeProfileOutput, VolumeRsi, VolumeWeightedMacd,
|
||||
VolumeWeightedMacdOutput, VolumeWeightedSr, VolumeWeightedSrOutput, Vortex, VortexOutput, Vpin,
|
||||
Vwap, VwapStdDevBands, VwapStdDevBandsOutput, Vwma, Vzo, Wad, WavePm, WaveTrend,
|
||||
WaveTrendOutput, Wedge, WeightedClose, WickRatio, WilliamsFractals, WilliamsFractalsOutput,
|
||||
WilliamsR, WinRate, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility, YoyoExit,
|
||||
ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ That includes:
|
||||
[Python](https://docs.wickra.org/Quickstart-Python),
|
||||
[Node](https://docs.wickra.org/Quickstart-Node), and
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- A per-indicator deep dive for every one of the **488 indicators** across
|
||||
- A per-indicator deep dive for every one of the **507 indicators** across
|
||||
the sixteen families (Moving Averages, Momentum Oscillators, Trend &
|
||||
Directional, Price Oscillators, Volatility & Bands, Bands & Channels,
|
||||
Trailing Stops, Volume, Price Statistics, Ehlers / Cycle DSP, Pivots &
|
||||
|
||||
Generated
+7
-7
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.3",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -26,12 +26,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.7.0",
|
||||
"wickra-darwin-x64": "0.7.0",
|
||||
"wickra-linux-arm64-gnu": "0.7.0",
|
||||
"wickra-linux-x64-gnu": "0.7.0",
|
||||
"wickra-win32-arm64-msvc": "0.7.0",
|
||||
"wickra-win32-x64-msvc": "0.7.0"
|
||||
"wickra-darwin-arm64": "0.7.3",
|
||||
"wickra-darwin-x64": "0.7.3",
|
||||
"wickra-linux-arm64-gnu": "0.7.3",
|
||||
"wickra-linux-x64-gnu": "0.7.3",
|
||||
"wickra-win32-arm64-msvc": "0.7.3",
|
||||
"wickra-win32-x64-msvc": "0.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! `Ema(20)`. This target now covers every scalar indicator in the catalogue.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, AdaptiveRsi, Alma, AnchoredRsi, Apo, Autocorrelation, AutocorrelationPeriodogram, AverageDrawdown, BandpassFilter, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EvenBetterSinewave, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, Garch11, GeneralizedDema, GeometricMa, HighpassFilter, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, Reflex, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, StochRsi, SuperSmoother, Tema, Tii, TrendLabel, TrendStrengthIndex, Trendflex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, UniversalOscillator, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
use wickra_core::{AdaptiveCycle, AdaptiveLaguerreFilter, AdaptiveRsi, Alma, AnchoredRsi, Apo, Autocorrelation, AutocorrelationPeriodogram, AverageDrawdown, BandpassFilter, BatchExt, Beta, BipowerVariation, BollingerBands, BomarBands, BurkeRatio, CalmarRatio, CenterOfGravity, Cfo, Cmo, CoefficientOfVariation, CommonSenseRatio, ConditionalValueAtRisk, ConnorsRsi, Coppock, CorrelationTrendIndicator, CyberneticCycle, Decycler, DecyclerOscillator, Dema, DerivativeOscillator, DetrendedStdDev, DisparityIndex, DoubleBollinger, Dpo, DrawdownDuration, DynamicMomentumIndex, EhlersStochastic, Ehma, ElderImpulse, Ema, EmpiricalModeDecomposition, EvenBetterSinewave, EwmaVolatility, Expectancy, Fama, FisherRsi, FisherTransform, Frama, GainLossRatio, GainToPainRatio, Garch11, GeneralizedDema, GeometricMa, HighpassFilter, HilbertDominantCycle, HistoricalVolatility, Hma, HoltWinters, HtDcPhase, HtPhasor, HtTrendMode, HurstExponent, Indicator, InstantaneousTrendline, InverseFisherTransform, JarqueBera, Jma, JumpIndicator, KRatio, Kama, KellyCriterion, Kst, Kurtosis, LaguerreRsi, LinRegAngle, LinRegChannel, LinRegIntercept, LinRegSlope, LinearRegression, LogReturn, M2Measure, MaEnvelope, MaType, MacdExt, MacdFix, MacdHistogram, MacdIndicator, Mama, MartinRatio, MaxDrawdown, McGinleyDynamic, MedianAbsoluteDeviation, MedianChannel, MedianMa, MidPoint, Mom, OmegaRatio, PainIndex, PearsonCorrelation, PercentageTrailingStop, Pmo, PolarizedFractalEfficiency, Ppo, PpoHistogram, ProfitFactor, Qqe, QuartileBands, RSquared, RealizedVolatility, RecoveryFactor, Reflex, RegimeLabel, RenkoTrailingStop, Rmi, Roc, Rocp, Rocr, Rocr100, RollingIqr, RollingMinMaxScaler, RollingPercentileRank, RollingQuantile, RoofingFilter, Rsi, Rsx, RviVolatility, SampleEntropy, ShannonEntropy, SharpeRatio, SineWave, SineWeightedMa, Skewness, Sma, Smma, SortinoRatio, SpearmanCorrelation, StandardError, StandardErrorBands, Stc, StdDev, StepTrailingStop, SterlingRatio, StochRsi, SuperSmoother, TailRatio, Tema, Tii, TrendLabel, TrendStrengthIndex, Trendflex, Trima, Trix, Tsf, TsfOscillator, Tsi, UlcerIndex, UniversalOscillator, UpsidePotentialRatio, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VolatilityOfVolatility, WavePm, WinRate, Wma, ZScore, ZeroLagMacd, Zlema, T3};
|
||||
|
||||
/// Drive a single streaming + batch run through one scalar indicator. Marked
|
||||
/// `#[inline(never)]` so a panic backtrace pin-points the specific indicator.
|
||||
@@ -211,6 +211,15 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
drive(|| KellyCriterion::new(20).unwrap(), &data);
|
||||
drive(|| WinRate::new(20).unwrap(), &data);
|
||||
drive(|| Expectancy::new(20).unwrap(), &data);
|
||||
drive(|| SterlingRatio::new(12).unwrap(), &data);
|
||||
drive(|| BurkeRatio::new(12).unwrap(), &data);
|
||||
drive(|| MartinRatio::new(14).unwrap(), &data);
|
||||
drive(|| TailRatio::new(20).unwrap(), &data);
|
||||
drive(|| KRatio::new(30).unwrap(), &data);
|
||||
drive(|| CommonSenseRatio::new(20).unwrap(), &data);
|
||||
drive(|| GainToPainRatio::new(12).unwrap(), &data);
|
||||
drive(|| UpsidePotentialRatio::new(20, 0.0).unwrap(), &data);
|
||||
drive(|| M2Measure::new(20, 0.0, 0.02).unwrap(), &data);
|
||||
|
||||
// RecoveryFactor and DrawdownDuration produce non-`f64` outputs / have
|
||||
// no `period` knob, so they cannot use the `drive` helper directly.
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
//! WeightedClose.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, AndrewsPitchfork, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, CandleVolume, Cci, CentralPivotRange, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, DumplingTop, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, Equivolume, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, FryPanBottom, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator, HiLoActivator, HighLowRange, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, MurreyMathLines, Natr, NewPriceLines, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PivotReversal, PlusDi, PlusDm, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, Smi, SmoothedHeikinAshi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdMovingAverage, TdOpen, TdPressure, TdPropulsion, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, TdTrap, ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TowerTopBottom, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, Tristar, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, VolumeWeightedSr, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
use wickra_core::{AbandonedBaby, Abcd, AccelerationBands, AcceleratorOscillator, AdOscillator, AdaptiveCci, Adl, AdvanceBlock, Adx, Adxr, Alligator, AnchoredVwap, AndrewsPitchfork, Aroon, AroonOscillator, Atr, AtrBands, AtrRatchet, AtrTrailingStop, AutoFib, AverageDailyRange, AwesomeOscillator, AwesomeOscillatorHistogram, BalanceOfPower, Bat, BatchExt, BeltHold, BetterVolume, BodySizePct, Breakaway, Butterfly, Camarilla, Candle, CandleVolume, Cci, CentralPivotRange, ChaikinMoneyFlow, ChaikinOscillator, ChaikinVolatility, ChandeKrollStop, ChandelierExit, ChoppinessIndex, ClassicPivots, CloseVsOpen, ClosingMarubozu, CompositeProfile, ConcealingBabySwallow, Counterattack, Crab, CupAndHandle, Cypher, DayOfWeekProfile, DemandIndex, DemarkPivots, Doji, DojiStar, Donchian, DonchianStop, DoubleTopBottom, DownsideGapThreeMethods, DragonflyDoji, DumplingTop, Dx, EaseOfMovement, ElderRay, ElderSafeZone, Engulfing, Equivolume, EveningDojiStar, Evwma, FallingThreeMethods, FibArcs, FibChannel, FibConfluence, FibExtension, FibFan, FibProjection, FibRetracement, FibTimeZones, FibonacciPivots, FlagPennant, ForceIndex, FractalChaosBands, FryPanBottom, GapSideBySideWhite, GarmanKlassVolatility, Gartley, GatorOscillator, GoldenPocket, GravestoneDoji, Hammer, HangingMan, Harami, HaramiCross, HeadAndShoulders, HeikinAshi, HeikinAshiOscillator, HiLoActivator, HighLowRange, HighLowVolumeNodes, HighWave, Hikkake, HikkakeModified, HomingPigeon, HurstChannel, Ichimoku, IdenticalThreeCrows, InNeck, Indicator, Inertia, InitialBalance, IntradayIntensity, IntradayMomentumIndex, IntradayVolatilityProfile, InvertedHammer, KaseDevStop, KasePermissionStochastic, Keltner, Kicking, KickingByLength, Kvo, LadderBottom, LongLeggedDoji, LongLine, MarketFacilitationIndex, Marubozu, MassIndex, MatHold, MatchingLow, AvgPrice, MedianPrice, Mfi, MidPrice, MinusDi, MinusDm, ModifiedMaStop, MorningDojiStar, MorningEveningStar, MurreyMathLines, NakedPoc, Natr, NewPriceLines, Nrtr, Nvi, Obv, OnNeck, OpeningMarubozu, OpeningRange, OvernightGap, OvernightIntradayReturn, ParkinsonVolatility, Pgo, PiercingDarkCloud, PivotReversal, PlusDi, PlusDm, ProfileShape, ProjectionBands, ProjectionOscillator, Psar, Pvi, Qstick, RectangleRange, RickshawMan, RisingThreeMethods, RogersSatchellVolatility, RollingVwap, Rvi, Rwi, SarExt, SeasonalZScore, SeparatingLines, SessionHighLow, SessionRange, SessionVwap, Shark, ShootingStar, ShortLine, SinglePrints, Smi, SmoothedHeikinAshi, SpinningTop, StalledPattern, StarcBands, StickSandwich, Stochastic, StochasticCci, SuperTrend, Takuri, TasukiGap, TdCamouflage, TdClop, TdClopwin, TdCombo, TdCountdown, TdDWave, TdDeMarker, TdDifferential, TdLines, TdMovingAverage, TdOpen, TdPressure, TdPropulsion, TdRangeProjection, TdRei, TdRiskLevel, TdSequential, TdSetup, TdTrap, ThreeDrives, ThreeInside, ThreeLineBreak, ThreeLineStrike, ThreeOutside, ThreeSoldiersOrCrows, ThreeStarsInSouth, Thrusting, TimeBasedStop, TimeOfDayReturnProfile, TowerTopBottom, TpoProfile, TradeVolumeIndex, Triangle, TripleTopBottom, Tristar, TrueRange, Tsv, TtmSqueeze, TtmTrend, TurnOfMonth, Tweezer, TwiggsMoneyFlow, TwoCrows, TypicalPrice, UltimateOscillator, UniqueThreeRiver, UpsideGapThreeMethods, UpsideGapTwoCrows, ValueArea, VolatilityCone, VolatilityRatio, VoltyStop, VolumeByTimeProfile, VolumeOscillator, VolumePriceTrend, VolumeProfile, VolumeRsi, VolumeWeightedMacd, VolumeWeightedSr, Vortex, Vwap, VwapStdDevBands, Vwma, Vzo, WaveTrend, Wedge, WeightedClose, WickRatio, Wad, WilliamsFractals, WilliamsR, WoodiePivots, YangZhangVolatility, YoyoExit, ZigZag};
|
||||
|
||||
/// Convert a flat `f64` stream into a `Vec<Candle>` by chunking it into
|
||||
/// `[open, high, low, close, volume]` groups. Tuples that fail OHLCV
|
||||
@@ -188,6 +188,11 @@ fuzz_target!(|data: Vec<f64>| {
|
||||
}
|
||||
|
||||
// --- Market Profile (multi-output) ---
|
||||
drive(|| CompositeProfile::new(20, 24, 0.7).unwrap(), &candles);
|
||||
drive(|| HighLowVolumeNodes::new(20, 24).unwrap(), &candles);
|
||||
drive(|| NakedPoc::new(20, 24).unwrap(), &candles);
|
||||
drive(|| SinglePrints::new(20, 24).unwrap(), &candles);
|
||||
drive(|| ProfileShape::new(20, 24).unwrap(), &candles);
|
||||
drive(|| ValueArea::new(20, 50, 0.70).unwrap(), &candles);
|
||||
drive(|| VolumeProfile::new(20, 50).unwrap(), &candles);
|
||||
drive(|| TpoProfile::new(20, 50).unwrap(), &candles);
|
||||
|
||||
@@ -10,11 +10,7 @@
|
||||
//! never panic, streaming or batched.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{
|
||||
BatchExt, CalendarSpread, DerivativesTick, FundingBasis, FundingRate, FundingRateMean,
|
||||
FundingRateZScore, Indicator, LiquidationFeatures, LongShortRatio, OIPriceDivergence,
|
||||
OIWeighted, OpenInterestDelta, TakerBuySellRatio, TermStructureBasis,
|
||||
};
|
||||
use wickra_core::{BatchExt, CalendarSpread, DerivativesTick, EstimatedLeverageRatio, FundingBasis, FundingImpliedApr, FundingRate, FundingRateMean, FundingRateZScore, Indicator, LiquidationFeatures, LongShortRatio, OIPriceDivergence, OIWeighted, OiToVolumeRatio, OpenInterestDelta, OpenInterestMomentum, PerpetualPremiumIndex, TakerBuySellRatio, TermStructureBasis};
|
||||
|
||||
#[inline(never)]
|
||||
fn drive<I>(make: impl Fn() -> I, ticks: &[DerivativesTick])
|
||||
@@ -53,6 +49,11 @@ fuzz_target!(|data: &[u8]| {
|
||||
drive(TakerBuySellRatio::new, &ticks);
|
||||
drive(TermStructureBasis::new, &ticks);
|
||||
drive(CalendarSpread::new, &ticks);
|
||||
drive(EstimatedLeverageRatio::new, &ticks);
|
||||
drive(OiToVolumeRatio::new, &ticks);
|
||||
drive(PerpetualPremiumIndex::new, &ticks);
|
||||
drive(|| FundingImpliedApr::new(1095.0).unwrap(), &ticks);
|
||||
drive(|| OpenInterestMomentum::new(14).unwrap(), &ticks);
|
||||
|
||||
// LiquidationFeatures emits a struct, not an f64, so drive it directly.
|
||||
let mut liq = LiquidationFeatures::new();
|
||||
|
||||
Reference in New Issue
Block a user