opendeviationbar-streaming 13.70.3

Real-time streaming engine for open deviation bar processing
Documentation
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
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
//! ClickHouseBarRow: maps CompletedBar fields to ClickHouse columns.
//!
//! Issue #318: Rust-native ClickHouse writer — row mapping.
//!
//! Field mapping follows `python/opendeviationbar/clickhouse/schema.sql` exactly,
//! EXCLUDING computed columns (`is_liquidation_cascade`, `computed_at`) which use
//! ClickHouse DEFAULT expressions.

use opendeviationbar_core::fixed_point::SCALE;

use crate::live_engine::CompletedBar;

/// Scale divisor for i128 volume fields (same as SCALE but as f64 for division).
const VOLUME_SCALE: f64 = SCALE as f64;

/// All core column names in the `ClickHouseBarRow` struct, in insertion order.
/// Used by plugin discovery (Plan 02) to distinguish core vs plugin columns.
pub const CORE_COLUMNS: &[&str] = &[
    "symbol",
    "threshold_decimal_bps",
    "close_time_us",
    "open_time_us",
    "open",
    "high",
    "low",
    "close",
    "volume",
    "vwap",
    "buy_volume",
    "sell_volume",
    "individual_trade_count",
    "agg_record_count",
    "duration_us",
    "ofi",
    "vwap_close_deviation",
    "price_impact",
    "kyle_lambda_proxy",
    "trade_intensity",
    "volume_per_trade",
    "aggression_ratio",
    "aggregation_density",
    "turnover_imbalance",
    "ouroboros_mode",
    "exchange_session_sydney",
    "exchange_session_tokyo",
    "exchange_session_london",
    "exchange_session_newyork",
    "lookback_trade_count",
    "lookback_ofi",
    "lookback_duration_us",
    "lookback_intensity",
    "lookback_vwap_raw",
    "lookback_vwap_position",
    "lookback_count_imbalance",
    "lookback_kyle_lambda",
    "lookback_burstiness",
    "lookback_volume_skew",
    "lookback_volume_kurt",
    "lookback_price_range",
    "lookback_kaufman_er",
    "lookback_garman_klass_vol",
    "lookback_hurst",
    "lookback_permutation_entropy",
    "intra_bull_epoch_density",
    "intra_bear_epoch_density",
    "intra_bull_excess_gain",
    "intra_bear_excess_gain",
    "intra_bull_cv",
    "intra_bear_cv",
    "intra_max_drawdown",
    "intra_max_runup",
    "intra_trade_count",
    "intra_ofi",
    "intra_duration_us",
    "intra_intensity",
    "intra_vwap_position",
    "intra_count_imbalance",
    "intra_kyle_lambda",
    "intra_burstiness",
    "intra_volume_skew",
    "intra_volume_kurt",
    "intra_kaufman_er",
    "intra_garman_klass_vol",
    "intra_hurst",
    "intra_permutation_entropy",
    "has_gap",
    "gap_trade_count",
    "max_gap_duration_us",
    "is_exchange_gap",
    "first_agg_trade_id",
    "last_agg_trade_id",
    "is_orphan",
    "cache_key",
    "opendeviationbar_version",
    "source_start_ts",
    "source_end_ts",
];

/// A single row for ClickHouse INSERT into `open_deviation_bars`.
///
/// Mirrors the schema in `python/opendeviationbar/clickhouse/schema.sql` exactly,
/// EXCLUDING columns with DEFAULT expressions that ClickHouse computes:
/// - `is_liquidation_cascade` (DEFAULT expression from OHLCV + threshold)
/// - `computed_at` (DEFAULT now64(3, 'UTC'))
/// - All plugin columns (DEFAULT NULL, discovered at runtime)
#[derive(Debug, Clone, clickhouse::Row, serde::Serialize)]
pub struct ClickHouseBarRow {
    // -- Cache key components --
    pub symbol: String,
    pub threshold_decimal_bps: u32,

    // -- OHLCV data --
    pub close_time_us: i64,
    pub open_time_us: i64,
    pub open: f64,
    pub high: f64,
    pub low: f64,
    pub close: f64,
    pub volume: f64,

    // -- Market microstructure --
    pub vwap: f64,
    pub buy_volume: f64,
    pub sell_volume: f64,
    pub individual_trade_count: u32,
    pub agg_record_count: u32,

    // -- Microstructure features (Issue #25) --
    pub duration_us: i64,
    pub ofi: f64,
    pub vwap_close_deviation: f64,
    pub price_impact: f64,
    pub kyle_lambda_proxy: f64,
    pub trade_intensity: f64,
    pub volume_per_trade: f64,
    pub aggression_ratio: f64,
    /// Maps from `bar.aggregation_density_f64` (Rust field name mismatch with CH column).
    pub aggregation_density: f64,
    pub turnover_imbalance: f64,

    // -- Ouroboros --
    pub ouroboros_mode: String,

    // -- Exchange session flags --
    pub exchange_session_sydney: u8,
    pub exchange_session_tokyo: u8,
    pub exchange_session_london: u8,
    pub exchange_session_newyork: u8,

    // -- Inter-bar features (Issue #59) --
    pub lookback_trade_count: Option<u32>,
    pub lookback_ofi: Option<f64>,
    pub lookback_duration_us: Option<i64>,
    pub lookback_intensity: Option<f64>,
    pub lookback_vwap_raw: Option<f64>,
    pub lookback_vwap_position: Option<f64>,
    pub lookback_count_imbalance: Option<f64>,
    pub lookback_kyle_lambda: Option<f64>,
    pub lookback_burstiness: Option<f64>,
    pub lookback_volume_skew: Option<f64>,
    pub lookback_volume_kurt: Option<f64>,
    pub lookback_price_range: Option<f64>,
    pub lookback_kaufman_er: Option<f64>,
    pub lookback_garman_klass_vol: Option<f64>,
    pub lookback_hurst: Option<f64>,
    pub lookback_permutation_entropy: Option<f64>,

    // -- Intra-bar features (Issue #78) --
    // ITH features (8)
    pub intra_bull_epoch_density: Option<f64>,
    pub intra_bear_epoch_density: Option<f64>,
    pub intra_bull_excess_gain: Option<f64>,
    pub intra_bear_excess_gain: Option<f64>,
    pub intra_bull_cv: Option<f64>,
    pub intra_bear_cv: Option<f64>,
    pub intra_max_drawdown: Option<f64>,
    pub intra_max_runup: Option<f64>,
    // Statistical features (12)
    pub intra_trade_count: Option<u32>,
    pub intra_ofi: Option<f64>,
    pub intra_duration_us: Option<i64>,
    pub intra_intensity: Option<f64>,
    pub intra_vwap_position: Option<f64>,
    pub intra_count_imbalance: Option<f64>,
    pub intra_kyle_lambda: Option<f64>,
    pub intra_burstiness: Option<f64>,
    pub intra_volume_skew: Option<f64>,
    pub intra_volume_kurt: Option<f64>,
    pub intra_kaufman_er: Option<f64>,
    pub intra_garman_klass_vol: Option<f64>,
    // Complexity features (2)
    pub intra_hurst: Option<f64>,
    pub intra_permutation_entropy: Option<f64>,

    // -- Gap awareness (v7.0 Aion) --
    pub has_gap: bool,
    pub gap_trade_count: i64,
    pub max_gap_duration_us: i64,
    pub is_exchange_gap: bool,

    // -- Trade ID range (Issue #72) --
    pub first_ref_id: i64,
    pub last_ref_id: i64,

    // -- Bar flags --
    pub is_orphan: u8,

    // -- Cache metadata --
    /// Per-batch dedup token (computed in flush thread, empty at construction).
    pub cache_key: String,
    pub opendeviationbar_version: String,
    /// Computed per-batch in flush thread.
    pub source_start_ts: i64,
    /// Computed per-batch in flush thread.
    pub source_end_ts: i64,
}

impl ClickHouseBarRow {
    /// Convert a `CompletedBar` (Rust streaming engine output) to a `ClickHouseBarRow`.
    ///
    /// Field mapping follows the Python `store_bars_bulk()` path in
    /// `python/opendeviationbar/clickhouse/bulk_operations.py`.
    ///
    /// # Critical conversions
    ///
    /// - Prices (open/high/low/close/vwap): `FixedPoint::to_f64()`
    /// - Volumes (volume/buy_volume/sell_volume): `i128 as f64 / SCALE`
    /// - `aggregation_density`: maps from `bar.aggregation_density_f64` (name mismatch)
    /// - `lookback_vwap_raw`: `Option<i64>` -> `Option<f64>` via SCALE division
    /// - `ouroboros_mode`: hardcoded `"aion"` (only active mode)
    /// - `exchange_session_*`: hardcoded `0` (crypto has no exchange sessions)
    /// - `is_orphan`: hardcoded `0` (Aion mode has no orphan bars)
    /// - `cache_key`, `source_start_ts`, `source_end_ts`: empty/zero (computed per-batch)
    pub fn from_completed_bar(completed: &CompletedBar) -> Self {
        let bar = &completed.bar;

        Self {
            symbol: completed.symbol.to_string(),
            threshold_decimal_bps: completed.threshold_decimal_bps,

            // OHLCV (FixedPoint -> f64, i128 -> f64/SCALE)
            close_time_us: bar.close_time,
            open_time_us: bar.open_time,
            open: bar.open.to_f64(),
            high: bar.high.to_f64(),
            low: bar.low.to_f64(),
            close: bar.close.to_f64(),
            volume: bar.volume as f64 / VOLUME_SCALE,

            // Market microstructure
            vwap: bar.vwap.to_f64(),
            buy_volume: bar.buy_volume as f64 / VOLUME_SCALE,
            sell_volume: bar.sell_volume as f64 / VOLUME_SCALE,
            individual_trade_count: bar.individual_trade_count,
            agg_record_count: bar.agg_record_count,

            // Microstructure features
            duration_us: bar.duration_us,
            ofi: bar.ofi,
            vwap_close_deviation: bar.vwap_close_deviation,
            price_impact: bar.price_impact,
            kyle_lambda_proxy: bar.kyle_lambda_proxy,
            trade_intensity: bar.trade_intensity,
            volume_per_trade: bar.volume_per_trade,
            aggression_ratio: bar.aggression_ratio,
            aggregation_density: bar.aggregation_density_f64, // Name mismatch: Rust field vs CH column
            turnover_imbalance: bar.turnover_imbalance,

            // Ouroboros — only Aion is active (#318, v8.0+)
            ouroboros_mode: "aion".to_string(),

            // Exchange sessions — crypto has no exchange sessions
            exchange_session_sydney: 0,
            exchange_session_tokyo: 0,
            exchange_session_london: 0,
            exchange_session_newyork: 0,

            // Inter-bar features
            lookback_trade_count: bar.lookback_trade_count,
            lookback_ofi: bar.lookback_ofi,
            lookback_duration_us: bar.lookback_duration_us,
            lookback_intensity: bar.lookback_intensity,
            lookback_vwap_raw: bar.lookback_vwap_raw.map(|v| v as f64 / VOLUME_SCALE),
            lookback_vwap_position: bar.lookback_vwap_position,
            lookback_count_imbalance: bar.lookback_count_imbalance,
            lookback_kyle_lambda: bar.lookback_kyle_lambda,
            lookback_burstiness: bar.lookback_burstiness,
            lookback_volume_skew: bar.lookback_volume_skew,
            lookback_volume_kurt: bar.lookback_volume_kurt,
            lookback_price_range: bar.lookback_price_range,
            lookback_kaufman_er: bar.lookback_kaufman_er,
            lookback_garman_klass_vol: bar.lookback_garman_klass_vol,
            lookback_hurst: bar.lookback_hurst,
            lookback_permutation_entropy: bar.lookback_permutation_entropy,

            // Intra-bar features
            intra_bull_epoch_density: bar.intra_bull_epoch_density,
            intra_bear_epoch_density: bar.intra_bear_epoch_density,
            intra_bull_excess_gain: bar.intra_bull_excess_gain,
            intra_bear_excess_gain: bar.intra_bear_excess_gain,
            intra_bull_cv: bar.intra_bull_cv,
            intra_bear_cv: bar.intra_bear_cv,
            intra_max_drawdown: bar.intra_max_drawdown,
            intra_max_runup: bar.intra_max_runup,
            intra_trade_count: bar.intra_trade_count,
            intra_ofi: bar.intra_ofi,
            intra_duration_us: bar.intra_duration_us,
            intra_intensity: bar.intra_intensity,
            intra_vwap_position: bar.intra_vwap_position,
            intra_count_imbalance: bar.intra_count_imbalance,
            intra_kyle_lambda: bar.intra_kyle_lambda,
            intra_burstiness: bar.intra_burstiness,
            intra_volume_skew: bar.intra_volume_skew,
            intra_volume_kurt: bar.intra_volume_kurt,
            intra_kaufman_er: bar.intra_kaufman_er,
            intra_garman_klass_vol: bar.intra_garman_klass_vol,
            intra_hurst: bar.intra_hurst,
            intra_permutation_entropy: bar.intra_permutation_entropy,

            // Gap awareness
            has_gap: bar.has_gap,
            gap_trade_count: bar.gap_trade_count,
            max_gap_duration_us: bar.max_gap_duration_us,
            is_exchange_gap: bar.is_exchange_gap,

            // Trade ID range
            first_ref_id: bar.first_agg_trade_id,
            last_ref_id: bar.last_agg_trade_id,

            // Bar flags — Aion mode has no orphan bars
            is_orphan: 0,

            // Cache metadata — computed per-batch in flush thread
            cache_key: String::new(),
            opendeviationbar_version: env!("CARGO_PKG_VERSION").to_string(),
            source_start_ts: 0,
            source_end_ts: 0,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use opendeviationbar_core::fixed_point::FixedPoint;
    use opendeviationbar_core::OpenDeviationBar;
    use std::sync::Arc;

    /// Helper: create a CompletedBar with known values for testing.
    fn test_completed_bar() -> CompletedBar {
        let mut bar = OpenDeviationBar::default();

        // Prices via FixedPoint (8-decimal precision)
        bar.open = FixedPoint::from_str("50000.12345678").unwrap();
        bar.high = FixedPoint::from_str("50100.0").unwrap();
        bar.low = FixedPoint::from_str("49900.0").unwrap();
        bar.close = FixedPoint::from_str("50050.0").unwrap();
        bar.vwap = FixedPoint::from_str("50025.0").unwrap();

        // Timestamps (microseconds)
        bar.open_time = 1_700_000_000_000_000;
        bar.close_time = 1_700_000_100_000_000;

        // Volumes (i128 raw, SCALE = 100_000_000)
        bar.volume = 5_000_000_000; // 50.0 in f64
        bar.buy_volume = 3_000_000_000; // 30.0
        bar.sell_volume = 2_000_000_000; // 20.0

        // Trade tracking
        bar.individual_trade_count = 100;
        bar.agg_record_count = 50;
        bar.first_trade_id = 1000;
        bar.last_trade_id = 1099;
        bar.first_agg_trade_id = 500;
        bar.last_agg_trade_id = 549;

        // Microstructure (pre-computed values)
        bar.duration_us = 100_000_000;
        bar.ofi = 0.2;
        bar.vwap_close_deviation = 0.1;
        bar.price_impact = 0.001;
        bar.kyle_lambda_proxy = 0.5;
        bar.trade_intensity = 1000.0;
        bar.volume_per_trade = 0.5;
        bar.aggression_ratio = 1.5;
        bar.aggregation_density_f64 = 2.0;
        bar.turnover_imbalance = 0.2;

        // Inter-bar features (some set, some None)
        bar.lookback_trade_count = Some(200);
        bar.lookback_ofi = Some(0.1);
        bar.lookback_vwap_raw = Some(5_002_500_000_000); // FixedPoint raw i64

        // Gap awareness
        bar.has_gap = false;
        bar.gap_trade_count = 0;
        bar.max_gap_duration_us = 0;
        bar.is_exchange_gap = false;

        CompletedBar {
            symbol: Arc::from("BTCUSDT"),
            threshold_decimal_bps: 250,
            bar,
        }
    }

    #[test]
    fn test_from_completed_bar_prices() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        // FixedPoint::to_f64() preserves 8-decimal precision
        let epsilon = 1e-8;
        assert!((row.open - 50000.12345678).abs() < epsilon);
        assert!((row.high - 50100.0).abs() < epsilon);
        assert!((row.low - 49900.0).abs() < epsilon);
        assert!((row.close - 50050.0).abs() < epsilon);
        assert!((row.vwap - 50025.0).abs() < epsilon);
    }

    #[test]
    fn test_from_completed_bar_volumes() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        // i128 / SCALE (100_000_000)
        let epsilon = 1e-8;
        assert!((row.volume - 50.0).abs() < epsilon);
        assert!((row.buy_volume - 30.0).abs() < epsilon);
        assert!((row.sell_volume - 20.0).abs() < epsilon);
    }

    #[test]
    fn test_from_completed_bar_lookback_vwap() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        // Option<i64> -> Option<f64> via SCALE division
        let vwap = row.lookback_vwap_raw.expect("lookback_vwap_raw should be Some");
        let expected = 5_002_500_000_000_f64 / 100_000_000.0;
        assert!((vwap - expected).abs() < 1e-6);
    }

    #[test]
    fn test_from_completed_bar_aggregation_density() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        // Field name mapping: bar.aggregation_density_f64 -> row.aggregation_density
        assert!((row.aggregation_density - 2.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_from_completed_bar_hardcoded_fields() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        assert_eq!(row.ouroboros_mode, "aion");
        assert_eq!(row.exchange_session_sydney, 0);
        assert_eq!(row.exchange_session_tokyo, 0);
        assert_eq!(row.exchange_session_london, 0);
        assert_eq!(row.exchange_session_newyork, 0);
        assert_eq!(row.is_orphan, 0);
    }

    #[test]
    fn test_computed_columns_not_in_struct() {
        // Compile-time check: ClickHouseBarRow has no is_liquidation_cascade or computed_at field.
        // If either field existed, this would fail to compile because we'd get "no field" errors.
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);
        // Access all fields to prove the struct shape — these lines compile only if the
        // struct does NOT have is_liquidation_cascade or computed_at.
        let _symbol = &row.symbol;
        let _version = &row.opendeviationbar_version;
        // If you add is_liquidation_cascade or computed_at to the struct, un-comment
        // the following lines and the test will fail to compile:
        // let _x = row.is_liquidation_cascade;
        // let _y = row.computed_at;
    }

    #[test]
    fn test_from_completed_bar_timestamps() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        assert_eq!(row.open_time_us, 1_700_000_000_000_000);
        assert_eq!(row.close_time_us, 1_700_000_100_000_000);
    }

    #[test]
    fn test_from_completed_bar_trade_ids() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        assert_eq!(row.first_ref_id, 500);
        assert_eq!(row.last_ref_id, 549);
    }

    #[test]
    fn test_from_completed_bar_symbol_and_threshold() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        assert_eq!(row.symbol, "BTCUSDT");
        assert_eq!(row.threshold_decimal_bps, 250);
    }

    #[test]
    fn test_from_completed_bar_cache_metadata_defaults() {
        let completed = test_completed_bar();
        let row = ClickHouseBarRow::from_completed_bar(&completed);

        // Per-batch fields default to empty/zero
        assert!(row.cache_key.is_empty());
        assert_eq!(row.source_start_ts, 0);
        assert_eq!(row.source_end_ts, 0);
        assert!(!row.opendeviationbar_version.is_empty());
    }

    #[test]
    fn test_core_columns_count() {
        // CORE_COLUMNS should list all fields in the struct
        // 80+ columns from schema.sql minus is_liquidation_cascade, computed_at
        assert!(CORE_COLUMNS.len() >= 75, "Expected 75+ core columns, got {}", CORE_COLUMNS.len());
    }
}