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