Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fae60e0d54 | |||
| 433b06367f | |||
| 3dd7010129 | |||
| 4f11df0e33 |
@@ -40,7 +40,7 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
id-token: write # OIDC token to publish results to the OpenSSF API
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
+25
-1
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.3] - 2026-06-01
|
||||
|
||||
### Added
|
||||
- **Microstructure family — price impact & depth (part 3).** Indicators over a
|
||||
trade paired with the prevailing mid (`TradeQuote`) and over the order-book
|
||||
depth profile, exposed in Rust, Python, Node and WASM:
|
||||
- **Effective Spread** — `2 · D · (tradePrice − mid) / mid · 10_000` bps, the
|
||||
realised round-trip cost of a single trade against the mid.
|
||||
- **Realized Spread** — `2 · D · (tradePrice − mid_{t+horizon}) / mid_t ·
|
||||
10_000` bps, the share of the effective spread a liquidity provider keeps
|
||||
once the mid has moved over a configurable horizon.
|
||||
- **Kyle's Lambda** — the rolling OLS slope of mid changes on signed volume
|
||||
(`cov(Δmid, q) / var(q)`), the canonical price-impact / market-depth proxy.
|
||||
- **Depth Slope** — the mean per-side OLS slope of cumulative resting size
|
||||
against distance from the mid, measuring how fast the book thickens away
|
||||
from the touch.
|
||||
- **Microstructure family — footprint (part 4).** **Footprint** decomposes the
|
||||
volume traded in a bar across price buckets (`round(price / tick_size)`),
|
||||
splitting each bucket into buy-initiated (ask) and sell-initiated (bid)
|
||||
volume. A multi-output, variable-length indicator: every `update` returns the
|
||||
full footprint accumulated since the last `reset`, exposed in Rust, Python
|
||||
(`(k, 3)` arrays), Node (`{ price, bidVol, askVol }` rows) and WASM.
|
||||
|
||||
## [0.4.2] - 2026-06-01
|
||||
|
||||
### Added
|
||||
@@ -957,7 +980,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
optional Binance live feed.
|
||||
- Bindings for Python, Node.js, and WebAssembly.
|
||||
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.2...HEAD
|
||||
[Unreleased]: https://github.com/wickra-lib/wickra/compare/v0.4.3...HEAD
|
||||
[0.4.3]: https://github.com/wickra-lib/wickra/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/wickra-lib/wickra/compare/v0.4.1...v0.4.2
|
||||
[0.4.1]: https://github.com/wickra-lib/wickra/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/wickra-lib/wickra/compare/v0.3.1...v0.4.0
|
||||
|
||||
Generated
+6
-6
@@ -1867,7 +1867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
@@ -1878,7 +1878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-core"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"proptest",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-data"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"csv",
|
||||
@@ -1915,7 +1915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-node"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
@@ -1925,7 +1925,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-python"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"numpy",
|
||||
"pyo3",
|
||||
@@ -1934,7 +1934,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wickra-wasm"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ members = [
|
||||
exclude = ["fuzz"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
authors = ["kingchenc <support@wickra.org>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.86"
|
||||
@@ -24,7 +24,7 @@ keywords = ["finance", "trading", "indicators", "technical-analysis", "ta"]
|
||||
categories = ["finance", "mathematics", "science"]
|
||||
|
||||
[workspace.dependencies]
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.4.2" }
|
||||
wickra-core = { path = "crates/wickra-core", version = "0.4.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=227" 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=232" alt="Wickra — streaming-first technical indicators" width="100%"></a>
|
||||
</p>
|
||||
|
||||
[](https://github.com/wickra-lib/wickra/actions/workflows/ci.yml)
|
||||
@@ -47,7 +47,7 @@ Full documentation lives at **[docs.wickra.org](https://docs.wickra.org)**:
|
||||
[Node](https://docs.wickra.org/Quickstart-Node),
|
||||
[WASM](https://docs.wickra.org/Quickstart-WASM).
|
||||
- **Indicators** — a per-indicator deep dive (formula, parameters, warmup) for
|
||||
every one of the 227 indicators; start at the
|
||||
every one of the 232 indicators; start at the
|
||||
[indicators overview](https://docs.wickra.org/Indicators-Overview).
|
||||
- **Reference** — [warmup periods](https://docs.wickra.org/Warmup-Periods),
|
||||
[streaming vs batch](https://docs.wickra.org/Streaming-vs-Batch),
|
||||
@@ -135,7 +135,7 @@ python -m benchmarks.compare_libraries
|
||||
|
||||
## Indicators
|
||||
|
||||
227 streaming-first indicators across seventeen families. Every one passes the
|
||||
232 streaming-first indicators across seventeen 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).
|
||||
@@ -156,7 +156,7 @@ warmup) at [docs.wickra.org](https://docs.wickra.org/Indicators-Overview).
|
||||
| DeMark | TD Setup, TD Sequential, TD DeMarker, TD REI, TD Pressure, TD Combo, TD Countdown, TD Lines, TD Range Projection, TD Differential, TD Open, TD Risk Level |
|
||||
| Ichimoku & Charts | Ichimoku Kinko Hyo (Tenkan, Kijun, Senkou A/B, Chikou), Heikin-Ashi |
|
||||
| Candlestick Patterns | Doji, Hammer, Inverted Hammer, Hanging Man, Shooting Star, Engulfing, Harami, Morning/Evening Star, Three White Soldiers/Black Crows, Piercing Line/Dark Cloud Cover, Marubozu, Tweezer, Spinning Top, Three Inside Up/Down, Three Outside Up/Down |
|
||||
| Microstructure | Order-Book Imbalance (Top-1 / Top-N / Full), Microprice, Quoted Spread, Signed Volume, Cumulative Volume Delta, Trade Imbalance |
|
||||
| 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 |
|
||||
| Market Profile | Value Area (POC / VAH / VAL), Initial Balance, Opening Range |
|
||||
| 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) |
|
||||
|
||||
@@ -237,7 +237,7 @@ A Python live-trading example using the public `websockets` package lives at
|
||||
```
|
||||
wickra/
|
||||
├── crates/
|
||||
│ ├── wickra-core/ core engine + all 227 indicators
|
||||
│ ├── wickra-core/ core engine + all 232 indicators
|
||||
│ ├── wickra/ top-level facade crate (publishes on crates.io) + benches/
|
||||
│ └── wickra-data/ CSV reader, tick aggregator, live exchange feeds
|
||||
├── bindings/
|
||||
|
||||
@@ -926,6 +926,10 @@ test('order-book indicators reference values', () => {
|
||||
assert.equal(new wickra.Microprice().update([100], [1], [101], [3]), 100.25);
|
||||
// Quoted spread: 1 / 100.5 * 10000 ≈ 99.5025 bps.
|
||||
assert.ok(Math.abs(new wickra.QuotedSpread().update([100], [1], [101], [1]) - 99.50248756) < 1e-6);
|
||||
// Depth slope: each side distances 1,2 -> cumulative 1,3 -> OLS slope 2.
|
||||
assert.ok(Math.abs(new wickra.DepthSlope().update([99, 98], [1, 2], [101, 102], [1, 2]) - 2.0) < 1e-9);
|
||||
// Single level per side -> no slope -> 0.
|
||||
assert.equal(new wickra.DepthSlope().update([100], [1], [101], [1]), 0.0);
|
||||
});
|
||||
|
||||
test('order-book streaming update matches batch', () => {
|
||||
@@ -981,3 +985,112 @@ test('trade-flow rejects bad input', () => {
|
||||
assert.throws(() => new wickra.TradeImbalance(0));
|
||||
assert.throws(() => new wickra.SignedVolume().update(100, -1, true));
|
||||
});
|
||||
|
||||
test('price-impact indicators reference values', () => {
|
||||
// Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
|
||||
assert.ok(Math.abs(new wickra.EffectiveSpread().update(100.05, 1, true, 100.0) - 10.0) < 1e-9);
|
||||
// Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
|
||||
assert.ok(Math.abs(new wickra.EffectiveSpread().update(99.95, 1, false, 100.0) - 10.0) < 1e-9);
|
||||
// A buy filled below the mid is price improvement -> negative.
|
||||
assert.ok(new wickra.EffectiveSpread().update(99.95, 1, true, 100.0) < 0.0);
|
||||
});
|
||||
|
||||
test('price-impact streaming update matches batch', () => {
|
||||
const n = 30;
|
||||
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
|
||||
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
|
||||
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
|
||||
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
|
||||
const batch = new wickra.EffectiveSpread().batch(price, size, isBuy, mid);
|
||||
const streamer = new wickra.EffectiveSpread();
|
||||
assert.equal(batch.length, n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
|
||||
assert.ok(Math.abs(s - batch[i]) < 1e-9, `mismatch at ${i}: ${s} vs ${batch[i]}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('realized spread resolves against the future mid', () => {
|
||||
const rs = new wickra.RealizedSpread(1);
|
||||
assert.equal(rs.update(100.10, 1, true, 100.0), null); // buffered
|
||||
// 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps.
|
||||
assert.ok(Math.abs(rs.update(99.90, 1, false, 100.20) - -20.0) < 1e-9);
|
||||
});
|
||||
|
||||
test('realized spread streaming update matches batch', () => {
|
||||
const n = 30;
|
||||
const mid = Array.from({ length: n }, (_, i) => 100 + 0.25 * Math.sin(i * 0.5));
|
||||
const isBuy = Array.from({ length: n }, (_, i) => i % 3 !== 0);
|
||||
const price = Array.from({ length: n }, (_, i) => mid[i] + (isBuy[i] ? 0.03 : -0.03));
|
||||
const size = Array.from({ length: n }, (_, i) => 1 + (i % 4));
|
||||
const batch = new wickra.RealizedSpread(4).batch(price, size, isBuy, mid);
|
||||
const streamer = new wickra.RealizedSpread(4);
|
||||
assert.equal(batch.length, n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = streamer.update(price[i], size[i], isBuy[i], mid[i]);
|
||||
const got = s === null ? NaN : s;
|
||||
assert.ok(
|
||||
(Number.isNaN(got) && Number.isNaN(batch[i])) || Math.abs(got - batch[i]) < 1e-9,
|
||||
`mismatch at ${i}: ${got} vs ${batch[i]}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("kyle's lambda recovers a constant price-impact slope", () => {
|
||||
// Each trade moves the mid by exactly 0.5 per unit of signed volume.
|
||||
const impact = 0.5;
|
||||
let mid = 100;
|
||||
const price = [];
|
||||
const size = [];
|
||||
const isBuy = [];
|
||||
const mids = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const buy = i % 2 === 0;
|
||||
const sz = 1 + (i % 3);
|
||||
const signed = buy ? sz : -sz;
|
||||
mid += impact * signed;
|
||||
price.push(mid);
|
||||
size.push(sz);
|
||||
isBuy.push(buy);
|
||||
mids.push(mid);
|
||||
}
|
||||
const out = new wickra.KylesLambda(6).batch(price, size, isBuy, mids);
|
||||
assert.ok(Math.abs(out[out.length - 1] - 0.5) < 1e-9);
|
||||
});
|
||||
|
||||
test('price-impact rejects bad input', () => {
|
||||
assert.throws(() => new wickra.EffectiveSpread().update(100, 1, true, 0));
|
||||
assert.throws(() => new wickra.RealizedSpread(0));
|
||||
assert.throws(() => new wickra.KylesLambda(1));
|
||||
});
|
||||
|
||||
test('footprint buckets buy and sell volume per price level', () => {
|
||||
const fp = new wickra.Footprint(1.0);
|
||||
fp.update(100.2, 2, true); // bucket 100 -> ask 2
|
||||
fp.update(100.7, 3, false); // bucket 101 -> bid 3
|
||||
const out = fp.update(100.1, 1, true); // bucket 100 -> ask 3
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(
|
||||
{ price: out[0].price, bidVol: out[0].bidVol, askVol: out[0].askVol },
|
||||
{ price: 100.0, bidVol: 0.0, askVol: 3.0 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ price: out[1].price, bidVol: out[1].bidVol, askVol: out[1].askVol },
|
||||
{ price: 101.0, bidVol: 3.0, askVol: 0.0 },
|
||||
);
|
||||
});
|
||||
|
||||
test('footprint streaming update matches batch and rejects bad tick', () => {
|
||||
const n = 12;
|
||||
const price = Array.from({ length: n }, (_, i) => 100 + (i % 5) * 0.3);
|
||||
const size = Array.from({ length: n }, (_, i) => 1 + (i % 3));
|
||||
const isBuy = Array.from({ length: n }, (_, i) => i % 2 === 0);
|
||||
const batch = new wickra.Footprint(1.0).batch(price, size, isBuy);
|
||||
const streamer = new wickra.Footprint(1.0);
|
||||
assert.equal(batch.length, n);
|
||||
for (let i = 0; i < n; i++) {
|
||||
const s = streamer.update(price[i], size[i], isBuy[i]);
|
||||
assert.deepEqual(s, batch[i], `mismatch at ${i}`);
|
||||
}
|
||||
assert.throws(() => new wickra.Footprint(0));
|
||||
});
|
||||
|
||||
Vendored
+51
@@ -286,6 +286,12 @@ export interface ObSnapshot {
|
||||
askPx: Array<number>
|
||||
askSz: Array<number>
|
||||
}
|
||||
/** One price bucket of a footprint. */
|
||||
export interface FootprintLevelValue {
|
||||
price: number
|
||||
bidVol: number
|
||||
askVol: number
|
||||
}
|
||||
export type SmaNode = SMA
|
||||
export declare class SMA {
|
||||
constructor(period: number)
|
||||
@@ -2232,6 +2238,15 @@ export declare class QuotedSpread {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type DepthSlopeNode = DepthSlope
|
||||
export declare class DepthSlope {
|
||||
constructor()
|
||||
update(bidPx: Array<number>, bidSz: Array<number>, askPx: Array<number>, askSz: Array<number>): number | null
|
||||
batch(snapshots: Array<ObSnapshot>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type OrderBookImbalanceTopNNode = OrderBookImbalanceTopN
|
||||
export declare class OrderBookImbalanceTopN {
|
||||
constructor(levels: number)
|
||||
@@ -2268,6 +2283,42 @@ export declare class TradeImbalance {
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type EffectiveSpreadNode = EffectiveSpread
|
||||
export declare class EffectiveSpread {
|
||||
constructor()
|
||||
update(price: number, size: number, isBuy: boolean, mid: number): number | null
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>, mid: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type RealizedSpreadNode = RealizedSpread
|
||||
export declare class RealizedSpread {
|
||||
constructor(horizon: number)
|
||||
update(price: number, size: number, isBuy: boolean, mid: number): number | null
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>, mid: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type KylesLambdaNode = KylesLambda
|
||||
export declare class KylesLambda {
|
||||
constructor(window: number)
|
||||
update(price: number, size: number, isBuy: boolean, mid: number): number | null
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>, mid: Array<number>): Array<number>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type FootprintNode = Footprint
|
||||
export declare class Footprint {
|
||||
constructor(tickSize: number)
|
||||
update(price: number, size: number, isBuy: boolean): Array<FootprintLevelValue>
|
||||
batch(price: Array<number>, size: Array<number>, isBuy: Array<boolean>): Array<Array<FootprintLevelValue>>
|
||||
reset(): void
|
||||
isReady(): boolean
|
||||
warmupPeriod(): number
|
||||
}
|
||||
export type SharpeRatioNode = SharpeRatio
|
||||
export declare class SharpeRatio {
|
||||
constructor(period: number, riskFree: number)
|
||||
|
||||
@@ -310,7 +310,7 @@ if (!nativeBinding) {
|
||||
throw new Error(`Failed to load native binding`)
|
||||
}
|
||||
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
|
||||
const { version, SMA, EMA, WMA, RSI, DEMA, TEMA, HMA, ROC, TRIX, SMMA, TRIMA, ZLEMA, MOM, CMO, DPO, StdDev, UlcerIndex, VerticalHorizontalFilter, ZScore, McGinleyDynamic, FRAMA, SuperSmoother, FisherTransform, Decycler, CenterOfGravity, CyberneticCycle, InstantaneousTrendline, EhlersStochastic, RVIVolatility, Variance, CoefficientOfVariation, Skewness, Kurtosis, StandardError, DetrendedStdDev, RSquared, MedianAbsoluteDeviation, Autocorrelation, HurstExponent, PearsonCorrelation, Beta, PairwiseBeta, SpearmanCorrelation, PairSpreadZScore, LeadLagCrossCorrelation, Cointegration, RelativeStrengthAB, MACD, BollingerBands, ATR, Stochastic, OBV, ADX, ADXR, CCI, WilliamsR, MFI, PSAR, Keltner, Donchian, VWAP, RollingVWAP, AwesomeOscillator, Aroon, Inertia, ConnorsRSI, LaguerreRSI, SMI, KST, PGO, RVI, AwesomeOscillatorHistogram, STC, ElderImpulse, ZeroLagMACD, CFO, APO, KAMA, EVWMA, Alligator, JMA, VIDYA, ALMA, T3, TSI, PMO, TII, ADL, VolumePriceTrend, ChaikinMoneyFlow, ChaikinOscillator, ForceIndex, NVI, PVI, VolumeOscillator, KVO, WilliamsAD, AnchoredVWAP, DemandIndex, TSV, VZO, MarketFacilitationIndex, EaseOfMovement, SuperTrend, ChandelierExit, ChandeKrollStop, AtrTrailingStop, HiLoActivator, VoltyStop, YoyoExit, DonchianStop, PercentageTrailingStop, StepTrailingStop, RenkoTrailingStop, TypicalPrice, MedianPrice, WeightedClose, LinearRegression, LinRegSlope, AcceleratorOscillator, BalanceOfPower, ChoppinessIndex, TrueRange, ChaikinVolatility, YangZhangVolatility, RogersSatchellVolatility, GarmanKlassVolatility, ParkinsonVolatility, LinRegAngle, BollingerBandwidth, PercentB, NATR, HistoricalVolatility, AroonOscillator, WaveTrend, RWI, Vortex, MassIndex, StochRSI, UltimateOscillator, PPO, Coppock, VWMA, MaEnvelope, AccelerationBands, StarcBands, AtrBands, HurstChannel, LinRegChannel, StandardErrorBands, DoubleBollinger, TtmSqueeze, FractalChaosBands, VwapStdDevBands, ClassicPivots, FibonacciPivots, Camarilla, WoodiePivots, DemarkPivots, WilliamsFractals, ZigZag, TDSetup, TDSequential, TDDeMarker, TDREI, TDPressure, TDCombo, TDCountdown, TDLines, TDRangeProjection, TDDifferential, TDOpen, TDRiskLevel, InverseFisherTransform, DecyclerOscillator, RoofingFilter, EmpiricalModeDecomposition, HilbertDominantCycle, AdaptiveCycle, SineWave, MAMA, FAMA, Ichimoku, HeikinAshi, ValueArea, InitialBalance, OpeningRange, Doji, Hammer, InvertedHammer, HangingMan, ShootingStar, Engulfing, Harami, MorningEveningStar, ThreeSoldiersOrCrows, PiercingDarkCloud, Marubozu, Tweezer, SpinningTop, ThreeInside, ThreeOutside, OrderBookImbalanceTop1, OrderBookImbalanceFull, Microprice, QuotedSpread, DepthSlope, OrderBookImbalanceTopN, SignedVolume, CumulativeVolumeDelta, TradeImbalance, EffectiveSpread, RealizedSpread, KylesLambda, Footprint, SharpeRatio, SortinoRatio, CalmarRatio, OmegaRatio, MaxDrawdown, AverageDrawdown, DrawdownDuration, PainIndex, ValueAtRisk, ConditionalValueAtRisk, ProfitFactor, GainLossRatio, RecoveryFactor, KellyCriterion, TreynorRatio, InformationRatio, Alpha } = nativeBinding
|
||||
|
||||
module.exports.version = version
|
||||
module.exports.SMA = SMA
|
||||
@@ -519,10 +519,15 @@ module.exports.OrderBookImbalanceTop1 = OrderBookImbalanceTop1
|
||||
module.exports.OrderBookImbalanceFull = OrderBookImbalanceFull
|
||||
module.exports.Microprice = Microprice
|
||||
module.exports.QuotedSpread = QuotedSpread
|
||||
module.exports.DepthSlope = DepthSlope
|
||||
module.exports.OrderBookImbalanceTopN = OrderBookImbalanceTopN
|
||||
module.exports.SignedVolume = SignedVolume
|
||||
module.exports.CumulativeVolumeDelta = CumulativeVolumeDelta
|
||||
module.exports.TradeImbalance = TradeImbalance
|
||||
module.exports.EffectiveSpread = EffectiveSpread
|
||||
module.exports.RealizedSpread = RealizedSpread
|
||||
module.exports.KylesLambda = KylesLambda
|
||||
module.exports.Footprint = Footprint
|
||||
module.exports.SharpeRatio = SharpeRatio
|
||||
module.exports.SortinoRatio = SortinoRatio
|
||||
module.exports.CalmarRatio = CalmarRatio
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra-darwin-arm64",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.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.4.2",
|
||||
"version": "0.4.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.4.2",
|
||||
"version": "0.4.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.4.2",
|
||||
"version": "0.4.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.4.2",
|
||||
"version": "0.4.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.4.2",
|
||||
"version": "0.4.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.4.2",
|
||||
"version": "0.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -15,12 +15,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.4.2",
|
||||
"wickra-darwin-x64": "0.4.2",
|
||||
"wickra-linux-arm64-gnu": "0.4.2",
|
||||
"wickra-linux-x64-gnu": "0.4.2",
|
||||
"wickra-win32-arm64-msvc": "0.4.2",
|
||||
"wickra-win32-x64-msvc": "0.4.2"
|
||||
"wickra-darwin-arm64": "0.4.3",
|
||||
"wickra-darwin-x64": "0.4.3",
|
||||
"wickra-linux-arm64-gnu": "0.4.3",
|
||||
"wickra-linux-x64-gnu": "0.4.3",
|
||||
"wickra-win32-arm64-msvc": "0.4.3",
|
||||
"wickra-win32-x64-msvc": "0.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/cli": {
|
||||
@@ -41,8 +41,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-arm64": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.2.tgz",
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-arm64/-/wickra-darwin-arm64-0.4.3.tgz",
|
||||
"integrity": "sha512-4eZiBR/yGUdr4nzhEUFy2i69XgNx64iI2ax/LPamsThgylC0KpHOZKK19QzJ2d9KbK4C8nMjME5FLuR+4GNEwQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -57,8 +57,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-darwin-x64": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.2.tgz",
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-darwin-x64/-/wickra-darwin-x64-0.4.3.tgz",
|
||||
"integrity": "sha512-6hf8zI3QPjTFp4zCpmgUwDvNtu6jHqNUHKD5e55POo0CgA52HkpyxSPtVm8TGTIZDI7kPjlbOdBM8CJ76mmXwA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -73,8 +73,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-arm64-gnu": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.2.tgz",
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-arm64-gnu/-/wickra-linux-arm64-gnu-0.4.3.tgz",
|
||||
"integrity": "sha512-kSe6y0xBMSiqdPLXNjwop5WZdHtvdBNKSEBCwZ4hFq33p4apW25/wrlzv9/oDuyD4kuPabJEhCCnFOplh58CUg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -89,8 +89,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-linux-x64-gnu": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.2.tgz",
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-linux-x64-gnu/-/wickra-linux-x64-gnu-0.4.3.tgz",
|
||||
"integrity": "sha512-tWBWS4qz7hxM4xnpFb59bhf6TaLwXq0Z3jEa/2l7r8PiHA94g8r8S53NRMiT+4yiL5hSWe/nUiC/YXdRrhEZ4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
@@ -105,8 +105,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-arm64-msvc": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.2.tgz",
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-arm64-msvc/-/wickra-win32-arm64-msvc-0.4.3.tgz",
|
||||
"integrity": "sha512-EXIckHxAtF75PUGDKRzXyqMe9ldP0JjSdu68WFN6iJfp+McYrGu6h40TEJlQ/oUEIoPqiZB/xhVyo/el5Lg7zw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
@@ -121,8 +121,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wickra-win32-x64-msvc": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.2.tgz",
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/wickra-win32-x64-msvc/-/wickra-win32-x64-msvc-0.4.3.tgz",
|
||||
"integrity": "sha512-Yfsqq1Xwp6hdxMyLze411vNdo7BDwI6+lPSe7A9XdqyPecNDbtKwYLpsal2r8EHbNzqM+R8XnuRtUaEQS5VlUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wickra",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.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.4.2",
|
||||
"wickra-linux-arm64-gnu": "0.4.2",
|
||||
"wickra-darwin-x64": "0.4.2",
|
||||
"wickra-darwin-arm64": "0.4.2",
|
||||
"wickra-win32-x64-msvc": "0.4.2",
|
||||
"wickra-win32-arm64-msvc": "0.4.2"
|
||||
"wickra-linux-x64-gnu": "0.4.3",
|
||||
"wickra-linux-arm64-gnu": "0.4.3",
|
||||
"wickra-darwin-x64": "0.4.3",
|
||||
"wickra-darwin-arm64": "0.4.3",
|
||||
"wickra-win32-x64-msvc": "0.4.3",
|
||||
"wickra-win32-arm64-msvc": "0.4.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
|
||||
@@ -8865,6 +8865,7 @@ node_ob_indicator!(
|
||||
);
|
||||
node_ob_indicator!(MicropriceNode, wc::Microprice, "Microprice");
|
||||
node_ob_indicator!(QuotedSpreadNode, wc::QuotedSpread, "QuotedSpread");
|
||||
node_ob_indicator!(DepthSlopeNode, wc::DepthSlope, "DepthSlope");
|
||||
|
||||
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
|
||||
#[napi(js_name = "OrderBookImbalanceTopN")]
|
||||
@@ -9052,6 +9053,305 @@ impl TradeImbalanceNode {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Price Impact ==============================
|
||||
//
|
||||
// Price-impact indicators consume a trade paired with the mid prevailing at
|
||||
// execution. Streaming `update(price, size, isBuy, mid)` takes one such
|
||||
// trade-quote (`isBuy=true` for a buyer-initiated trade); `batch` takes four
|
||||
// equal-length arrays.
|
||||
|
||||
fn build_trade_quote(
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> napi::Result<wc::TradeQuote> {
|
||||
let trade = build_trade(price, size, is_buy)?;
|
||||
wc::TradeQuote::new(trade, mid).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! node_trade_quote_indicator {
|
||||
($node:ident, $inner:ty, $js:literal) => {
|
||||
#[napi(js_name = $js)]
|
||||
pub struct $node {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
impl Default for $node {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl $node {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
mid: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if price.len() != size.len()
|
||||
|| size.len() != is_buy.len()
|
||||
|| is_buy.len() != mid.len()
|
||||
{
|
||||
return Err(NapiError::from_reason(
|
||||
"price, size, is_buy, mid must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
|
||||
out.push(self.inner.update(quote).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
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
node_trade_quote_indicator!(EffectiveSpreadNode, wc::EffectiveSpread, "EffectiveSpread");
|
||||
|
||||
// Realized spread carries a `horizon` parameter, so it is hand-written.
|
||||
#[napi(js_name = "RealizedSpread")]
|
||||
pub struct RealizedSpreadNode {
|
||||
inner: wc::RealizedSpread,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl RealizedSpreadNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(horizon: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::RealizedSpread::new(horizon as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
mid: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"price, size, is_buy, mid must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
|
||||
out.push(self.inner.update(quote).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
|
||||
}
|
||||
}
|
||||
|
||||
// Kyle's lambda carries a `window` parameter, so it is hand-written.
|
||||
#[napi(js_name = "KylesLambda")]
|
||||
pub struct KylesLambdaNode {
|
||||
inner: wc::KylesLambda,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl KylesLambdaNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(window: u32) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::KylesLambda::new(window as usize).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> napi::Result<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
mid: Vec<f64>,
|
||||
) -> napi::Result<Vec<f64>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"price, size, is_buy, mid must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
|
||||
out.push(self.inner.update(quote).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
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Footprint ==============================
|
||||
//
|
||||
// Footprint is a multi-output, variable-length indicator. Each `update(price,
|
||||
// size, isBuy)` returns the full bar footprint accumulated since the last
|
||||
// `reset()` as an array of `{ price, bidVol, askVol }` rows (sorted ascending
|
||||
// by price); `batch` returns an array of such arrays, one per trade.
|
||||
|
||||
/// One price bucket of a footprint.
|
||||
#[napi(object)]
|
||||
pub struct FootprintLevelValue {
|
||||
pub price: f64,
|
||||
pub bid_vol: f64,
|
||||
pub ask_vol: f64,
|
||||
}
|
||||
|
||||
fn footprint_levels(out: &wc::FootprintOutput) -> Vec<FootprintLevelValue> {
|
||||
out.levels
|
||||
.iter()
|
||||
.map(|level| FootprintLevelValue {
|
||||
price: level.price,
|
||||
bid_vol: level.bid_vol,
|
||||
ask_vol: level.ask_vol,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[napi(js_name = "Footprint")]
|
||||
pub struct FootprintNode {
|
||||
inner: wc::Footprint,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl FootprintNode {
|
||||
#[napi(constructor)]
|
||||
pub fn new(tick_size: f64) -> napi::Result<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Footprint::new(tick_size).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
#[napi]
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
) -> napi::Result<Vec<FootprintLevelValue>> {
|
||||
let out = self
|
||||
.inner
|
||||
.update(build_trade(price, size, is_buy)?)
|
||||
.expect("footprint emits on every trade");
|
||||
Ok(footprint_levels(&out))
|
||||
}
|
||||
#[napi]
|
||||
pub fn batch(
|
||||
&mut self,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
) -> napi::Result<Vec<Vec<FootprintLevelValue>>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() {
|
||||
return Err(NapiError::from_reason(
|
||||
"price, size, is_buy must be equal length".to_string(),
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let snapshot = self
|
||||
.inner
|
||||
.update(build_trade(price[i], size[i], is_buy[i])?)
|
||||
.expect("footprint emits on every trade");
|
||||
out.push(footprint_levels(&snapshot));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
#[napi]
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[napi(js_name = "isReady")]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[napi(js_name = "warmupPeriod")]
|
||||
pub fn warmup_period(&self) -> u32 {
|
||||
self.inner.warmup_period() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
// Risk metrics with fallible `new` (most need `period >= 2`), so each wrapper
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "wickra"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
description = "Streaming-first technical indicators: incremental, fast, install-free."
|
||||
readme = "README.md"
|
||||
license = { text = "PolyForm-Noncommercial-1.0.0 with additional personal-account permissions; see LICENSE" }
|
||||
|
||||
@@ -246,10 +246,17 @@ from ._wickra import (
|
||||
OrderBookImbalanceFull,
|
||||
Microprice,
|
||||
QuotedSpread,
|
||||
DepthSlope,
|
||||
# Microstructure: trade flow
|
||||
SignedVolume,
|
||||
CumulativeVolumeDelta,
|
||||
TradeImbalance,
|
||||
# Microstructure: price impact
|
||||
EffectiveSpread,
|
||||
RealizedSpread,
|
||||
KylesLambda,
|
||||
# Microstructure: footprint
|
||||
Footprint,
|
||||
# Risk / Performance
|
||||
SharpeRatio,
|
||||
SortinoRatio,
|
||||
@@ -493,10 +500,17 @@ __all__ = [
|
||||
"OrderBookImbalanceFull",
|
||||
"Microprice",
|
||||
"QuotedSpread",
|
||||
"DepthSlope",
|
||||
# Microstructure: trade flow
|
||||
"SignedVolume",
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
# Microstructure: price impact
|
||||
"EffectiveSpread",
|
||||
"RealizedSpread",
|
||||
"KylesLambda",
|
||||
# Microstructure: footprint
|
||||
"Footprint",
|
||||
# Risk / Performance
|
||||
"SharpeRatio",
|
||||
"SortinoRatio",
|
||||
|
||||
@@ -11714,6 +11714,7 @@ py_ob_indicator!(
|
||||
);
|
||||
py_ob_indicator!(PyMicroprice, wc::Microprice, "Microprice");
|
||||
py_ob_indicator!(PyQuotedSpread, wc::QuotedSpread, "QuotedSpread");
|
||||
py_ob_indicator!(PyDepthSlope, wc::DepthSlope, "DepthSlope");
|
||||
|
||||
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
|
||||
#[pyclass(
|
||||
@@ -11902,6 +11903,285 @@ impl PyTradeImbalance {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Price Impact ==============================
|
||||
//
|
||||
// Price-impact indicators consume a trade paired with the mid prevailing at
|
||||
// execution. Streaming `update(price, size, is_buy, mid)` takes one such
|
||||
// trade-quote (`is_buy=True` for a buyer-initiated trade); `batch` takes four
|
||||
// equal-length arrays.
|
||||
|
||||
fn build_trade_quote(price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult<wc::TradeQuote> {
|
||||
let trade = build_trade(price, size, is_buy)?;
|
||||
wc::TradeQuote::new(trade, mid).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! py_trade_quote_indicator {
|
||||
($name:ident, $inner:ty, $repr:expr) => {
|
||||
#[pyclass(name = $repr, module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct $name {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl $name {
|
||||
#[new]
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> PyResult<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
mid: Vec<f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if price.len() != size.len()
|
||||
|| size.len() != is_buy.len()
|
||||
|| is_buy.len() != mid.len()
|
||||
{
|
||||
return Err(PyValueError::new_err(
|
||||
"price, size, is_buy, mid must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
|
||||
out.push(self.inner.update(quote).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("{}()", $repr)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
py_trade_quote_indicator!(PyEffectiveSpread, wc::EffectiveSpread, "EffectiveSpread");
|
||||
|
||||
// Realized spread carries a `horizon` parameter, so it is hand-written.
|
||||
#[pyclass(
|
||||
name = "RealizedSpread",
|
||||
module = "wickra._wickra",
|
||||
skip_from_py_object
|
||||
)]
|
||||
#[derive(Clone)]
|
||||
struct PyRealizedSpread {
|
||||
inner: wc::RealizedSpread,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyRealizedSpread {
|
||||
#[new]
|
||||
fn new(horizon: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::RealizedSpread::new(horizon).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
mid: Vec<f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"price, size, is_buy, mid must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
|
||||
out.push(self.inner.update(quote).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("RealizedSpread(horizon={})", self.inner.horizon())
|
||||
}
|
||||
}
|
||||
|
||||
// Kyle's lambda carries a `window` parameter, so it is hand-written.
|
||||
#[pyclass(name = "KylesLambda", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyKylesLambda {
|
||||
inner: wc::KylesLambda,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyKylesLambda {
|
||||
#[new]
|
||||
fn new(window: usize) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::KylesLambda::new(window).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update(&mut self, price: f64, size: f64, is_buy: bool, mid: f64) -> PyResult<Option<f64>> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
mid: Vec<f64>,
|
||||
) -> PyResult<Bound<'py, PyArray1<f64>>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() || is_buy.len() != mid.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"price, size, is_buy, mid must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let quote = build_trade_quote(price[i], size[i], is_buy[i], mid[i])?;
|
||||
out.push(self.inner.update(quote).unwrap_or(f64::NAN));
|
||||
}
|
||||
Ok(out.into_pyarray(py))
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("KylesLambda(window={})", self.inner.window())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Footprint ==============================
|
||||
//
|
||||
// Footprint is a multi-output, variable-length indicator: each `update(price,
|
||||
// size, is_buy)` returns the full bar footprint accumulated since the last
|
||||
// `reset()` as a `(k, 3)` array with columns `[price, bid_vol, ask_vol]`, one
|
||||
// row per touched price bucket (sorted ascending by price). `batch` returns a
|
||||
// list of such arrays, one per trade.
|
||||
|
||||
fn footprint_to_array<'py>(
|
||||
py: Python<'py>,
|
||||
out: &wc::FootprintOutput,
|
||||
) -> Bound<'py, PyArray2<f64>> {
|
||||
let rows = out.levels.len();
|
||||
let mut data = Vec::with_capacity(rows * 3);
|
||||
for level in &out.levels {
|
||||
data.push(level.price);
|
||||
data.push(level.bid_vol);
|
||||
data.push(level.ask_vol);
|
||||
}
|
||||
numpy::ndarray::Array2::from_shape_vec((rows, 3), data)
|
||||
.expect("shape consistent")
|
||||
.into_pyarray(py)
|
||||
}
|
||||
|
||||
#[pyclass(name = "Footprint", module = "wickra._wickra", skip_from_py_object)]
|
||||
#[derive(Clone)]
|
||||
struct PyFootprint {
|
||||
inner: wc::Footprint,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyFootprint {
|
||||
#[new]
|
||||
fn new(tick_size: f64) -> PyResult<Self> {
|
||||
Ok(Self {
|
||||
inner: wc::Footprint::new(tick_size).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
fn update<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
) -> PyResult<Bound<'py, PyArray2<f64>>> {
|
||||
let out = self
|
||||
.inner
|
||||
.update(build_trade(price, size, is_buy)?)
|
||||
.expect("footprint emits on every trade");
|
||||
Ok(footprint_to_array(py, &out))
|
||||
}
|
||||
fn batch<'py>(
|
||||
&mut self,
|
||||
py: Python<'py>,
|
||||
price: Vec<f64>,
|
||||
size: Vec<f64>,
|
||||
is_buy: Vec<bool>,
|
||||
) -> PyResult<Vec<Bound<'py, PyArray2<f64>>>> {
|
||||
if price.len() != size.len() || size.len() != is_buy.len() {
|
||||
return Err(PyValueError::new_err(
|
||||
"price, size, is_buy must be equal length",
|
||||
));
|
||||
}
|
||||
let mut out = Vec::with_capacity(price.len());
|
||||
for i in 0..price.len() {
|
||||
let snapshot = self
|
||||
.inner
|
||||
.update(build_trade(price[i], size[i], is_buy[i])?)
|
||||
.expect("footprint emits on every trade");
|
||||
out.push(footprint_to_array(py, &snapshot));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
fn __repr__(&self) -> String {
|
||||
format!("Footprint(tick_size={})", self.inner.tick_size())
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Family 15: Risk / Performance ==============================
|
||||
|
||||
#[pyclass(name = "SharpeRatio", module = "wickra._wickra", skip_from_py_object)]
|
||||
@@ -13013,10 +13293,17 @@ fn _wickra(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyOrderBookImbalanceFull>()?;
|
||||
m.add_class::<PyMicroprice>()?;
|
||||
m.add_class::<PyQuotedSpread>()?;
|
||||
m.add_class::<PyDepthSlope>()?;
|
||||
// Microstructure: trade flow.
|
||||
m.add_class::<PySignedVolume>()?;
|
||||
m.add_class::<PyCumulativeVolumeDelta>()?;
|
||||
m.add_class::<PyTradeImbalance>()?;
|
||||
// Microstructure: price impact.
|
||||
m.add_class::<PyEffectiveSpread>()?;
|
||||
m.add_class::<PyRealizedSpread>()?;
|
||||
m.add_class::<PyKylesLambda>()?;
|
||||
// Microstructure: footprint.
|
||||
m.add_class::<PyFootprint>()?;
|
||||
// Family 15: Risk / Performance metrics.
|
||||
m.add_class::<PySharpeRatio>()?;
|
||||
m.add_class::<PySortinoRatio>()?;
|
||||
|
||||
@@ -211,3 +211,30 @@ def test_trade_non_positive_price_raises():
|
||||
def test_trade_batch_unequal_lengths_raise():
|
||||
with pytest.raises(ValueError):
|
||||
ta.SignedVolume().batch([100.0, 100.0], [1.0], [True, False])
|
||||
|
||||
|
||||
def test_effective_spread_non_positive_mid_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.EffectiveSpread().update(100.0, 1.0, True, 0.0)
|
||||
|
||||
|
||||
def test_effective_spread_batch_unequal_lengths_raise():
|
||||
with pytest.raises(ValueError):
|
||||
ta.EffectiveSpread().batch([100.0, 100.0], [1.0, 1.0], [True, False], [100.0])
|
||||
|
||||
|
||||
def test_realized_spread_zero_horizon_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.RealizedSpread(0)
|
||||
|
||||
|
||||
def test_kyles_lambda_window_below_two_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.KylesLambda(1)
|
||||
|
||||
|
||||
def test_footprint_non_positive_tick_raises():
|
||||
with pytest.raises(ValueError):
|
||||
ta.Footprint(0.0)
|
||||
with pytest.raises(ValueError):
|
||||
ta.Footprint(-1.0)
|
||||
|
||||
@@ -872,6 +872,27 @@ def test_quoted_spread_reference_value():
|
||||
assert qs.update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(99.50248756, abs=1e-6)
|
||||
|
||||
|
||||
def test_depth_slope_reference_value():
|
||||
# Symmetric book, each side distances 1, 2 with cumulative sizes 1, 3.
|
||||
# OLS slope of (1->1, 2->3) = 2; mean of two equal sides = 2.
|
||||
ds = ta.DepthSlope()
|
||||
out = ds.update([99.0, 98.0], [1.0, 2.0], [101.0, 102.0], [1.0, 2.0])
|
||||
assert out == pytest.approx(2.0, abs=1e-9)
|
||||
# A book with a single level per side has no slope -> 0.
|
||||
assert ta.DepthSlope().update([100.0], [1.0], [101.0], [1.0]) == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_footprint_buckets_buy_and_sell_volume():
|
||||
fp = ta.Footprint(1.0)
|
||||
fp.update(100.2, 2.0, True) # bucket 100 -> ask 2
|
||||
fp.update(100.7, 3.0, False) # bucket 101 -> bid 3
|
||||
out = fp.update(100.1, 1.0, True) # bucket 100 -> ask 3
|
||||
# Columns are [price, bid_vol, ask_vol], rows sorted ascending by price.
|
||||
assert out.shape == (2, 3)
|
||||
assert list(out[0]) == [100.0, 0.0, 3.0]
|
||||
assert list(out[1]) == [101.0, 3.0, 0.0]
|
||||
|
||||
|
||||
def test_signed_volume_reference_values():
|
||||
assert ta.SignedVolume().update(100.0, 2.0, True) == pytest.approx(2.0)
|
||||
assert ta.SignedVolume().update(100.0, 3.0, False) == pytest.approx(-3.0)
|
||||
@@ -889,3 +910,39 @@ def test_trade_imbalance_reference_value():
|
||||
assert ti.update(100.0, 3.0, True) is None # warming up
|
||||
# Window full: buyVol 3, sellVol 1 -> (3 - 1) / 4 = 0.5.
|
||||
assert ti.update(100.0, 1.0, False) == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_effective_spread_reference_values():
|
||||
# Buy at 100.05 vs mid 100.0: 2 * (100.05 - 100) / 100 * 10000 = 10 bps.
|
||||
assert ta.EffectiveSpread().update(100.05, 1.0, True, 100.0) == pytest.approx(10.0)
|
||||
# Sell at 99.95 vs mid 100.0: 2 * -1 * (99.95 - 100) / 100 * 10000 = 10 bps.
|
||||
assert ta.EffectiveSpread().update(99.95, 1.0, False, 100.0) == pytest.approx(10.0)
|
||||
# A buy filled below the mid is price improvement -> negative.
|
||||
assert ta.EffectiveSpread().update(99.95, 1.0, True, 100.0) < 0.0
|
||||
|
||||
|
||||
def test_realized_spread_reference_value():
|
||||
rs = ta.RealizedSpread(1)
|
||||
assert rs.update(100.10, 1.0, True, 100.0) is None # buffered
|
||||
# Resolved against mid 100.20 one trade later:
|
||||
# 2 * (+1) * (100.10 - 100.20) / 100.0 * 10000 = -20 bps (adverse selection).
|
||||
assert rs.update(99.90, 1.0, False, 100.20) == pytest.approx(-20.0)
|
||||
|
||||
|
||||
def test_kyles_lambda_recovers_constant_impact():
|
||||
# Build a tape where each trade moves the mid by exactly 0.5 per unit of
|
||||
# signed volume -> the rolling OLS slope is 0.5.
|
||||
impact = 0.5
|
||||
mid = 100.0
|
||||
price, size, is_buy, mids = [], [], [], []
|
||||
for i in range(20):
|
||||
buy = i % 2 == 0
|
||||
sz = 1.0 + (i % 3)
|
||||
signed = sz if buy else -sz
|
||||
mid += impact * signed
|
||||
price.append(mid)
|
||||
size.append(sz)
|
||||
is_buy.append(buy)
|
||||
mids.append(mid)
|
||||
out = ta.KylesLambda(6).batch(price, size, is_buy, mids)
|
||||
assert out[-1] == pytest.approx(0.5, abs=1e-9)
|
||||
|
||||
@@ -139,6 +139,7 @@ def test_orderbook_lifecycle():
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
ta.DepthSlope(),
|
||||
]:
|
||||
assert ind.warmup_period() == 1
|
||||
assert not ind.is_ready()
|
||||
@@ -172,3 +173,48 @@ def test_trade_imbalance_lifecycle_and_repr():
|
||||
ti.reset()
|
||||
assert not ti.is_ready()
|
||||
assert repr(ta.TradeImbalance(4)) == "TradeImbalance(window=4)"
|
||||
|
||||
|
||||
def test_effective_spread_lifecycle():
|
||||
es = ta.EffectiveSpread()
|
||||
assert es.warmup_period() == 1
|
||||
assert not es.is_ready()
|
||||
es.update(100.05, 1.0, True, 100.0)
|
||||
assert es.is_ready()
|
||||
es.reset()
|
||||
assert not es.is_ready()
|
||||
|
||||
|
||||
def test_realized_spread_lifecycle_and_repr():
|
||||
rs = ta.RealizedSpread(3)
|
||||
assert rs.warmup_period() == 4
|
||||
assert not rs.is_ready()
|
||||
for _ in range(4):
|
||||
rs.update(100.0, 1.0, True, 100.0)
|
||||
assert rs.is_ready()
|
||||
rs.reset()
|
||||
assert not rs.is_ready()
|
||||
assert repr(ta.RealizedSpread(5)) == "RealizedSpread(horizon=5)"
|
||||
|
||||
|
||||
def test_kyles_lambda_lifecycle_and_repr():
|
||||
kl = ta.KylesLambda(3)
|
||||
assert kl.warmup_period() == 4
|
||||
assert not kl.is_ready()
|
||||
for i in range(4):
|
||||
kl.update(100.0 + i, 1.0 + (i % 2), i % 2 == 0, 100.0 + i)
|
||||
assert kl.is_ready()
|
||||
kl.reset()
|
||||
assert not kl.is_ready()
|
||||
assert repr(ta.KylesLambda(7)) == "KylesLambda(window=7)"
|
||||
|
||||
|
||||
def test_footprint_lifecycle_and_repr():
|
||||
fp = ta.Footprint(0.5)
|
||||
assert fp.warmup_period() == 1
|
||||
assert not fp.is_ready()
|
||||
fp.update(100.0, 1.0, True)
|
||||
assert fp.is_ready()
|
||||
fp.reset()
|
||||
assert not fp.is_ready()
|
||||
assert repr(ta.Footprint(0.25)) == "Footprint(tick_size=0.25)"
|
||||
|
||||
@@ -1890,6 +1890,7 @@ def test_orderbook_indicators_streaming_equals_batch():
|
||||
ta.OrderBookImbalanceFull,
|
||||
ta.Microprice,
|
||||
ta.QuotedSpread,
|
||||
ta.DepthSlope,
|
||||
):
|
||||
batch = make().batch(snaps)
|
||||
streamer = make()
|
||||
@@ -1918,3 +1919,36 @@ def test_tradeflow_indicators_streaming_equals_batch():
|
||||
)
|
||||
assert batch.shape == (n,)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_price_impact_indicators_streaming_equals_batch():
|
||||
n = 40
|
||||
mid = np.array([100.0 + 0.5 * math.sin(i * 0.4) for i in range(n)], dtype=np.float64)
|
||||
is_buy = [i % 2 == 0 for i in range(n)]
|
||||
# Aggressive trades print across the mid in the aggressor's direction.
|
||||
price = np.array(
|
||||
[mid[i] + (0.02 if is_buy[i] else -0.02) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
size = np.array([1.0 + (i % 5) for i in range(n)], dtype=np.float64)
|
||||
for make in (ta.EffectiveSpread, lambda: ta.RealizedSpread(4), lambda: ta.KylesLambda(5)):
|
||||
batch = make().batch(price, size, is_buy, mid)
|
||||
streamer = make()
|
||||
streamed = np.array(
|
||||
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert batch.shape == (n,)
|
||||
assert _eq_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_footprint_streaming_equals_batch():
|
||||
n = 20
|
||||
price = [100.0 + (i % 5) * 0.3 for i in range(n)]
|
||||
size = [1.0 + (i % 3) for i in range(n)]
|
||||
is_buy = [i % 2 == 0 for i in range(n)]
|
||||
batch = ta.Footprint(1.0).batch(price, size, is_buy)
|
||||
streamer = ta.Footprint(1.0)
|
||||
assert len(batch) == n
|
||||
for i in range(n):
|
||||
streamed = streamer.update(price[i], size[i], is_buy[i])
|
||||
assert np.array_equal(streamed, batch[i])
|
||||
|
||||
@@ -108,6 +108,7 @@ def test_orderbook_indicators_construct_and_emit():
|
||||
ta.OrderBookImbalanceFull(),
|
||||
ta.Microprice(),
|
||||
ta.QuotedSpread(),
|
||||
ta.DepthSlope(),
|
||||
]
|
||||
for ind in indicators:
|
||||
out = ind.update(*snapshot)
|
||||
@@ -135,3 +136,34 @@ def test_tradeflow_batch_returns_one_value_per_trade():
|
||||
out = ta.CumulativeVolumeDelta().batch(price, size, is_buy)
|
||||
assert out.shape == (6,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_price_impact_indicators_construct_and_emit():
|
||||
# Price-impact indicators take a trade paired with the prevailing mid.
|
||||
assert isinstance(ta.EffectiveSpread().update(100.05, 1.0, True, 100.0), float)
|
||||
# RealizedSpread buffers until its horizon elapses.
|
||||
assert ta.RealizedSpread(1).update(100.05, 1.0, True, 100.0) is None
|
||||
|
||||
|
||||
def test_price_impact_batch_returns_one_value_per_trade():
|
||||
price = np.array([100.05, 99.95, 100.10, 99.90])
|
||||
size = np.array([1.0, 2.0, 1.0, 2.0])
|
||||
is_buy = [True, False, True, False]
|
||||
mid = np.full(4, 100.0)
|
||||
for ind in (ta.EffectiveSpread(), ta.RealizedSpread(2), ta.KylesLambda(2)):
|
||||
out = ind.batch(price, size, is_buy, mid)
|
||||
assert out.shape == (4,)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_footprint_constructs_and_emits():
|
||||
out = ta.Footprint(1.0).update(100.2, 2.0, True)
|
||||
assert out.shape == (1, 3)
|
||||
assert out.dtype == np.float64
|
||||
|
||||
|
||||
def test_footprint_batch_returns_list_of_arrays():
|
||||
res = ta.Footprint(1.0).batch([100.2, 100.7], [2.0, 3.0], [True, False])
|
||||
assert isinstance(res, list)
|
||||
assert len(res) == 2
|
||||
assert res[-1].shape[1] == 3
|
||||
|
||||
@@ -231,3 +231,20 @@ def test_tradeflow_streaming_matches_batch():
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
|
||||
def test_price_impact_streaming_matches_batch():
|
||||
n = 30
|
||||
mid = np.array([100.0 + 0.25 * math.sin(i * 0.5) for i in range(n)], dtype=np.float64)
|
||||
is_buy = [i % 3 != 0 for i in range(n)]
|
||||
price = np.array(
|
||||
[mid[i] + (0.03 if is_buy[i] else -0.03) for i in range(n)], dtype=np.float64
|
||||
)
|
||||
size = np.array([1.0 + (i % 4) for i in range(n)], dtype=np.float64)
|
||||
batch = ta.EffectiveSpread().batch(price, size, is_buy, mid)
|
||||
streamer = ta.EffectiveSpread()
|
||||
streamed = np.array(
|
||||
[streamer.update(price[i], size[i], is_buy[i], mid[i]) for i in range(n)],
|
||||
dtype=np.float64,
|
||||
)
|
||||
assert _equal_with_nan(batch, streamed)
|
||||
|
||||
+193
-1
@@ -9,7 +9,7 @@
|
||||
#![allow(clippy::needless_pass_by_value)]
|
||||
#![allow(missing_debug_implementations)] // wasm_bindgen wrappers expose JS objects, no need for Debug
|
||||
|
||||
use js_sys::{Float64Array, Object, Reflect};
|
||||
use js_sys::{Array, Float64Array, Object, Reflect};
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wickra_core as wc;
|
||||
use wickra_core::{BatchExt, Indicator};
|
||||
@@ -6421,6 +6421,7 @@ wasm_ob_indicator!(
|
||||
);
|
||||
wasm_ob_indicator!(WasmMicroprice, wc::Microprice, Microprice);
|
||||
wasm_ob_indicator!(WasmQuotedSpread, wc::QuotedSpread, QuotedSpread);
|
||||
wasm_ob_indicator!(WasmDepthSlope, wc::DepthSlope, DepthSlope);
|
||||
|
||||
// Top-N imbalance carries a `levels` parameter, so it is hand-written.
|
||||
#[wasm_bindgen(js_name = OrderBookImbalanceTopN)]
|
||||
@@ -6555,6 +6556,197 @@ impl WasmTradeImbalance {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Price Impact ==============================
|
||||
//
|
||||
// Price-impact indicators consume a trade paired with the mid prevailing at
|
||||
// execution. Each `update(price, size, isBuy, mid)` takes one such trade-quote
|
||||
// (`isBuy=true` for a buyer-initiated trade) — the streaming model for a live
|
||||
// browser trade feed. Batch over a tape is provided by the Python and Node
|
||||
// bindings.
|
||||
|
||||
fn build_trade_quote(
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> Result<wc::TradeQuote, JsError> {
|
||||
let trade = build_trade(price, size, is_buy)?;
|
||||
wc::TradeQuote::new(trade, mid).map_err(map_err)
|
||||
}
|
||||
|
||||
macro_rules! wasm_trade_quote_indicator {
|
||||
($wasm:ident, $inner:ty, $js:ident) => {
|
||||
#[wasm_bindgen(js_name = $js)]
|
||||
pub struct $wasm {
|
||||
inner: $inner,
|
||||
}
|
||||
|
||||
impl Default for $wasm {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = $js)]
|
||||
impl $wasm {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new() -> $wasm {
|
||||
Self {
|
||||
inner: <$inner>::new(),
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
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_trade_quote_indicator!(WasmEffectiveSpread, wc::EffectiveSpread, EffectiveSpread);
|
||||
|
||||
// Realized spread carries a `horizon` parameter, so it is hand-written.
|
||||
#[wasm_bindgen(js_name = RealizedSpread)]
|
||||
pub struct WasmRealizedSpread {
|
||||
inner: wc::RealizedSpread,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = RealizedSpread)]
|
||||
impl WasmRealizedSpread {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(horizon: usize) -> Result<WasmRealizedSpread, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::RealizedSpread::new(horizon).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// Kyle's lambda carries a `window` parameter, so it is hand-written.
|
||||
#[wasm_bindgen(js_name = KylesLambda)]
|
||||
pub struct WasmKylesLambda {
|
||||
inner: wc::KylesLambda,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = KylesLambda)]
|
||||
impl WasmKylesLambda {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(window: usize) -> Result<WasmKylesLambda, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::KylesLambda::new(window).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
price: f64,
|
||||
size: f64,
|
||||
is_buy: bool,
|
||||
mid: f64,
|
||||
) -> Result<Option<f64>, JsError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.update(build_trade_quote(price, size, is_buy, mid)?))
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Microstructure: Footprint ==============================
|
||||
//
|
||||
// Footprint is a multi-output, variable-length indicator. Each `update(price,
|
||||
// size, isBuy)` returns the full bar footprint accumulated since the last
|
||||
// `reset()` as an array of `{ price, bidVol, askVol }` objects (sorted ascending
|
||||
// by price) — the streaming model for a live browser trade feed.
|
||||
|
||||
#[wasm_bindgen(js_name = Footprint)]
|
||||
pub struct WasmFootprint {
|
||||
inner: wc::Footprint,
|
||||
}
|
||||
|
||||
#[wasm_bindgen(js_class = Footprint)]
|
||||
impl WasmFootprint {
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(tick_size: f64) -> Result<WasmFootprint, JsError> {
|
||||
Ok(Self {
|
||||
inner: wc::Footprint::new(tick_size).map_err(map_err)?,
|
||||
})
|
||||
}
|
||||
pub fn update(&mut self, price: f64, size: f64, is_buy: bool) -> Result<JsValue, JsError> {
|
||||
let out = self
|
||||
.inner
|
||||
.update(build_trade(price, size, is_buy)?)
|
||||
.expect("footprint emits on every trade");
|
||||
let levels = Array::new();
|
||||
for level in &out.levels {
|
||||
let obj = Object::new();
|
||||
Reflect::set(&obj, &"price".into(), &level.price.into()).ok();
|
||||
Reflect::set(&obj, &"bidVol".into(), &level.bid_vol.into()).ok();
|
||||
Reflect::set(&obj, &"askVol".into(), &level.ask_vol.into()).ok();
|
||||
levels.push(&obj);
|
||||
}
|
||||
Ok(levels.into())
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
#[wasm_bindgen(js_name = isReady)]
|
||||
pub fn is_ready(&self) -> bool {
|
||||
self.inner.is_ready()
|
||||
}
|
||||
#[wasm_bindgen(js_name = warmupPeriod)]
|
||||
pub fn warmup_period(&self) -> usize {
|
||||
self.inner.warmup_period()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Depth Slope — how fast resting liquidity accumulates away from the mid.
|
||||
|
||||
use crate::microstructure::{Level, OrderBook};
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Ordinary-least-squares slope of cumulative resting size against distance
|
||||
/// from the mid, over the levels of one book side.
|
||||
///
|
||||
/// `signed_distance` is `+1.0` for the ask side (price above the mid) and
|
||||
/// `−1.0` for the bid side (price below the mid), so the regressor `x` —
|
||||
/// distance from the mid — is non-negative on both sides. The response `y` is
|
||||
/// the cumulative size walking outward from the touch. Returns `0.0` for a
|
||||
/// degenerate fit where every level sits at the same distance (zero variance in
|
||||
/// `x`).
|
||||
fn cumulative_slope(levels: &[Level], mid: f64, signed_distance: f64) -> f64 {
|
||||
let count = levels.len() as f64;
|
||||
let mut cumulative = 0.0;
|
||||
let mut sum_x = 0.0;
|
||||
let mut sum_y = 0.0;
|
||||
let mut sum_xy = 0.0;
|
||||
let mut sum_xx = 0.0;
|
||||
for level in levels {
|
||||
let x = signed_distance * (level.price - mid);
|
||||
cumulative += level.size;
|
||||
sum_x += x;
|
||||
sum_y += cumulative;
|
||||
sum_xy += x * cumulative;
|
||||
sum_xx += x * x;
|
||||
}
|
||||
let denom = count * sum_xx - sum_x * sum_x;
|
||||
if denom == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
(count * sum_xy - sum_x * sum_y) / denom
|
||||
}
|
||||
|
||||
/// Depth Slope — the average rate at which cumulative resting size grows with
|
||||
/// distance from the mid, across the bid and ask sides of the book.
|
||||
///
|
||||
/// For each side the indicator runs an ordinary-least-squares regression of
|
||||
/// cumulative size (walking outward from the touch) on the level's distance
|
||||
/// from the mid, then reports the mean of the two slopes:
|
||||
///
|
||||
/// ```text
|
||||
/// slope_side = OLS slope of (|priceᵢ − mid|, Σ_{j≤i} sizeⱼ)
|
||||
/// depthSlope = (slope_bid + slope_ask) / 2
|
||||
/// ```
|
||||
///
|
||||
/// Because the response is *cumulative* size it never decreases with distance,
|
||||
/// so the slope is non-negative: it is a magnitude, not a direction. A large
|
||||
/// slope means cumulative liquidity builds quickly away from the touch — a deep
|
||||
/// book that absorbs large orders with little walking; a small slope is a thin,
|
||||
/// shallow book. A book whose size is concentrated at the touch and thins out
|
||||
/// behind it (a fragile book) reads a *smaller* slope than one of equal total
|
||||
/// depth that thickens with distance.
|
||||
///
|
||||
/// A side with fewer than two levels carries no slope, so the indicator returns
|
||||
/// `0.0` whenever either side has fewer than two levels (including an empty
|
||||
/// book).
|
||||
///
|
||||
/// `Input = OrderBook`, `Output = f64`. Stateless; ready after the first
|
||||
/// snapshot.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{DepthSlope, Indicator, Level, OrderBook};
|
||||
///
|
||||
/// // Both sides thicken linearly away from the mid (sizes 1, 2, 3 …).
|
||||
/// let book = OrderBook::new(
|
||||
/// vec![Level::new(99.0, 1.0).unwrap(), Level::new(98.0, 2.0).unwrap()],
|
||||
/// vec![Level::new(101.0, 1.0).unwrap(), Level::new(102.0, 2.0).unwrap()],
|
||||
/// )
|
||||
/// .unwrap();
|
||||
/// let mut ds = DepthSlope::new();
|
||||
/// assert!(ds.update(book).unwrap() > 0.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DepthSlope {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl DepthSlope {
|
||||
/// Construct a new depth-slope indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for DepthSlope {
|
||||
type Input = OrderBook;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, book: OrderBook) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let Some(mid) = book.mid() else {
|
||||
return Some(0.0);
|
||||
};
|
||||
if book.bids.len() < 2 || book.asks.len() < 2 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let bid_slope = cumulative_slope(&book.bids, mid, -1.0);
|
||||
let ask_slope = cumulative_slope(&book.asks, mid, 1.0);
|
||||
Some(f64::midpoint(bid_slope, ask_slope))
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"DepthSlope"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn book(bids: &[(f64, f64)], asks: &[(f64, f64)]) -> OrderBook {
|
||||
let to_levels = |xs: &[(f64, f64)]| {
|
||||
xs.iter()
|
||||
.map(|&(p, s)| Level::new(p, s).unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
OrderBook::new(to_levels(bids), to_levels(asks)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let ds = DepthSlope::new();
|
||||
assert_eq!(ds.name(), "DepthSlope");
|
||||
assert_eq!(ds.warmup_period(), 1);
|
||||
assert!(!ds.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thickening_book_has_positive_slope() {
|
||||
let mut ds = DepthSlope::new();
|
||||
let out = ds
|
||||
.update(book(
|
||||
&[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
|
||||
&[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
|
||||
))
|
||||
.unwrap();
|
||||
assert!(out > 0.0);
|
||||
assert!(ds.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn front_loaded_book_has_smaller_slope_than_back_loaded() {
|
||||
// Same total depth (6 per side), but one book thickens away from the
|
||||
// touch and the other thins. Cumulative slope is non-negative for both;
|
||||
// the back-loaded book accumulates faster, so its slope is larger.
|
||||
let mut back = DepthSlope::new();
|
||||
let back_slope = back
|
||||
.update(book(
|
||||
&[(99.0, 1.0), (98.0, 2.0), (97.0, 3.0)],
|
||||
&[(101.0, 1.0), (102.0, 2.0), (103.0, 3.0)],
|
||||
))
|
||||
.unwrap();
|
||||
let mut front = DepthSlope::new();
|
||||
let front_slope = front
|
||||
.update(book(
|
||||
&[(99.0, 3.0), (98.0, 2.0), (97.0, 1.0)],
|
||||
&[(101.0, 3.0), (102.0, 2.0), (103.0, 1.0)],
|
||||
))
|
||||
.unwrap();
|
||||
assert!(front_slope >= 0.0);
|
||||
assert!(back_slope > front_slope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_slope_value() {
|
||||
// Symmetric book, each side: distances 1, 2; cumulative sizes 1, 3.
|
||||
// OLS slope of (1->1, 2->3) = 2. Mean of two equal sides = 2.
|
||||
let mut ds = DepthSlope::new();
|
||||
let out = ds
|
||||
.update(book(
|
||||
&[(99.0, 1.0), (98.0, 2.0)],
|
||||
&[(101.0, 1.0), (102.0, 2.0)],
|
||||
))
|
||||
.unwrap();
|
||||
assert!((out - 2.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_level_side_is_zero() {
|
||||
let mut ds = DepthSlope::new();
|
||||
// Bid side has only one level -> no slope -> 0.
|
||||
assert_eq!(
|
||||
ds.update(book(&[(100.0, 1.0)], &[(101.0, 1.0), (102.0, 1.0)])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_book_is_zero() {
|
||||
let mut ds = DepthSlope::new();
|
||||
assert_eq!(
|
||||
ds.update(OrderBook::new_unchecked(vec![], vec![])),
|
||||
Some(0.0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degenerate_distance_slope_is_zero() {
|
||||
// Two levels at the same distance from mid carry zero x-variance.
|
||||
let levels = [
|
||||
Level::new_unchecked(100.0, 1.0),
|
||||
Level::new_unchecked(100.0, 2.0),
|
||||
];
|
||||
assert_eq!(cumulative_slope(&levels, 100.0, 1.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let books: Vec<OrderBook> = (0..20)
|
||||
.map(|i| {
|
||||
let extra = f64::from(i % 4);
|
||||
book(
|
||||
&[(99.0, 1.0 + extra), (98.0, 2.0)],
|
||||
&[(101.0, 1.0), (102.0, 2.0 + extra)],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut a = DepthSlope::new();
|
||||
let mut b = DepthSlope::new();
|
||||
assert_eq!(
|
||||
a.batch(&books),
|
||||
books
|
||||
.iter()
|
||||
.map(|x| b.update(x.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut ds = DepthSlope::new();
|
||||
ds.update(book(
|
||||
&[(99.0, 1.0), (98.0, 2.0)],
|
||||
&[(101.0, 1.0), (102.0, 2.0)],
|
||||
));
|
||||
assert!(ds.is_ready());
|
||||
ds.reset();
|
||||
assert!(!ds.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Effective Spread — the realised cost of a single trade in basis points.
|
||||
|
||||
use crate::microstructure::TradeQuote;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Effective Spread — twice the signed deviation of an executed trade price
|
||||
/// from the prevailing mid, expressed in basis points of the mid.
|
||||
///
|
||||
/// ```text
|
||||
/// effectiveSpread = 2 · D · (tradePrice − mid) / mid · 10_000 (bps)
|
||||
/// ```
|
||||
///
|
||||
/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell). The
|
||||
/// factor of two scales the one-sided deviation up to a full round-trip cost so
|
||||
/// it is directly comparable to the [quoted spread]: a marketable order that
|
||||
/// fills exactly at the touch of an otherwise quoted-spread book pays an
|
||||
/// effective spread equal to the quoted spread. Trades that fill *inside* the
|
||||
/// spread (price improvement) read below the quoted spread; trades that walk
|
||||
/// the book read above it.
|
||||
///
|
||||
/// A buy printed above the mid (`tradePrice > mid`) and a sell printed below it
|
||||
/// both yield a positive effective spread — the conventional sign, since the
|
||||
/// aggressor pays in both cases. A trade printed on the wrong side of the mid
|
||||
/// for its aggressor flag (a buy below the mid) reads negative, the signature of
|
||||
/// price improvement or a stale/mislabelled quote.
|
||||
///
|
||||
/// `Input = TradeQuote`, `Output = f64`. Stateless; ready after the first
|
||||
/// trade-quote.
|
||||
///
|
||||
/// [quoted spread]: crate::QuotedSpread
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{EffectiveSpread, Indicator, Side, Trade, TradeQuote};
|
||||
///
|
||||
/// let mut es = EffectiveSpread::new();
|
||||
/// // Buy filled at 100.05 against a mid of 100.0:
|
||||
/// // 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
|
||||
/// let trade = Trade::new(100.05, 1.0, Side::Buy, 0).unwrap();
|
||||
/// let quote = TradeQuote::new(trade, 100.0).unwrap();
|
||||
/// assert!((es.update(quote).unwrap() - 10.0).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct EffectiveSpread {
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl EffectiveSpread {
|
||||
/// Construct a new effective-spread indicator.
|
||||
pub const fn new() -> Self {
|
||||
Self { has_emitted: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for EffectiveSpread {
|
||||
type Input = TradeQuote;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, quote: TradeQuote) -> Option<f64> {
|
||||
self.has_emitted = true;
|
||||
let sign = quote.trade.side.sign();
|
||||
Some(2.0 * sign * (quote.trade.price - quote.mid) / quote.mid * 10_000.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"EffectiveSpread"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::{Side, Trade};
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn quote(price: f64, side: Side, mid: f64) -> TradeQuote {
|
||||
TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let es = EffectiveSpread::new();
|
||||
assert_eq!(es.name(), "EffectiveSpread");
|
||||
assert_eq!(es.warmup_period(), 1);
|
||||
assert!(!es.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_above_mid_is_positive() {
|
||||
let mut es = EffectiveSpread::new();
|
||||
// 2 · (+1) · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
|
||||
let out = es.update(quote(100.05, Side::Buy, 100.0)).unwrap();
|
||||
assert!((out - 10.0).abs() < 1e-9);
|
||||
assert!(es.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sell_below_mid_is_positive() {
|
||||
let mut es = EffectiveSpread::new();
|
||||
// 2 · (−1) · (99.95 − 100.0) / 100.0 · 10_000 = 10 bps.
|
||||
let out = es.update(quote(99.95, Side::Sell, 100.0)).unwrap();
|
||||
assert!((out - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn price_improvement_reads_negative() {
|
||||
let mut es = EffectiveSpread::new();
|
||||
// A buy filled below the mid: price improvement -> negative.
|
||||
let out = es.update(quote(99.95, Side::Buy, 100.0)).unwrap();
|
||||
assert!(out < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trade_at_mid_is_zero() {
|
||||
let mut es = EffectiveSpread::new();
|
||||
assert_eq!(es.update(quote(100.0, Side::Buy, 100.0)), Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let quotes: Vec<TradeQuote> = (0..20)
|
||||
.map(|i| {
|
||||
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
let price = 100.0 + f64::from(i % 4) * 0.01;
|
||||
quote(price, side, 100.0)
|
||||
})
|
||||
.collect();
|
||||
let mut a = EffectiveSpread::new();
|
||||
let mut b = EffectiveSpread::new();
|
||||
assert_eq!(
|
||||
a.batch("es),
|
||||
quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut es = EffectiveSpread::new();
|
||||
es.update(quote(100.05, Side::Buy, 100.0));
|
||||
assert!(es.is_ready());
|
||||
es.reset();
|
||||
assert!(!es.is_ready());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Footprint — buy/sell volume profile per price bucket within a bar.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::Trade;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// One price bucket of a [`Footprint`]: the buy- and sell-initiated volume that
|
||||
/// traded there since the last reset.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct FootprintLevel {
|
||||
/// Bucket price (the bucket index times the tick size).
|
||||
pub price: f64,
|
||||
/// Sell-initiated (bid-hitting) volume traded at this bucket.
|
||||
pub bid_vol: f64,
|
||||
/// Buy-initiated (ask-lifting) volume traded at this bucket.
|
||||
pub ask_vol: f64,
|
||||
}
|
||||
|
||||
/// The full footprint of a bar: one [`FootprintLevel`] per touched price
|
||||
/// bucket, sorted ascending by price.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct FootprintOutput {
|
||||
/// Touched price buckets, lowest price first.
|
||||
pub levels: Vec<FootprintLevel>,
|
||||
}
|
||||
|
||||
/// Footprint — the buy/sell volume profile of a bar, bucketed by price.
|
||||
///
|
||||
/// A footprint (a.k.a. bid/ask or volume cluster chart) decomposes the volume
|
||||
/// traded within a bar across the price levels at which it printed, splitting
|
||||
/// each level into buy-initiated (ask-lifting) and sell-initiated (bid-hitting)
|
||||
/// volume. It exposes *where* inside a bar the activity happened and which side
|
||||
/// was the aggressor there — the basis for absorption, imbalance and
|
||||
/// point-of-control analysis that a single OHLCV bar hides.
|
||||
///
|
||||
/// Each trade is assigned to the price bucket `round(price / tick_size)`; its
|
||||
/// size is added to that bucket's ask volume for a buy and bid volume for a
|
||||
/// sell. Every [`update`] returns the complete footprint accumulated since the
|
||||
/// last [`reset`], as a [`FootprintOutput`] whose `levels` are sorted ascending
|
||||
/// by price. Call [`reset`] at each bar (or session) boundary to start a fresh
|
||||
/// footprint.
|
||||
///
|
||||
/// `Input = Trade`, `Output = FootprintOutput`. Ready after the first trade.
|
||||
///
|
||||
/// [`update`]: crate::Indicator::update
|
||||
/// [`reset`]: crate::Indicator::reset
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Footprint, Indicator, Side, Trade};
|
||||
///
|
||||
/// let mut fp = Footprint::new(1.0).unwrap();
|
||||
/// fp.update(Trade::new(100.2, 2.0, Side::Buy, 0).unwrap());
|
||||
/// let out = fp.update(Trade::new(100.7, 3.0, Side::Sell, 1).unwrap()).unwrap();
|
||||
/// // Two buckets: 100 (ask 2) and 101 (bid 3).
|
||||
/// assert_eq!(out.levels.len(), 2);
|
||||
/// assert_eq!(out.levels[0].price, 100.0);
|
||||
/// assert_eq!(out.levels[0].ask_vol, 2.0);
|
||||
/// assert_eq!(out.levels[1].price, 101.0);
|
||||
/// assert_eq!(out.levels[1].bid_vol, 3.0);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Footprint {
|
||||
tick_size: f64,
|
||||
// bucket index -> (bid_vol = sell-initiated, ask_vol = buy-initiated).
|
||||
buckets: BTreeMap<i64, (f64, f64)>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl Footprint {
|
||||
/// Construct a footprint with the given price-bucket `tick_size`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidTick`] if `tick_size` is not a finite, strictly
|
||||
/// positive number.
|
||||
pub fn new(tick_size: f64) -> Result<Self> {
|
||||
if !tick_size.is_finite() || tick_size <= 0.0 {
|
||||
return Err(Error::InvalidTick {
|
||||
message: "footprint tick_size must be finite and positive",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
tick_size,
|
||||
buckets: BTreeMap::new(),
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The configured price-bucket size.
|
||||
pub const fn tick_size(&self) -> f64 {
|
||||
self.tick_size
|
||||
}
|
||||
|
||||
fn bucket_index(&self, price: f64) -> i64 {
|
||||
// Float-to-int `as` saturates rather than wrapping, so an extreme
|
||||
// price/tick ratio clamps to i64::MIN/MAX instead of misbehaving;
|
||||
// realistic ratios fit comfortably.
|
||||
#[allow(clippy::cast_possible_truncation)]
|
||||
{
|
||||
(price / self.tick_size).round() as i64
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> FootprintOutput {
|
||||
let levels = self
|
||||
.buckets
|
||||
.iter()
|
||||
.map(|(&index, &(bid_vol, ask_vol))| FootprintLevel {
|
||||
price: index as f64 * self.tick_size,
|
||||
bid_vol,
|
||||
ask_vol,
|
||||
})
|
||||
.collect();
|
||||
FootprintOutput { levels }
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for Footprint {
|
||||
type Input = Trade;
|
||||
type Output = FootprintOutput;
|
||||
|
||||
fn update(&mut self, trade: Trade) -> Option<FootprintOutput> {
|
||||
self.has_emitted = true;
|
||||
let index = self.bucket_index(trade.price);
|
||||
let entry = self.buckets.entry(index).or_insert((0.0, 0.0));
|
||||
if trade.side.sign() > 0.0 {
|
||||
entry.1 += trade.size;
|
||||
} else {
|
||||
entry.0 += trade.size;
|
||||
}
|
||||
Some(self.snapshot())
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.buckets.clear();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"Footprint"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::Side;
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn trade(price: f64, size: f64, side: Side) -> Trade {
|
||||
Trade::new(price, size, side, 0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_tick_size() {
|
||||
assert!(matches!(
|
||||
Footprint::new(0.0),
|
||||
Err(Error::InvalidTick { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Footprint::new(-1.0),
|
||||
Err(Error::InvalidTick { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
Footprint::new(f64::NAN),
|
||||
Err(Error::InvalidTick { .. })
|
||||
));
|
||||
assert!(Footprint::new(0.5).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let fp = Footprint::new(0.25).unwrap();
|
||||
assert_eq!(fp.name(), "Footprint");
|
||||
assert_eq!(fp.warmup_period(), 1);
|
||||
assert_eq!(fp.tick_size(), 0.25);
|
||||
assert!(!fp.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buckets_buy_and_sell_volume() {
|
||||
let mut fp = Footprint::new(1.0).unwrap();
|
||||
fp.update(trade(100.2, 2.0, Side::Buy));
|
||||
fp.update(trade(100.7, 3.0, Side::Sell));
|
||||
let out = fp.update(trade(100.1, 1.0, Side::Buy)).unwrap();
|
||||
assert!(fp.is_ready());
|
||||
// Bucket 100: buy 2 + buy 1 = ask 3, bid 0. Bucket 101: sell 3.
|
||||
assert_eq!(out.levels.len(), 2);
|
||||
assert_eq!(out.levels[0].price, 100.0);
|
||||
assert_eq!(out.levels[0].ask_vol, 3.0);
|
||||
assert_eq!(out.levels[0].bid_vol, 0.0);
|
||||
assert_eq!(out.levels[1].price, 101.0);
|
||||
assert_eq!(out.levels[1].bid_vol, 3.0);
|
||||
assert_eq!(out.levels[1].ask_vol, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn levels_sorted_ascending_by_price() {
|
||||
let mut fp = Footprint::new(1.0).unwrap();
|
||||
fp.update(trade(103.0, 1.0, Side::Buy));
|
||||
fp.update(trade(100.0, 1.0, Side::Sell));
|
||||
let out = fp.update(trade(101.0, 1.0, Side::Buy)).unwrap();
|
||||
let prices: Vec<f64> = out.levels.iter().map(|l| l.price).collect();
|
||||
assert_eq!(prices, vec![100.0, 101.0, 103.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sub_tick_prices_share_a_bucket() {
|
||||
let mut fp = Footprint::new(0.5).unwrap();
|
||||
// 100.24 and 100.26 both round to bucket 200 (price 100.0)... check:
|
||||
// 100.24/0.5 = 200.48 -> 200; 100.26/0.5 = 200.52 -> 201. Distinct.
|
||||
fp.update(trade(100.20, 1.0, Side::Buy)); // 200.4 -> 200 -> price 100.0
|
||||
let out = fp.update(trade(100.10, 2.0, Side::Buy)).unwrap(); // 200.2 -> 200
|
||||
assert_eq!(out.levels.len(), 1);
|
||||
assert_eq!(out.levels[0].price, 100.0);
|
||||
assert_eq!(out.levels[0].ask_vol, 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_the_footprint() {
|
||||
let mut fp = Footprint::new(1.0).unwrap();
|
||||
fp.update(trade(100.0, 5.0, Side::Buy));
|
||||
assert!(fp.is_ready());
|
||||
fp.reset();
|
||||
assert!(!fp.is_ready());
|
||||
let out = fp.update(trade(200.0, 1.0, Side::Sell)).unwrap();
|
||||
assert_eq!(out.levels.len(), 1);
|
||||
assert_eq!(out.levels[0].price, 200.0);
|
||||
assert_eq!(out.levels[0].bid_vol, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let trades: Vec<Trade> = (0..30)
|
||||
.map(|i| {
|
||||
let side = if i % 3 == 0 { Side::Sell } else { Side::Buy };
|
||||
trade(100.0 + f64::from(i % 5), 1.0 + f64::from(i % 4), side)
|
||||
})
|
||||
.collect();
|
||||
let mut a = Footprint::new(1.0).unwrap();
|
||||
let mut b = Footprint::new(1.0).unwrap();
|
||||
assert_eq!(
|
||||
a.batch(&trades),
|
||||
trades.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Kyle's Lambda — rolling price impact per unit of signed order flow.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::TradeQuote;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Kyle's Lambda — the rolling ordinary-least-squares slope of mid-price changes
|
||||
/// on signed trade volume, the canonical measure of market depth / price
|
||||
/// impact.
|
||||
///
|
||||
/// Each `update` receives a [`TradeQuote`] — a trade plus the mid prevailing at
|
||||
/// execution. Internally the indicator forms, per trade, the mid change since
|
||||
/// the previous trade (`Δmid = midₜ − midₜ₋₁`) and the signed volume
|
||||
/// (`q = size · D`, with `D` the aggressor sign), then runs a rolling OLS
|
||||
/// regression of `Δmid` on `q` over the trailing window of `window` trades:
|
||||
///
|
||||
/// ```text
|
||||
/// cov = (1/n) · Σ q·Δmid − q̄·Δ̄mid
|
||||
/// var = (1/n) · Σ q² − q̄²
|
||||
/// λ = cov / var
|
||||
/// ```
|
||||
///
|
||||
/// `λ` is the estimated price move per unit of signed volume: a deep, liquid
|
||||
/// book absorbs flow with little movement and reads a small `λ`; a thin book
|
||||
/// moves sharply per unit traded and reads a large `λ`. It is a direct,
|
||||
/// model-light proxy for the slope of the demand curve in Kyle's microstructure
|
||||
/// model.
|
||||
///
|
||||
/// Each `update` is O(1): four running sums (`Σq`, `ΣΔmid`, `Σq²`, `Σq·Δmid`)
|
||||
/// are maintained as the window slides. A window of constant signed volume has
|
||||
/// zero variance and `λ` is undefined; the indicator returns `0` in that case
|
||||
/// rather than producing `NaN`.
|
||||
///
|
||||
/// `Input = TradeQuote`, `Output = f64`. It warms up for `window + 1`
|
||||
/// trade-quotes: one to seed the previous mid, then `window` paired
|
||||
/// observations.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, KylesLambda, Side, Trade, TradeQuote};
|
||||
///
|
||||
/// // A book where each trade moves the mid by exactly 0.5 per unit of signed
|
||||
/// // volume gives λ = 0.5.
|
||||
/// let mut lambda = KylesLambda::new(8).unwrap();
|
||||
/// let mut mid = 100.0;
|
||||
/// let mut last = None;
|
||||
/// for i in 0..20 {
|
||||
/// let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
/// let size = 1.0 + f64::from(i % 3);
|
||||
/// let signed = size * side.sign();
|
||||
/// mid += 0.5 * signed;
|
||||
/// let trade = Trade::new(mid, size, side, 0).unwrap();
|
||||
/// last = lambda.update(TradeQuote::new(trade, mid).unwrap());
|
||||
/// }
|
||||
/// assert!((last.unwrap() - 0.5).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KylesLambda {
|
||||
window: usize,
|
||||
prev_mid: Option<f64>,
|
||||
pairs: VecDeque<(f64, f64)>,
|
||||
sum_q: f64,
|
||||
sum_dm: f64,
|
||||
sum_qq: f64,
|
||||
sum_qdm: f64,
|
||||
}
|
||||
|
||||
impl KylesLambda {
|
||||
/// Construct a rolling Kyle's lambda over `window` paired observations.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::InvalidPeriod`] if `window < 2` (the regression
|
||||
/// variance needs at least two observations).
|
||||
pub fn new(window: usize) -> Result<Self> {
|
||||
if window < 2 {
|
||||
return Err(Error::InvalidPeriod {
|
||||
message: "kyle's lambda needs window >= 2",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
window,
|
||||
prev_mid: None,
|
||||
pairs: VecDeque::with_capacity(window),
|
||||
sum_q: 0.0,
|
||||
sum_dm: 0.0,
|
||||
sum_qq: 0.0,
|
||||
sum_qdm: 0.0,
|
||||
})
|
||||
}
|
||||
|
||||
/// The configured window length, in paired observations.
|
||||
pub const fn window(&self) -> usize {
|
||||
self.window
|
||||
}
|
||||
|
||||
fn push_pair(&mut self, signed_vol: f64, delta_mid: f64) -> Option<f64> {
|
||||
if self.pairs.len() == self.window {
|
||||
let (old_q, old_dm) = self.pairs.pop_front().expect("non-empty");
|
||||
self.sum_q -= old_q;
|
||||
self.sum_dm -= old_dm;
|
||||
self.sum_qq -= old_q * old_q;
|
||||
self.sum_qdm -= old_q * old_dm;
|
||||
}
|
||||
self.pairs.push_back((signed_vol, delta_mid));
|
||||
self.sum_q += signed_vol;
|
||||
self.sum_dm += delta_mid;
|
||||
self.sum_qq += signed_vol * signed_vol;
|
||||
self.sum_qdm += signed_vol * delta_mid;
|
||||
if self.pairs.len() < self.window {
|
||||
return None;
|
||||
}
|
||||
let n = self.window as f64;
|
||||
let mean_q = self.sum_q / n;
|
||||
let mean_dm = self.sum_dm / n;
|
||||
let var_q = (self.sum_qq / n - mean_q * mean_q).max(0.0);
|
||||
let cov = self.sum_qdm / n - mean_q * mean_dm;
|
||||
if var_q == 0.0 {
|
||||
// Constant signed-volume window has no defined slope.
|
||||
return Some(0.0);
|
||||
}
|
||||
Some(cov / var_q)
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for KylesLambda {
|
||||
type Input = TradeQuote;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, quote: TradeQuote) -> Option<f64> {
|
||||
let mid = quote.mid;
|
||||
let signed_vol = quote.trade.size * quote.trade.side.sign();
|
||||
let Some(prev) = self.prev_mid else {
|
||||
self.prev_mid = Some(mid);
|
||||
return None;
|
||||
};
|
||||
self.prev_mid = Some(mid);
|
||||
self.push_pair(signed_vol, mid - prev)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.prev_mid = None;
|
||||
self.pairs.clear();
|
||||
self.sum_q = 0.0;
|
||||
self.sum_dm = 0.0;
|
||||
self.sum_qq = 0.0;
|
||||
self.sum_qdm = 0.0;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.window + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.pairs.len() == self.window
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"KylesLambda"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::{Side, Trade};
|
||||
use crate::traits::BatchExt;
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn quotes_with_impact(n: usize, impact: f64) -> Vec<TradeQuote> {
|
||||
let mut mid = 100.0;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
let size = 1.0 + (i % 3) as f64;
|
||||
let signed = size * side.sign();
|
||||
mid += impact * signed;
|
||||
let trade = Trade::new(mid, size, side, 0).unwrap();
|
||||
TradeQuote::new(trade, mid).unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_window_below_two() {
|
||||
assert!(KylesLambda::new(0).is_err());
|
||||
assert!(KylesLambda::new(1).is_err());
|
||||
assert!(KylesLambda::new(2).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let kl = KylesLambda::new(14).unwrap();
|
||||
assert_eq!(kl.name(), "KylesLambda");
|
||||
assert_eq!(kl.window(), 14);
|
||||
assert_eq!(kl.warmup_period(), 15);
|
||||
assert!(!kl.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_constant_impact_slope() {
|
||||
// mid moves exactly 0.5 per unit signed volume -> lambda = 0.5.
|
||||
let last = KylesLambda::new(6)
|
||||
.unwrap()
|
||||
.batch("es_with_impact(20, 0.5))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.5, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_impact_reads_negative() {
|
||||
let last = KylesLambda::new(6)
|
||||
.unwrap()
|
||||
.batch("es_with_impact(20, -0.3))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, -0.3, epsilon = 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_signed_volume_is_zero() {
|
||||
// Every trade is a buy of size 1: signed volume is constant -> var 0 -> 0.
|
||||
let mut mid = 100.0;
|
||||
let quotes: Vec<TradeQuote> = (0..10)
|
||||
.map(|_| {
|
||||
mid += 0.01;
|
||||
let trade = Trade::new(mid, 1.0, Side::Buy, 0).unwrap();
|
||||
TradeQuote::new(trade, mid).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let last = KylesLambda::new(5)
|
||||
.unwrap()
|
||||
.batch("es)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.last()
|
||||
.unwrap();
|
||||
assert_relative_eq!(last, 0.0, epsilon = 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warms_up_after_window_plus_one() {
|
||||
let mut kl = KylesLambda::new(3).unwrap();
|
||||
let quotes = quotes_with_impact(4, 0.2);
|
||||
assert_eq!(kl.update(quotes[0]), None); // seeds prev mid
|
||||
assert_eq!(kl.update(quotes[1]), None);
|
||||
assert_eq!(kl.update(quotes[2]), None);
|
||||
assert!(!kl.is_ready());
|
||||
assert!(kl.update(quotes[3]).is_some());
|
||||
assert!(kl.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let quotes = quotes_with_impact(40, 0.15);
|
||||
let batch = KylesLambda::new(10).unwrap().batch("es);
|
||||
let mut kl = KylesLambda::new(10).unwrap();
|
||||
let streamed: Vec<_> = quotes.iter().map(|q| kl.update(*q)).collect();
|
||||
assert_eq!(batch, streamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut kl = KylesLambda::new(3).unwrap();
|
||||
for q in quotes_with_impact(6, 0.2) {
|
||||
kl.update(q);
|
||||
}
|
||||
assert!(kl.is_ready());
|
||||
kl.reset();
|
||||
assert!(!kl.is_ready());
|
||||
assert_eq!(kl.update(quotes_with_impact(1, 0.2)[0]), None);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ mod decycler_oscillator;
|
||||
mod dema;
|
||||
mod demand_index;
|
||||
mod demark_pivots;
|
||||
mod depth_slope;
|
||||
mod detrended_std_dev;
|
||||
mod doji;
|
||||
mod donchian;
|
||||
@@ -62,6 +63,7 @@ mod double_bollinger;
|
||||
mod dpo;
|
||||
mod drawdown_duration;
|
||||
mod ease_of_movement;
|
||||
mod effective_spread;
|
||||
mod ehlers_stochastic;
|
||||
mod elder_impulse;
|
||||
mod ema;
|
||||
@@ -71,6 +73,7 @@ mod evwma;
|
||||
mod fama;
|
||||
mod fibonacci_pivots;
|
||||
mod fisher_transform;
|
||||
mod footprint;
|
||||
mod force_index;
|
||||
mod fractal_chaos_bands;
|
||||
mod frama;
|
||||
@@ -100,6 +103,7 @@ mod keltner;
|
||||
mod kst;
|
||||
mod kurtosis;
|
||||
mod kvo;
|
||||
mod kyles_lambda;
|
||||
mod laguerre_rsi;
|
||||
mod lead_lag_cross_correlation;
|
||||
mod linreg;
|
||||
@@ -144,6 +148,7 @@ mod psar;
|
||||
mod pvi;
|
||||
mod quoted_spread;
|
||||
mod r_squared;
|
||||
mod realized_spread;
|
||||
mod recovery_factor;
|
||||
mod relative_strength_ab;
|
||||
mod renko_trailing_stop;
|
||||
@@ -281,6 +286,7 @@ pub use decycler_oscillator::DecyclerOscillator;
|
||||
pub use dema::Dema;
|
||||
pub use demand_index::DemandIndex;
|
||||
pub use demark_pivots::{DemarkPivots, DemarkPivotsOutput};
|
||||
pub use depth_slope::DepthSlope;
|
||||
pub use detrended_std_dev::DetrendedStdDev;
|
||||
pub use doji::Doji;
|
||||
pub use donchian::{Donchian, DonchianOutput};
|
||||
@@ -289,6 +295,7 @@ pub use double_bollinger::{DoubleBollinger, DoubleBollingerOutput};
|
||||
pub use dpo::Dpo;
|
||||
pub use drawdown_duration::DrawdownDuration;
|
||||
pub use ease_of_movement::EaseOfMovement;
|
||||
pub use effective_spread::EffectiveSpread;
|
||||
pub use ehlers_stochastic::EhlersStochastic;
|
||||
pub use elder_impulse::ElderImpulse;
|
||||
pub use ema::Ema;
|
||||
@@ -298,6 +305,7 @@ pub use evwma::Evwma;
|
||||
pub use fama::Fama;
|
||||
pub use fibonacci_pivots::{FibonacciPivots, FibonacciPivotsOutput};
|
||||
pub use fisher_transform::FisherTransform;
|
||||
pub use footprint::{Footprint, FootprintLevel, FootprintOutput};
|
||||
pub use force_index::ForceIndex;
|
||||
pub use fractal_chaos_bands::{FractalChaosBands, FractalChaosBandsOutput};
|
||||
pub use frama::Frama;
|
||||
@@ -327,6 +335,7 @@ pub use keltner::{Keltner, KeltnerOutput};
|
||||
pub use kst::{Kst, KstOutput};
|
||||
pub use kurtosis::Kurtosis;
|
||||
pub use kvo::Kvo;
|
||||
pub use kyles_lambda::KylesLambda;
|
||||
pub use laguerre_rsi::LaguerreRsi;
|
||||
pub use lead_lag_cross_correlation::{LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput};
|
||||
pub use linreg::LinearRegression;
|
||||
@@ -371,6 +380,7 @@ pub use psar::Psar;
|
||||
pub use pvi::Pvi;
|
||||
pub use quoted_spread::QuotedSpread;
|
||||
pub use r_squared::RSquared;
|
||||
pub use realized_spread::RealizedSpread;
|
||||
pub use recovery_factor::RecoveryFactor;
|
||||
pub use relative_strength_ab::{RelativeStrengthAB, RelativeStrengthOutput};
|
||||
pub use renko_trailing_stop::RenkoTrailingStop;
|
||||
@@ -731,9 +741,14 @@ pub const FAMILIES: &[(&str, &[&str])] = &[
|
||||
"OrderBookImbalanceFull",
|
||||
"Microprice",
|
||||
"QuotedSpread",
|
||||
"DepthSlope",
|
||||
"SignedVolume",
|
||||
"CumulativeVolumeDelta",
|
||||
"TradeImbalance",
|
||||
"EffectiveSpread",
|
||||
"RealizedSpread",
|
||||
"KylesLambda",
|
||||
"Footprint",
|
||||
],
|
||||
),
|
||||
(
|
||||
@@ -790,6 +805,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, 222, "FAMILIES total drifted from indicator count");
|
||||
assert_eq!(total, 227, "FAMILIES total drifted from indicator count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Realized Spread — the post-trade liquidity revenue of a trade in basis
|
||||
//! points.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::microstructure::TradeQuote;
|
||||
use crate::traits::Indicator;
|
||||
|
||||
/// Realized Spread — twice the signed deviation of a trade price from the mid
|
||||
/// that prevails `horizon` trades *later*, expressed in basis points of the
|
||||
/// trade's contemporaneous mid.
|
||||
///
|
||||
/// ```text
|
||||
/// realizedSpread = 2 · D · (tradePrice − mid_{t+horizon}) / mid_t · 10_000 (bps)
|
||||
/// ```
|
||||
///
|
||||
/// where `D` is the aggressor sign (`+1` for a buy, `−1` for a sell), `mid_t`
|
||||
/// is the mid at the time of the trade, and `mid_{t+horizon}` is the mid
|
||||
/// `horizon` trade-quotes later. Where the [effective spread] measures the full
|
||||
/// cost paid by the aggressor against the contemporaneous mid, the realized
|
||||
/// spread measures the share of that cost a liquidity provider *keeps* after
|
||||
/// the mid has moved: it is the effective spread net of the price impact
|
||||
/// (`effective = realized + 2 · priceImpact`). A high realized spread means
|
||||
/// the quote was not picked off; a low or negative one is the signature of
|
||||
/// adverse selection, the trade preceding a move in its own direction.
|
||||
///
|
||||
/// The indicator buffers each incoming trade-quote and emits the realized
|
||||
/// spread for the trade made `horizon` updates ago, once that future mid is
|
||||
/// known. It warms up for `horizon + 1` trade-quotes — `update` returns `None`
|
||||
/// until the first trade can be resolved — and then emits one value per update
|
||||
/// in O(1).
|
||||
///
|
||||
/// `Input = TradeQuote`, `Output = f64`.
|
||||
///
|
||||
/// [effective spread]: crate::EffectiveSpread
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use wickra_core::{Indicator, RealizedSpread, Side, Trade, TradeQuote};
|
||||
///
|
||||
/// let mut rs = RealizedSpread::new(1).unwrap();
|
||||
/// let tq = |price: f64, side, mid| TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap();
|
||||
/// // First trade buffered; nothing to resolve yet.
|
||||
/// assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None);
|
||||
/// // One trade later the mid is 100.20, resolving the first buy:
|
||||
/// // 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps (adverse selection).
|
||||
/// let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap();
|
||||
/// assert!((out - (-20.0)).abs() < 1e-9);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RealizedSpread {
|
||||
horizon: usize,
|
||||
// Each pending entry is (aggressor sign, trade price, contemporaneous mid).
|
||||
pending: VecDeque<(f64, f64, f64)>,
|
||||
has_emitted: bool,
|
||||
}
|
||||
|
||||
impl RealizedSpread {
|
||||
/// Construct a realized-spread indicator that resolves each trade against
|
||||
/// the mid `horizon` trade-quotes later.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::PeriodZero`] if `horizon` is zero (the realized spread
|
||||
/// is defined against a strictly future mid).
|
||||
pub fn new(horizon: usize) -> Result<Self> {
|
||||
if horizon == 0 {
|
||||
return Err(Error::PeriodZero);
|
||||
}
|
||||
Ok(Self {
|
||||
horizon,
|
||||
pending: VecDeque::with_capacity(horizon + 1),
|
||||
has_emitted: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The configured horizon, in trade-quotes.
|
||||
pub const fn horizon(&self) -> usize {
|
||||
self.horizon
|
||||
}
|
||||
}
|
||||
|
||||
impl Indicator for RealizedSpread {
|
||||
type Input = TradeQuote;
|
||||
type Output = f64;
|
||||
|
||||
fn update(&mut self, quote: TradeQuote) -> Option<f64> {
|
||||
let sign = quote.trade.side.sign();
|
||||
self.pending.push_back((sign, quote.trade.price, quote.mid));
|
||||
if self.pending.len() <= self.horizon {
|
||||
return None;
|
||||
}
|
||||
let (old_sign, old_price, old_mid) = self.pending.pop_front().expect("len > horizon >= 1");
|
||||
self.has_emitted = true;
|
||||
// `quote.mid` is the mid prevailing `horizon` trades after the resolved
|
||||
// trade; normalise by that trade's own contemporaneous mid.
|
||||
Some(2.0 * old_sign * (old_price - quote.mid) / old_mid * 10_000.0)
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.pending.clear();
|
||||
self.has_emitted = false;
|
||||
}
|
||||
|
||||
fn warmup_period(&self) -> usize {
|
||||
self.horizon + 1
|
||||
}
|
||||
|
||||
fn is_ready(&self) -> bool {
|
||||
self.has_emitted
|
||||
}
|
||||
|
||||
fn name(&self) -> &'static str {
|
||||
"RealizedSpread"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::microstructure::{Side, Trade};
|
||||
use crate::traits::BatchExt;
|
||||
|
||||
fn tq(price: f64, side: Side, mid: f64) -> TradeQuote {
|
||||
TradeQuote::new(Trade::new(price, 1.0, side, 0).unwrap(), mid).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_horizon() {
|
||||
assert!(matches!(RealizedSpread::new(0), Err(Error::PeriodZero)));
|
||||
assert!(RealizedSpread::new(1).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accessors_and_metadata() {
|
||||
let rs = RealizedSpread::new(3).unwrap();
|
||||
assert_eq!(rs.name(), "RealizedSpread");
|
||||
assert_eq!(rs.horizon(), 3);
|
||||
assert_eq!(rs.warmup_period(), 4);
|
||||
assert!(!rs.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_against_future_mid() {
|
||||
let mut rs = RealizedSpread::new(1).unwrap();
|
||||
assert_eq!(rs.update(tq(100.10, Side::Buy, 100.0)), None);
|
||||
assert!(!rs.is_ready());
|
||||
// 2 · (+1) · (100.10 − 100.20) / 100.0 · 10_000 = −20 bps.
|
||||
let out = rs.update(tq(99.90, Side::Sell, 100.20)).unwrap();
|
||||
assert!((out - (-20.0)).abs() < 1e-9);
|
||||
assert!(rs.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_adverse_move_equals_effective_spread() {
|
||||
// If the mid does not move over the horizon, realized == effective.
|
||||
let mut rs = RealizedSpread::new(1).unwrap();
|
||||
rs.update(tq(100.05, Side::Buy, 100.0));
|
||||
// mid stays at 100.0 -> 2 · (100.05 − 100.0) / 100.0 · 10_000 = 10 bps.
|
||||
let out = rs.update(tq(100.0, Side::Buy, 100.0)).unwrap();
|
||||
assert!((out - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_horizon_warms_up() {
|
||||
let mut rs = RealizedSpread::new(3).unwrap();
|
||||
for _ in 0..3 {
|
||||
assert_eq!(rs.update(tq(100.0, Side::Buy, 100.0)), None);
|
||||
}
|
||||
assert!(!rs.is_ready());
|
||||
assert!(rs.update(tq(100.0, Side::Buy, 100.0)).is_some());
|
||||
assert!(rs.is_ready());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_equals_streaming() {
|
||||
let quotes: Vec<TradeQuote> = (0..30)
|
||||
.map(|i| {
|
||||
let side = if i % 2 == 0 { Side::Buy } else { Side::Sell };
|
||||
let mid = 100.0 + f64::from(i % 5) * 0.05;
|
||||
tq(mid + 0.02, side, mid)
|
||||
})
|
||||
.collect();
|
||||
let mut a = RealizedSpread::new(4).unwrap();
|
||||
let mut b = RealizedSpread::new(4).unwrap();
|
||||
assert_eq!(
|
||||
a.batch("es),
|
||||
quotes.iter().map(|x| b.update(*x)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_state() {
|
||||
let mut rs = RealizedSpread::new(1).unwrap();
|
||||
rs.update(tq(100.05, Side::Buy, 100.0));
|
||||
rs.update(tq(100.0, Side::Buy, 100.0));
|
||||
assert!(rs.is_ready());
|
||||
rs.reset();
|
||||
assert!(!rs.is_ready());
|
||||
assert_eq!(rs.update(tq(100.05, Side::Buy, 100.0)), None);
|
||||
}
|
||||
}
|
||||
@@ -55,16 +55,17 @@ pub use indicators::{
|
||||
ChandelierExitOutput, ChoppinessIndex, ClassicPivots, ClassicPivotsOutput, Cmo,
|
||||
CoefficientOfVariation, Cointegration, CointegrationOutput, ConditionalValueAtRisk, ConnorsRsi,
|
||||
Coppock, CumulativeVolumeDelta, CyberneticCycle, Decycler, DecyclerOscillator, Dema,
|
||||
DemandIndex, DemarkPivots, DemarkPivotsOutput, DetrendedStdDev, Doji, Donchian, DonchianOutput,
|
||||
DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo,
|
||||
DrawdownDuration, EaseOfMovement, EhlersStochastic, ElderImpulse, Ema,
|
||||
DemandIndex, DemarkPivots, DemarkPivotsOutput, DepthSlope, DetrendedStdDev, Doji, Donchian,
|
||||
DonchianOutput, DonchianStop, DonchianStopOutput, DoubleBollinger, DoubleBollingerOutput, Dpo,
|
||||
DrawdownDuration, EaseOfMovement, EffectiveSpread, EhlersStochastic, ElderImpulse, Ema,
|
||||
EmpiricalModeDecomposition, Engulfing, Evwma, Fama, FibonacciPivots, FibonacciPivotsOutput,
|
||||
FisherTransform, ForceIndex, FractalChaosBands, FractalChaosBandsOutput, Frama, GainLossRatio,
|
||||
GarmanKlassVolatility, Hammer, HangingMan, Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator,
|
||||
HilbertDominantCycle, HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput,
|
||||
HurstExponent, Ichimoku, IchimokuOutput, Inertia, InformationRatio, InitialBalance,
|
||||
InitialBalanceOutput, InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma,
|
||||
Kama, KellyCriterion, Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, LaguerreRsi,
|
||||
FisherTransform, Footprint, FootprintOutput, ForceIndex, FractalChaosBands,
|
||||
FractalChaosBandsOutput, Frama, GainLossRatio, GarmanKlassVolatility, Hammer, HangingMan,
|
||||
Harami, HeikinAshi, HeikinAshiOutput, HiLoActivator, HilbertDominantCycle,
|
||||
HistoricalVolatility, Hma, HurstChannel, HurstChannelOutput, HurstExponent, Ichimoku,
|
||||
IchimokuOutput, Inertia, InformationRatio, InitialBalance, InitialBalanceOutput,
|
||||
InstantaneousTrendline, InverseFisherTransform, InvertedHammer, Jma, Kama, KellyCriterion,
|
||||
Keltner, KeltnerOutput, Kst, KstOutput, Kurtosis, Kvo, KylesLambda, LaguerreRsi,
|
||||
LeadLagCrossCorrelation, LeadLagCrossCorrelationOutput, LinRegAngle, LinRegChannel,
|
||||
LinRegChannelOutput, LinRegSlope, LinearRegression, MaEnvelope, MaEnvelopeOutput,
|
||||
MacdIndicator, MacdOutput, Mama, MamaOutput, MarketFacilitationIndex, Marubozu, MassIndex,
|
||||
@@ -73,23 +74,27 @@ pub use indicators::{
|
||||
OrderBookImbalanceFull, OrderBookImbalanceTop1, OrderBookImbalanceTopN, PainIndex,
|
||||
PairSpreadZScore, PairwiseBeta, ParkinsonVolatility, PearsonCorrelation, PercentB,
|
||||
PercentageTrailingStop, Pgo, PiercingDarkCloud, Pmo, Ppo, ProfitFactor, Psar, Pvi,
|
||||
QuotedSpread, RSquared, RecoveryFactor, RelativeStrengthAB, RelativeStrengthOutput,
|
||||
RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap, RoofingFilter, Rsi, Rvi,
|
||||
RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar, SignedVolume, SineWave, Skewness,
|
||||
Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation, SpinningTop, StandardError,
|
||||
StandardErrorBands, StandardErrorBandsOutput, StarcBands, StarcBandsOutput, Stc, StdDev,
|
||||
StepTrailingStop, StochRsi, Stochastic, StochasticOutput, SuperSmoother, SuperTrend,
|
||||
SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential, TdLines, TdLinesOutput,
|
||||
TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei, TdRiskLevel,
|
||||
TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside, ThreeOutside,
|
||||
ThreeSoldiersOrCrows, Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange, Tsi, Tsv,
|
||||
TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator, ValueArea,
|
||||
ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
|
||||
QuotedSpread, RSquared, RealizedSpread, RecoveryFactor, RelativeStrengthAB,
|
||||
RelativeStrengthOutput, RenkoTrailingStop, Roc, RogersSatchellVolatility, RollingVwap,
|
||||
RoofingFilter, Rsi, Rvi, RviVolatility, Rwi, RwiOutput, SharpeRatio, ShootingStar,
|
||||
SignedVolume, SineWave, Skewness, Sma, Smi, Smma, SortinoRatio, SpearmanCorrelation,
|
||||
SpinningTop, StandardError, StandardErrorBands, StandardErrorBandsOutput, StarcBands,
|
||||
StarcBandsOutput, Stc, StdDev, StepTrailingStop, StochRsi, Stochastic, StochasticOutput,
|
||||
SuperSmoother, SuperTrend, SuperTrendOutput, TdCombo, TdCountdown, TdDeMarker, TdDifferential,
|
||||
TdLines, TdLinesOutput, TdOpen, TdPressure, TdRangeProjection, TdRangeProjectionOutput, TdRei,
|
||||
TdRiskLevel, TdRiskLevelOutput, TdSequential, TdSequentialOutput, TdSetup, Tema, ThreeInside,
|
||||
ThreeOutside, ThreeSoldiersOrCrows, Tii, TradeImbalance, TreynorRatio, Trima, Trix, TrueRange,
|
||||
Tsi, Tsv, TtmSqueeze, TtmSqueezeOutput, Tweezer, TypicalPrice, UlcerIndex, UltimateOscillator,
|
||||
ValueArea, ValueAreaOutput, ValueAtRisk, Variance, VerticalHorizontalFilter, Vidya, VoltyStop,
|
||||
VolumeOscillator, VolumePriceTrend, Vortex, VortexOutput, Vwap, VwapStdDevBands,
|
||||
VwapStdDevBandsOutput, Vwma, Vzo, WaveTrend, WaveTrendOutput, WeightedClose, WilliamsFractals,
|
||||
WilliamsFractalsOutput, WilliamsR, Wma, WoodiePivots, WoodiePivotsOutput, YangZhangVolatility,
|
||||
YoyoExit, ZScore, ZeroLagMacd, ZeroLagMacdOutput, ZigZag, ZigZagOutput, Zlema, FAMILIES, T3,
|
||||
};
|
||||
// `FootprintLevel` is a row element of `FootprintOutput`, re-exported on its own
|
||||
// line so the indicator-count tooling (which scans the braced block above and
|
||||
// strips only `*Output` companions) does not count it as a separate indicator.
|
||||
pub use indicators::FootprintLevel;
|
||||
pub use microstructure::{Level, OrderBook, Side, Trade, TradeQuote};
|
||||
pub use ohlcv::{Candle, Tick};
|
||||
pub use traits::{BatchExt, Chain, Indicator};
|
||||
|
||||
@@ -33,14 +33,14 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Through
|
||||
use std::hint::black_box;
|
||||
use wickra::{
|
||||
Adx, Atr, Autocorrelation, BatchExt, BollingerBands, BollingerOutput, CalmarRatio, Candle, Cci,
|
||||
ClassicPivots, ConnorsRsi, Ema, EmpiricalModeDecomposition, Engulfing, Frama,
|
||||
HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator, Jma, Level,
|
||||
LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput, MaxDrawdown, Microprice, Obv,
|
||||
OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1, ParkinsonVolatility, Ppo, Psar,
|
||||
RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc, SuperTrend, SuperTrendOutput,
|
||||
TdSequential, TdSequentialOutput, Trade, TradeImbalance, TtmSqueeze, TtmSqueezeOutput,
|
||||
ValueArea, ValueAreaOutput, ValueAtRisk, Vwap, VwapStdDevBands, VwapStdDevBandsOutput,
|
||||
WaveTrend, YangZhangVolatility, T3,
|
||||
ClassicPivots, ConnorsRsi, DepthSlope, EffectiveSpread, Ema, EmpiricalModeDecomposition,
|
||||
Engulfing, Frama, HilbertDominantCycle, HurstExponent, Ichimoku, IchimokuOutput, Indicator,
|
||||
Jma, KylesLambda, Level, LinearRegression, MacdIndicator, MacdOutput, Mama, MamaOutput,
|
||||
MaxDrawdown, Microprice, Obv, OrderBook, OrderBookImbalanceFull, OrderBookImbalanceTop1,
|
||||
ParkinsonVolatility, Ppo, Psar, RollingVwap, Rsi, SharpeRatio, Side, SignedVolume, Sma, Stc,
|
||||
SuperTrend, SuperTrendOutput, TdSequential, TdSequentialOutput, Trade, TradeImbalance,
|
||||
TradeQuote, TtmSqueeze, TtmSqueezeOutput, ValueArea, ValueAreaOutput, ValueAtRisk, Vwap,
|
||||
VwapStdDevBands, VwapStdDevBandsOutput, WaveTrend, YangZhangVolatility, T3,
|
||||
};
|
||||
use wickra_data::csv::CandleReader;
|
||||
|
||||
@@ -159,6 +159,28 @@ where
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_tradequote_input<I, F, O>(c: &mut Criterion, name: &str, quotes: &[TradeQuote], make: F)
|
||||
where
|
||||
F: Fn() -> I,
|
||||
I: Indicator<Input = TradeQuote, Output = O>,
|
||||
{
|
||||
let mut group = c.benchmark_group(name);
|
||||
for &n in SIZES {
|
||||
let n = n.min(quotes.len());
|
||||
let series = "es[..n];
|
||||
group.throughput(Throughput::Elements(n as u64));
|
||||
group.bench_with_input(BenchmarkId::new("streaming", n), series, |b, quotes| {
|
||||
b.iter(|| {
|
||||
let mut ind = make();
|
||||
for q in quotes {
|
||||
black_box(ind.update(*q));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
group.finish();
|
||||
}
|
||||
|
||||
fn bench_scalar_multi<I, F, O>(c: &mut Criterion, name: &str, prices: &[f64], make: F)
|
||||
where
|
||||
F: Fn() -> I,
|
||||
@@ -332,6 +354,7 @@ fn benches(c: &mut Criterion) {
|
||||
bench_orderbook_input(c, "ob_imbalance_top1", &books, OrderBookImbalanceTop1::new);
|
||||
bench_orderbook_input(c, "ob_imbalance_full", &books, OrderBookImbalanceFull::new);
|
||||
bench_orderbook_input(c, "microprice", &books, Microprice::new);
|
||||
bench_orderbook_input(c, "depth_slope", &books, DepthSlope::new);
|
||||
|
||||
// Synthesise a trade tape from candles: one trade per bar, sided by the
|
||||
// candle's direction. SignedVolume is the cheapest; TradeImbalance carries
|
||||
@@ -351,6 +374,16 @@ fn benches(c: &mut Criterion) {
|
||||
bench_trade_input(c, "trade_imbalance", &trades, || {
|
||||
TradeImbalance::new(50).unwrap()
|
||||
});
|
||||
|
||||
// Pair each synthetic trade with the candle close as the prevailing mid to
|
||||
// exercise the price-impact family. EffectiveSpread is the stateless
|
||||
// representative.
|
||||
let quotes: Vec<TradeQuote> = trades
|
||||
.iter()
|
||||
.map(|trade| TradeQuote::new_unchecked(*trade, trade.price))
|
||||
.collect();
|
||||
bench_tradequote_input(c, "effective_spread", "es, EffectiveSpread::new);
|
||||
bench_tradequote_input(c, "kyles_lambda", "es, || KylesLambda::new(50).unwrap());
|
||||
}
|
||||
|
||||
criterion_group!(name = wickra_benches; config = Criterion::default(); targets = benches);
|
||||
|
||||
Generated
+7
-7
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"../../bindings/node": {
|
||||
"name": "wickra",
|
||||
"version": "0.4.2",
|
||||
"version": "0.4.3",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"devDependencies": {
|
||||
"@napi-rs/cli": "^2.18.0"
|
||||
@@ -26,12 +26,12 @@
|
||||
"node": ">= 18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"wickra-darwin-arm64": "0.4.2",
|
||||
"wickra-darwin-x64": "0.4.2",
|
||||
"wickra-linux-arm64-gnu": "0.4.2",
|
||||
"wickra-linux-x64-gnu": "0.4.2",
|
||||
"wickra-win32-arm64-msvc": "0.4.2",
|
||||
"wickra-win32-x64-msvc": "0.4.2"
|
||||
"wickra-darwin-arm64": "0.4.3",
|
||||
"wickra-darwin-x64": "0.4.3",
|
||||
"wickra-linux-arm64-gnu": "0.4.3",
|
||||
"wickra-linux-x64-gnu": "0.4.3",
|
||||
"wickra-win32-arm64-msvc": "0.4.3",
|
||||
"wickra-win32-x64-msvc": "0.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/wickra": {
|
||||
|
||||
@@ -66,6 +66,13 @@ test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "indicator_update_tradequote"
|
||||
path = "fuzz_targets/indicator_update_tradequote.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "tick_aggregator"
|
||||
path = "fuzz_targets/tick_aggregator.rs"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{
|
||||
BatchExt, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull,
|
||||
BatchExt, DepthSlope, Indicator, Level, Microprice, OrderBook, OrderBookImbalanceFull,
|
||||
OrderBookImbalanceTop1, OrderBookImbalanceTopN, QuotedSpread,
|
||||
};
|
||||
|
||||
@@ -51,4 +51,5 @@ fuzz_target!(|data: &[u8]| {
|
||||
drive(OrderBookImbalanceFull::new, &books);
|
||||
drive(Microprice::new, &books);
|
||||
drive(QuotedSpread::new, &books);
|
||||
drive(DepthSlope::new, &books);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{
|
||||
BatchExt, CumulativeVolumeDelta, Indicator, Side, SignedVolume, Trade, TradeImbalance,
|
||||
BatchExt, CumulativeVolumeDelta, Footprint, Indicator, Side, SignedVolume, Trade,
|
||||
TradeImbalance,
|
||||
};
|
||||
|
||||
#[inline(never)]
|
||||
@@ -42,4 +43,13 @@ fuzz_target!(|data: &[u8]| {
|
||||
drive(SignedVolume::new, &trades);
|
||||
drive(CumulativeVolumeDelta::new, &trades);
|
||||
drive(|| TradeImbalance::new(5).unwrap(), &trades);
|
||||
|
||||
// Footprint emits a variable-length `FootprintOutput` rather than an `f64`,
|
||||
// so it is driven directly rather than through the scalar-output helper.
|
||||
let mut footprint = Footprint::new(0.5).unwrap();
|
||||
for &trade in &trades {
|
||||
let _ = footprint.update(trade);
|
||||
}
|
||||
footprint.reset();
|
||||
let _ = Footprint::new(0.5).unwrap().batch(&trades);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
#![no_main]
|
||||
//! Fuzz price-impact `Indicator<Input = TradeQuote>` implementations with
|
||||
//! arbitrary trade-quote tapes.
|
||||
//!
|
||||
//! Each iteration consumes a byte stream, interprets it as a sequence of `f64`
|
||||
//! values (8 bytes each), and packs consecutive triples into `(price, size,
|
||||
//! mid)` trade-quotes whose aggressor side alternates with the sign of the size
|
||||
//! field. Trade-quotes are built with the `new_unchecked` constructors so the
|
||||
//! fuzzer can explore degenerate values (non-finite, negative, zero mid) that
|
||||
//! the validating constructors would reject — the indicators must never panic,
|
||||
//! streaming or batched.
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use wickra_core::{
|
||||
BatchExt, EffectiveSpread, Indicator, KylesLambda, RealizedSpread, Side, Trade, TradeQuote,
|
||||
};
|
||||
|
||||
#[inline(never)]
|
||||
fn drive<I>(make: impl Fn() -> I, quotes: &[TradeQuote])
|
||||
where
|
||||
I: Indicator<Input = TradeQuote, Output = f64> + BatchExt,
|
||||
{
|
||||
let mut streaming = make();
|
||||
for "e in quotes {
|
||||
let _ = streaming.update(quote);
|
||||
}
|
||||
let _ = make().batch(quotes);
|
||||
}
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let floats: Vec<f64> = data
|
||||
.chunks_exact(8)
|
||||
.map(|c| f64::from_le_bytes(c.try_into().expect("8 bytes")))
|
||||
.collect();
|
||||
let quotes: Vec<TradeQuote> = floats
|
||||
.chunks_exact(3)
|
||||
.map(|c| {
|
||||
let side = if c[1] >= 0.0 { Side::Buy } else { Side::Sell };
|
||||
let trade = Trade::new_unchecked(c[0], c[1], side, 0);
|
||||
TradeQuote::new_unchecked(trade, c[2])
|
||||
})
|
||||
.collect();
|
||||
|
||||
drive(EffectiveSpread::new, "es);
|
||||
drive(|| RealizedSpread::new(5).unwrap(), "es);
|
||||
drive(|| KylesLambda::new(5).unwrap(), "es);
|
||||
});
|
||||
Reference in New Issue
Block a user