digdigdig3_station/series/key.rs
1use std::time::Duration;
2
3use digdigdig3::core::types::{AccountType, ExchangeId};
4use digdigdig3::core::websocket::KlineInterval;
5
6/// Upstream data source for [`Kind::TpoProfile`]. The TPO algorithm itself
7/// is identical in both modes — the only thing that changes is the input
8/// channel feeding the letter-bucket accumulator.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum TpoSource {
11 /// Subscribe to `Stream::Trade`; bucket trades directly into the
12 /// letter-period windows. High-frequency, sub-tick precision.
13 TradeBucket,
14 /// Subscribe to `Stream::Kline(1m)`; resample 1-minute OHLC bars
15 /// into `freq_minutes`-wide letter periods. Canonical reference
16 /// algorithm (sivamgr, py-market-profile).
17 Kline1m,
18}
19
20/// What kind of stream this series carries.
21///
22/// `Kline` carries a typed `KlineInterval` so different timeframes of the
23/// same symbol get their own series. All other kinds have no extra parameter.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub enum Kind {
26 Trade,
27 AggTrade,
28 Kline(KlineInterval),
29 Ticker,
30 Orderbook,
31 OrderbookDelta,
32 MarkPrice,
33 FundingRate,
34 OpenInterest,
35 Liquidation,
36 // --- extended stream types ---
37 BlockTrade,
38 AuctionEvent,
39 IndexPrice,
40 CompositeIndex,
41 OptionGreeks,
42 VolatilityIndex,
43 HistoricalVolatility,
44 LongShortRatio,
45 TakerVolume,
46 LiquidationBucket,
47 Basis,
48 InsuranceFund,
49 OrderbookL3,
50 SettlementEvent,
51 MarketWarning,
52 RiskLimit,
53 PredictedFunding,
54 FundingSettlement,
55 MarkPriceKline(KlineInterval),
56 IndexPriceKline(KlineInterval),
57 PremiumIndexKline(KlineInterval),
58 // --- derived bar aggregators (always computed from Stream::Trade) ---
59 /// Range bar: a new bar opens when |trade.price − bar_open| ≥ range.
60 ///
61 /// `range` is expressed as a **fixed-point integer: price × 1e8**.
62 /// Example: a $1.00 range on a dollar-denominated pair = `100_000_000u64`.
63 /// This avoids carrying floats in `Hash`/`Eq` while keeping the unit
64 /// explicit and independent of the minimum exchange tick.
65 RangeBar(u64),
66 /// Tick bar: a new bar closes every `n` trades.
67 TickBar(u32),
68 /// Volume bar: a new bar closes when cumulative volume ≥ threshold.
69 ///
70 /// `threshold` is expressed as a **fixed-point integer: volume × 1e8**.
71 /// Example: 0.5 BTC threshold = `50_000_000u64`.
72 VolumeBar(u64),
73 /// Footprint bar: time-bucketed OHLCV with per-price buy/sell breakdown.
74 ///
75 /// Reuses `KlineInterval` for the time bucket (e.g. `"1m"`, `"5m"`).
76 Footprint(KlineInterval),
77 /// Renko brick: emits one fixed-height brick per `box_size` move in the
78 /// current direction; reversal requires `reversal_count` × box_size of
79 /// opposing movement and consumes one brick on the flip (mplfinance
80 /// gap-fill rule). Box size is **price × 1e8** fixed-point, like
81 /// [`Kind::RangeBar`]. Output is `BarPoint` shaped as a brick:
82 /// `(open, close) = (brick_bottom, brick_top)` for up bricks,
83 /// `(brick_top, brick_bottom)` for down bricks; `high == max`, `low ==
84 /// min`, `volume` = accumulated trade volume across the brick.
85 RenkoBar(u64, u8),
86 /// Point & Figure column: X-column (rising) or O-column (falling). Box
87 /// size is **price × 1e8** fixed-point. Reversal requires
88 /// `reversal_count × box_size` of opposing movement. Emits one
89 /// `BarPoint` per closed column (open=column bottom, close=column top
90 /// for X / vice-versa for O; high/low = full extent; volume = total).
91 /// Direction encoded into the **sign of `trades_count`** at emit: X
92 /// columns have positive `trades_count`, O columns negative — caller
93 /// reads sign to discriminate without losing the count.
94 PnfBar(u64, u8),
95 /// Kagi segment: a single up- or down-segment of the Kagi polyline.
96 /// Reversal threshold expressed as **price × 1e8** fixed-point.
97 /// Output is a dedicated `KagiSegmentPoint` (NOT `BarPoint` — segments
98 /// carry yang/yin thickness + connector flag that don't fit OHLCV
99 /// semantics).
100 KagiBar(u64),
101 /// Dollar bar (López de Prado, AFML ch.2): close bar when cumulative
102 /// `trade.price * trade.quantity` since last close ≥ `dollar_threshold`.
103 ///
104 /// `dollar_threshold` is encoded as **whole dollars** (integer). For
105 /// cent-precision, callers may encode as `dollars * 100` and document
106 /// the convention in their call site. Stored as `u64` to satisfy `Hash`.
107 DollarBar { dollar_threshold: u64 },
108 /// Tick Imbalance Bar (López de Prado, AFML ch.2): track running tick
109 /// imbalance `θ = Σ b_t` where `b_t = +1` (buyer-initiated) or `-1`
110 /// (seller-initiated). Close when `|θ| ≥ E[|θ|] × E[T]`.
111 ///
112 /// `alpha_x100`: EMA smoothing factor × 100 (e.g. 20 → 0.20).
113 /// `min_ticks`: sanity floor for the tick-count threshold.
114 TickImbalanceBar { alpha_x100: u16, min_ticks: u32 },
115 /// Volume Imbalance Bar (López de Prado, AFML ch.2): same as
116 /// [`Kind::TickImbalanceBar`] but `b_t * trade.quantity` (signed
117 /// volume) instead of unit tick.
118 VolumeImbalanceBar { alpha_x100: u16, min_ticks: u32 },
119 /// Run Bar (López de Prado, AFML ch.2): track the length of the current
120 /// buy-run and sell-run simultaneously. Close when
121 /// `max(run_buy_len, run_sell_len) ≥ E[run] × E[T]`.
122 RunBar { alpha_x100: u16, min_ticks: u32 },
123 /// Cumulative Volume Delta line: a scalar series. Each `Trade` event
124 /// emits `prev_cvd + (signed_qty)` where the sign comes from
125 /// `TradePoint.is_buyer_maker`. Output is `ScalarBarPoint { ts_ms,
126 /// value }`.
127 CvdLine,
128 /// Three Line Break (san-sen-ashi): index-axis chart derived from a
129 /// trade stream. A new line is drawn only when the closing price
130 /// exceeds the high or low of the last `lines_back` lines (default 3).
131 /// Reversals require crossing the high/low of the last `lines_back`
132 /// lines in the opposing direction — small pullbacks are filtered out.
133 /// Output is `ThreeLineBreakLinePoint`.
134 ThreeLineBreak { lines_back: u8 },
135 /// TPO Market Profile: session-aggregated letter chart. `freq_minutes`
136 /// controls the letter bucket size (industry default = 30). `source`
137 /// selects the data path:
138 ///
139 /// - [`TpoSource::TradeBucket`] — subscribes to `Stream::Trade` and
140 /// builds the letter buckets directly from the live tick stream.
141 /// Faster to start (no kline backfill), lower latency to first
142 /// profile update, sub-tick precision on high/low.
143 /// - [`TpoSource::Kline1m`] — subscribes to `Stream::Kline(1m)` and
144 /// resamples 1-minute OHLC bars into `freq_minutes` letter
145 /// buckets. Matches the canonical sivamgr / py-market-profile
146 /// reference implementations. Cheaper at steady state on
147 /// high-volume symbols (one event per minute vs N trades / sec).
148 ///
149 /// Both modes emit the same `TpoSessionPoint` shape.
150 TpoProfile(u16, TpoSource),
151 // --- private (auth-required) stream types ---
152 /// Order lifecycle events (create/fill/cancel/expire). Auth-required.
153 OrderUpdate,
154 /// Account balance changes. Auth-required.
155 BalanceUpdate,
156 /// Futures position changes. Auth-required.
157 PositionUpdate,
158}
159
160/// Polling cadence + anti-alignment jitter for REST-only stream kinds.
161///
162/// Returned by [`Kind::is_poll_only`] for kinds that have no WS feed and
163/// must be driven by periodic REST calls.
164#[derive(Debug, Clone, Copy)]
165pub struct PollSpec {
166 /// How often to call the REST endpoint.
167 pub cadence: Duration,
168 /// Jitter applied to the FIRST tick only, expressed as percent of cadence.
169 /// Prevents N symbols × M exchanges all calling REST at the same wall-clock
170 /// second. Value 10 means first tick fires at `cadence ± (cadence * 10 / 100)`.
171 pub jitter_pct: u8,
172}
173
174impl Kind {
175 /// True for stream kinds that are computed inside Station from upstream
176 /// WS-backed streams, rather than arriving directly from an exchange WS.
177 ///
178 /// Derived kinds bypass the `ws.subscribe(req)` path in `acquire_or_spawn`
179 /// and instead use `acquire_or_spawn_derived<D>(...)`.
180 pub(crate) fn is_derived(&self) -> bool {
181 matches!(
182 self,
183 Kind::Basis
184 | Kind::FundingSettlement
185 | Kind::RangeBar(_)
186 | Kind::TickBar(_)
187 | Kind::VolumeBar(_)
188 | Kind::Footprint(_)
189 | Kind::RenkoBar(_, _)
190 | Kind::PnfBar(_, _)
191 | Kind::KagiBar(_)
192 | Kind::CvdLine
193 | Kind::ThreeLineBreak { .. }
194 | Kind::TpoProfile(_, _)
195 | Kind::DollarBar { .. }
196 | Kind::TickImbalanceBar { .. }
197 | Kind::VolumeImbalanceBar { .. }
198 | Kind::RunBar { .. }
199 )
200 }
201
202 /// If this kind has no WS feed and must be driven by REST polling,
203 /// returns the default cadence + jitter spec.
204 ///
205 /// Poll-only kinds bypass `ws.subscribe` entirely in `acquire_or_spawn`
206 /// and instead use the `spawn_poller` actor path.
207 pub fn is_poll_only(&self) -> Option<PollSpec> {
208 match self {
209 Kind::LongShortRatio => Some(PollSpec {
210 cadence: Duration::from_secs(5 * 60), // 5 min bucket cadence
211 jitter_pct: 10,
212 }),
213 Kind::TakerVolume => Some(PollSpec {
214 cadence: Duration::from_secs(5 * 60),
215 jitter_pct: 10,
216 }),
217 Kind::LiquidationBucket => Some(PollSpec {
218 cadence: Duration::from_secs(5 * 60),
219 jitter_pct: 10,
220 }),
221 Kind::HistoricalVolatility => Some(PollSpec {
222 cadence: Duration::from_secs(60 * 60), // 1 h Deribit update cadence
223 jitter_pct: 5,
224 }),
225 _ => None,
226 }
227 }
228
229 /// Short kebab-case label for filesystem paths.
230 pub fn slug(&self) -> String {
231 match self {
232 Kind::Trade => "trades".to_string(),
233 Kind::AggTrade => "agg_trades".to_string(),
234 Kind::Kline(iv) => format!("klines_{}", iv.as_str()),
235 Kind::Ticker => "tickers".to_string(),
236 Kind::Orderbook => "orderbook_snapshots".to_string(),
237 Kind::OrderbookDelta => "orderbook_deltas".to_string(),
238 Kind::MarkPrice => "mark_price".to_string(),
239 Kind::FundingRate => "funding_rate".to_string(),
240 Kind::OpenInterest => "open_interest".to_string(),
241 Kind::Liquidation => "liquidations".to_string(),
242 Kind::BlockTrade => "block_trades".to_string(),
243 Kind::AuctionEvent => "auction_events".to_string(),
244 Kind::IndexPrice => "index_price".to_string(),
245 Kind::CompositeIndex => "composite_index".to_string(),
246 Kind::OptionGreeks => "option_greeks".to_string(),
247 Kind::VolatilityIndex => "volatility_index".to_string(),
248 Kind::HistoricalVolatility => "historical_volatility".to_string(),
249 Kind::LongShortRatio => "long_short_ratio".to_string(),
250 Kind::TakerVolume => "taker_volume".to_string(),
251 Kind::LiquidationBucket => "liquidation_bucket".to_string(),
252 Kind::Basis => "basis".to_string(),
253 Kind::InsuranceFund => "insurance_fund".to_string(),
254 Kind::OrderbookL3 => "orderbook_l3".to_string(),
255 Kind::SettlementEvent => "settlement_events".to_string(),
256 Kind::MarketWarning => "market_warnings".to_string(),
257 Kind::RiskLimit => "risk_limit".to_string(),
258 Kind::PredictedFunding => "predicted_funding".to_string(),
259 Kind::FundingSettlement => "funding_settlement".to_string(),
260 Kind::MarkPriceKline(iv) => format!("mark_price_klines_{}", iv.as_str()),
261 Kind::IndexPriceKline(iv) => format!("index_price_klines_{}", iv.as_str()),
262 Kind::PremiumIndexKline(iv) => format!("premium_index_klines_{}", iv.as_str()),
263 Kind::RangeBar(r) => format!("range_bars_{r}"),
264 Kind::TickBar(n) => format!("tick_bars_{n}"),
265 Kind::VolumeBar(v) => format!("volume_bars_{v}"),
266 Kind::Footprint(iv) => format!("footprint_{}", iv.as_str()),
267 Kind::RenkoBar(b, r) => format!("renko_bars_{b}_{r}"),
268 Kind::PnfBar(b, r) => format!("pnf_bars_{b}_{r}"),
269 Kind::KagiBar(r) => format!("kagi_bars_{r}"),
270 Kind::DollarBar { dollar_threshold } => format!("dollar_bars_{dollar_threshold}"),
271 Kind::TickImbalanceBar { alpha_x100, min_ticks } => format!("tib_bars_{alpha_x100}a_{min_ticks}mt"),
272 Kind::VolumeImbalanceBar { alpha_x100, min_ticks } => format!("vib_bars_{alpha_x100}a_{min_ticks}mt"),
273 Kind::RunBar { alpha_x100, min_ticks } => format!("run_bars_{alpha_x100}a_{min_ticks}mt"),
274 Kind::CvdLine => "cvd_line".to_string(),
275 Kind::ThreeLineBreak { lines_back } => format!("three_line_break_{lines_back}"),
276 Kind::TpoProfile(freq, src) => {
277 let src_slug = match src {
278 TpoSource::TradeBucket => "trade",
279 TpoSource::Kline1m => "kline1m",
280 };
281 format!("tpo_profile_{freq}m_{src_slug}")
282 }
283 Kind::OrderUpdate => "order_updates".to_string(),
284 Kind::BalanceUpdate => "balance_updates".to_string(),
285 Kind::PositionUpdate => "position_updates".to_string(),
286 }
287 }
288}
289
290/// Canonical series identity. Matches the MLC `BarSeriesKey` shape but is
291/// generalized over data-class via `kind`.
292#[derive(Debug, Clone, PartialEq, Eq, Hash)]
293pub struct SeriesKey {
294 pub exchange: ExchangeId,
295 pub account_type: AccountType,
296 /// Exchange-native raw symbol (already normalized via `SymbolNormalizer`).
297 pub symbol: String,
298 pub kind: Kind,
299}
300
301impl SeriesKey {
302 pub fn new(
303 exchange: ExchangeId,
304 account_type: AccountType,
305 symbol: impl Into<String>,
306 kind: Kind,
307 ) -> Self {
308 Self {
309 exchange,
310 account_type,
311 symbol: symbol.into(),
312 kind,
313 }
314 }
315
316 /// Filesystem-friendly account label (matches CLI `--account` spelling).
317 pub fn account_label(&self) -> &'static str {
318 match self.account_type {
319 AccountType::Spot => "spot",
320 AccountType::Margin => "margin",
321 AccountType::FuturesCross => "futures_cross",
322 AccountType::FuturesIsolated => "futures_isolated",
323 AccountType::Earn => "earn",
324 AccountType::Lending => "lending",
325 AccountType::Options => "options",
326 AccountType::Convert => "convert",
327 }
328 }
329
330 /// Lower-case exchange label.
331 pub fn exchange_label(&self) -> String {
332 format!("{:?}", self.exchange).to_lowercase()
333 }
334}