feat(derivatives): leverage, OI/volume, perpetual premium, funding APR, OI momentum (B16) (#214)
## B16 Derivatives — five new indicators (488 → 493)
All consume a `DerivativesTick` and emit `f64`:
| Indicator | Reads | Formula |
|-----------|-------|---------|
| `EstimatedLeverageRatio` | open_interest, long_size, short_size | `OI / (long + short)` |
| `OiToVolumeRatio` | open_interest, taker_buy_volume, taker_sell_volume | `OI / (buy + sell)` |
| `PerpetualPremiumIndex` | mark_price, index_price | `(mark − index) / index` |
| `FundingImpliedApr` | funding_rate | `rate × intervals_per_year` |
| `OpenInterestMomentum` | open_interest | `100 · (OI_t − OI_{t−period}) / OI_{t−period}` |
### Wiring
- Core structs + full unit tests (incl. zero-denominator branches).
- Hand-written Python/Node/WASM tick bindings; two new tick helpers (`deriv_oi_long_short`, `deriv_oi_taker`).
- Fuzz drives in `indicator_update_derivatives.rs`; dedicated reference + streaming-vs-batch tests (Python + Node).
- README counter + `docs/README.md` + `FAMILIES` assert bumped to 493.
### Verify (local, all green)
- `cargo test -p wickra-core --lib`: 4028 · `--doc`: 443
- clippy workspace: clean
- node: 563 · pytest: 928
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,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,6 +157,7 @@ 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;
|
||||
@@ -278,9 +280,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 +299,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;
|
||||
@@ -619,6 +624,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,6 +650,7 @@ 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;
|
||||
@@ -766,9 +773,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 +792,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;
|
||||
@@ -1478,6 +1488,11 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"LiquidationFeatures",
|
||||
"TermStructureBasis",
|
||||
"CalendarSpread",
|
||||
"EstimatedLeverageRatio",
|
||||
"OiToVolumeRatio",
|
||||
"PerpetualPremiumIndex",
|
||||
"FundingImpliedApr",
|
||||
"OpenInterestMomentum",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -1624,6 +1639,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, 493, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user