Skip to main content

hypercall_db/types/
analytics.rs

1//! Analytics and historical data types (API read path).
2
3use chrono::{DateTime, Utc};
4use hypercall_types::{
5    api_models::{FillApiResponse, Order, TradeApiResponse},
6    to_human_readable_decimal, WalletAddress,
7};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10
11/// Trade record from the trades table.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TradeRecord {
14    pub trade_id: i64,
15    pub symbol: String,
16    pub price: Decimal,
17    pub size: Decimal,
18    pub maker_address: WalletAddress,
19    pub taker_address: WalletAddress,
20    pub maker_fee: Decimal,
21    pub taker_fee: Decimal,
22    pub timestamp: i64,
23    pub created_at: Option<DateTime<Utc>>,
24}
25
26/// Fill record from the fills table.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct FillRecord {
29    pub fill_id: Option<i64>,
30    pub trade_id: i64,
31    pub wallet_address: WalletAddress,
32    pub symbol: String,
33    pub price: Decimal,
34    pub size: Decimal,
35    pub fee: Decimal,
36    pub is_taker: bool,
37    pub timestamp: i64,
38    pub builder_code_address: Option<WalletAddress>,
39    pub builder_code_fee: Option<Decimal>,
40    pub realized_pnl: Option<Decimal>,
41    pub underlying_notional: Option<Decimal>,
42    pub created_at: Option<DateTime<Utc>>,
43}
44
45/// Order record from the order_infos materialized view.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct OrderRecord {
48    pub order_id: i64,
49    pub wallet_address: WalletAddress,
50    pub symbol: String,
51    pub side: String,
52    pub price: Decimal,
53    pub size: Decimal,
54    pub filled_size: Decimal,
55    pub status: String,
56    pub tif: String,
57    pub client_id: Option<String>,
58    pub is_perp: bool,
59    pub underlying: Option<String>,
60    pub reduce_only: Option<bool>,
61    pub timestamp: i64,
62    pub created_at: Option<DateTime<Utc>>,
63    pub mmp_enabled: bool,
64}
65
66/// Historical PnL snapshot point.
67#[derive(Debug, Clone)]
68pub struct HistoricalPnlPoint {
69    pub timestamp_ms: i64,
70    pub total_equity: Decimal,
71    pub attribution: Option<Vec<u8>>,
72}
73
74/// Historical theoretical price point.
75#[derive(Debug, Clone)]
76pub struct HistoricalTheoPoint {
77    pub timestamp_ms: i64,
78    pub theoretical_price: Decimal,
79}
80
81/// BBO reference data for a symbol (best-ask reference price for 24h change).
82#[derive(Debug, Clone)]
83pub struct BboReferenceData {
84    pub reference_ask: Decimal,
85    pub reference_ts: i64,
86    pub used_earliest_fallback: bool,
87}
88
89/// Persisted vol surface snapshot (domain type, ORM-free).
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct VolSurfaceSnapshot {
92    pub id: i64,
93    pub underlying: String,
94    pub interval_ms: i64,
95    pub timestamp_ms: i64,
96    pub surface_json: serde_json::Value,
97    pub created_at: DateTime<Utc>,
98}
99
100/// Persisted BBO snapshot (domain type, ORM-free).
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct BboSnapshotRecord {
103    pub id: i64,
104    pub symbol: String,
105    pub best_bid: Decimal,
106    pub best_ask: Decimal,
107    pub best_bid_size: Option<Decimal>,
108    pub best_ask_size: Option<Decimal>,
109    pub snapshot_ts: i64,
110    pub created_at: DateTime<Utc>,
111}
112
113/// Input data for upserting a BBO snapshot (no id/created_at).
114#[derive(Debug, Clone)]
115pub struct NewBboSnapshotInput {
116    pub symbol: String,
117    pub best_bid: Decimal,
118    pub best_ask: Decimal,
119    pub best_bid_size: Option<Decimal>,
120    pub best_ask_size: Option<Decimal>,
121    pub snapshot_ts: i64,
122}
123
124impl From<TradeRecord> for TradeApiResponse {
125    fn from(r: TradeRecord) -> Self {
126        Self {
127            trade_id: r.trade_id,
128            symbol: r.symbol.clone(),
129            price: r.price,
130            size: to_human_readable_decimal(&r.symbol, r.size),
131            maker_address: r.maker_address,
132            taker_address: r.taker_address,
133            maker_fee: r.maker_fee,
134            taker_fee: r.taker_fee,
135            timestamp: r.timestamp,
136            created_at: r.created_at.unwrap_or_else(Utc::now),
137        }
138    }
139}
140
141impl From<FillRecord> for FillApiResponse {
142    fn from(r: FillRecord) -> Self {
143        Self {
144            fill_id: r.fill_id.unwrap_or(0),
145            trade_id: r.trade_id,
146            wallet_address: r.wallet_address,
147            symbol: r.symbol.clone(),
148            price: r.price,
149            size: to_human_readable_decimal(&r.symbol, r.size),
150            fee: r.fee,
151            is_taker: r.is_taker,
152            timestamp: r.timestamp,
153            created_at: r.created_at.unwrap_or_else(Utc::now),
154            builder_code_address: r.builder_code_address,
155            builder_code_fee: r.builder_code_fee,
156            realized_pnl: r.realized_pnl,
157            explorer_url: None,
158        }
159    }
160}
161
162impl From<OrderRecord> for Order {
163    fn from(r: OrderRecord) -> Self {
164        Self {
165            order_id: r.order_id,
166            wallet_address: r.wallet_address,
167            symbol: r.symbol.clone(),
168            side: r.side,
169            price: r.price,
170            size: to_human_readable_decimal(&r.symbol, r.size),
171            tif: r.tif,
172            status: Some(r.status),
173            created_at: r.timestamp,
174            updated_at: r.created_at,
175            filled_size: Some(to_human_readable_decimal(&r.symbol, r.filled_size)),
176            mmp_enabled: r.mmp_enabled,
177        }
178    }
179}
180
181/// Ledger event with timestamp and balance delta (deposit/withdrawal).
182#[derive(Debug, Clone)]
183pub struct LedgerEventTimeDelta {
184    pub event_ts_ms: i64,
185    pub delta: Decimal,
186}
187
188/// Minimal fill record for cache backfill (volume calculations).
189#[derive(Debug, Clone)]
190pub struct FillBackfillRecord {
191    pub symbol: String,
192    pub price: Decimal,
193    pub size: Decimal,
194    pub underlying_notional: Option<Decimal>,
195    pub timestamp: i64,
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use chrono::NaiveDateTime;
202    use hypercall_types::wallet_address::test_wallet;
203    use rust_decimal_macros::dec;
204
205    #[test]
206    fn trade_record_to_api_response() {
207        let ts = NaiveDateTime::from_timestamp_opt(1700000000, 0).unwrap();
208        let created = DateTime::<Utc>::from_naive_utc_and_offset(ts, Utc);
209        let record = TradeRecord {
210            trade_id: 42,
211            symbol: "BTC-20260115-95000-C".into(),
212            price: dec!(100.5),
213            size: dec!(1000000),
214            maker_address: test_wallet(1),
215            taker_address: test_wallet(2),
216            maker_fee: dec!(0.5),
217            taker_fee: dec!(0.3),
218            timestamp: 1700000000000,
219            created_at: Some(created),
220        };
221        let api: TradeApiResponse = record.into();
222        assert_eq!(api.trade_id, 42);
223        assert_eq!(api.price, dec!(100.5));
224        assert_eq!(api.maker_address, test_wallet(1));
225        assert_eq!(api.taker_address, test_wallet(2));
226        assert_eq!(api.maker_fee, dec!(0.5));
227        assert_eq!(api.taker_fee, dec!(0.3));
228        assert_eq!(api.timestamp, 1700000000000);
229        assert_eq!(api.created_at, created);
230        // size gets human-readable conversion
231        assert_eq!(
232            api.size,
233            to_human_readable_decimal("BTC-20260115-95000-C", dec!(1000000))
234        );
235    }
236
237    #[test]
238    fn trade_record_to_api_none_created_at_uses_fallback() {
239        let record = TradeRecord {
240            trade_id: 1,
241            symbol: "ETH-20260115-4000-P".into(),
242            price: dec!(50),
243            size: dec!(500000),
244            maker_address: test_wallet(3),
245            taker_address: test_wallet(4),
246            maker_fee: dec!(0),
247            taker_fee: dec!(0),
248            timestamp: 0,
249            created_at: None,
250        };
251        let api: TradeApiResponse = record.into();
252        // Should use Utc::now() fallback, just verify it doesn't panic
253        assert!(api.created_at.timestamp() > 0);
254    }
255
256    #[test]
257    fn fill_record_to_api_response() {
258        let ts = NaiveDateTime::from_timestamp_opt(1700000000, 0).unwrap();
259        let created = DateTime::<Utc>::from_naive_utc_and_offset(ts, Utc);
260        let record = FillRecord {
261            fill_id: Some(99),
262            trade_id: 42,
263            wallet_address: test_wallet(5),
264            symbol: "BTC-20260115-95000-C".into(),
265            price: dec!(100),
266            size: dec!(500000),
267            fee: dec!(0.25),
268            is_taker: true,
269            timestamp: 1700000000000,
270            builder_code_address: Some(test_wallet(6)),
271            builder_code_fee: Some(dec!(0.05)),
272            realized_pnl: Some(dec!(10)),
273            underlying_notional: Some(dec!(50000000)),
274            created_at: Some(created),
275        };
276        let api: FillApiResponse = record.into();
277        assert_eq!(api.fill_id, 99);
278        assert_eq!(api.trade_id, 42);
279        assert_eq!(api.wallet_address, test_wallet(5));
280        assert_eq!(api.price, dec!(100));
281        assert!(api.is_taker);
282        assert_eq!(api.builder_code_address, Some(test_wallet(6)));
283        assert_eq!(api.builder_code_fee, Some(dec!(0.05)));
284        assert_eq!(api.realized_pnl, Some(dec!(10)));
285        assert!(api.explorer_url.is_none());
286        assert_eq!(api.created_at, created);
287    }
288
289    #[test]
290    fn fill_record_to_api_none_fill_id_defaults_zero() {
291        let record = FillRecord {
292            fill_id: None,
293            trade_id: 1,
294            wallet_address: test_wallet(7),
295            symbol: "ETH-20260115-4000-P".into(),
296            price: dec!(0),
297            size: dec!(0),
298            fee: dec!(0),
299            is_taker: false,
300            timestamp: 0,
301            builder_code_address: None,
302            builder_code_fee: None,
303            realized_pnl: None,
304            underlying_notional: None,
305            created_at: None,
306        };
307        let api: FillApiResponse = record.into();
308        assert_eq!(api.fill_id, 0);
309        assert!(api.builder_code_address.is_none());
310        assert!(api.builder_code_fee.is_none());
311        assert!(api.realized_pnl.is_none());
312    }
313
314    #[test]
315    fn order_record_to_api_response() {
316        let ts = NaiveDateTime::from_timestamp_opt(1700000000, 0).unwrap();
317        let updated = DateTime::<Utc>::from_naive_utc_and_offset(ts, Utc);
318        let record = OrderRecord {
319            order_id: 123,
320            wallet_address: test_wallet(8),
321            symbol: "BTC-20260115-95000-C".into(),
322            side: "buy".into(),
323            price: dec!(5.5),
324            size: dec!(2000000),
325            filled_size: dec!(1000000),
326            status: "PARTIALLY_FILLED".into(),
327            tif: "GTC".into(),
328            client_id: Some("my-order-1".into()),
329            is_perp: false,
330            underlying: Some("BTC".into()),
331            reduce_only: Some(false),
332            timestamp: 1700000000000,
333            created_at: Some(updated),
334            mmp_enabled: true,
335        };
336        let api: Order = record.into();
337        assert_eq!(api.order_id, 123);
338        assert_eq!(api.wallet_address, test_wallet(8));
339        assert_eq!(api.side, "buy");
340        assert_eq!(api.price, dec!(5.5));
341        assert_eq!(api.tif, "GTC");
342        assert_eq!(api.status, Some("PARTIALLY_FILLED".into()));
343        assert_eq!(api.created_at, 1700000000000);
344        assert_eq!(api.updated_at, Some(updated));
345        assert!(api.mmp_enabled);
346        // size/filled_size get human-readable conversion
347        let expected_size = to_human_readable_decimal("BTC-20260115-95000-C", dec!(2000000));
348        let expected_filled = to_human_readable_decimal("BTC-20260115-95000-C", dec!(1000000));
349        assert_eq!(api.size, expected_size);
350        assert_eq!(api.filled_size, Some(expected_filled));
351    }
352}