Skip to main content

hypercall_db_diesel/
bootstrap.rs

1//! Bootstrap trait implementation for DieselDb.
2//!
3//! Implements `BootstrapReader` from `hypercall-db`, covering one-shot startup
4//! queries for instruments and other startup metadata.
5
6use anyhow::Result;
7use diesel::prelude::*;
8use diesel::sql_types::*;
9use diesel_async::RunQueryDsl;
10use rust_decimal::Decimal;
11
12use crate::diesel_db::DieselDb;
13use hypercall_db::{BootstrapReader, InstrumentRecord};
14use hypercall_types::api_models::InstrumentStatus;
15use hypercall_types::{OptionType, WalletAddress};
16
17// ---- Private row types for raw SQL queries ----
18
19#[derive(QueryableByName)]
20struct InstrumentRow {
21    #[diesel(sql_type = Integer)]
22    instrument_numeric_id: i32,
23    #[diesel(sql_type = Text)]
24    id: String,
25    #[diesel(sql_type = Text)]
26    underlying: String,
27    #[diesel(sql_type = Numeric)]
28    strike: Decimal,
29    #[diesel(sql_type = BigInt)]
30    expiry: i64,
31    #[diesel(sql_type = Text)]
32    option_type: String,
33    #[diesel(sql_type = Nullable<Bytea>)]
34    option_token_address: Option<WalletAddress>,
35    #[diesel(sql_type = Text)]
36    status: String,
37    #[diesel(sql_type = Text)]
38    trading_mode: String,
39}
40
41impl InstrumentRow {
42    fn into_record(self) -> Result<InstrumentRecord> {
43        let option_type = match self.option_type.to_uppercase().as_str() {
44            "C" | "CALL" => OptionType::Call,
45            "P" | "PUT" => OptionType::Put,
46            other => anyhow::bail!("Unknown option_type '{}' for instrument {}", other, self.id),
47        };
48        let status = InstrumentStatus::from_db_str(&self.status).ok_or_else(|| {
49            anyhow::anyhow!(
50                "Unknown instrument status '{}' for instrument {}",
51                self.status,
52                self.id
53            )
54        })?;
55        Ok(InstrumentRecord {
56            instrument_numeric_id: self.instrument_numeric_id,
57            id: self.id,
58            underlying: self.underlying,
59            strike: self.strike,
60            expiry: self.expiry,
61            option_type,
62            option_token_address: self.option_token_address,
63            status,
64            trading_mode: self.trading_mode,
65        })
66    }
67}
68
69// =============================================================================
70// BootstrapReader implementation
71// =============================================================================
72
73#[async_trait::async_trait]
74impl BootstrapReader for DieselDb {
75    async fn get_all_active_instruments(&self) -> Result<Vec<InstrumentRecord>> {
76        let mut conn = self.get_conn().await?;
77
78        let rows = diesel::sql_query(
79            "SELECT instrument_numeric_id, id, underlying, strike, \
80                    CAST(expiry AS BIGINT) AS expiry, option_type, \
81                    option_token_address, status, \
82                    COALESCE(trading_mode, 'orderbook') AS trading_mode \
83             FROM instruments \
84             WHERE status = 'ACTIVE' \
85             ORDER BY id ASC",
86        )
87        .get_results::<InstrumentRow>(&mut conn)
88        .await?;
89
90        rows.into_iter().map(|r| r.into_record()).collect()
91    }
92
93    async fn get_all_instruments(&self) -> Result<Vec<InstrumentRecord>> {
94        let mut conn = self.get_conn().await?;
95
96        let rows = diesel::sql_query(
97            "SELECT instrument_numeric_id, id, underlying, strike, \
98                    CAST(expiry AS BIGINT) AS expiry, option_type, \
99                    option_token_address, status, \
100                    COALESCE(trading_mode, 'orderbook') AS trading_mode \
101             FROM instruments \
102             ORDER BY underlying, expiry, strike, option_type",
103        )
104        .get_results::<InstrumentRow>(&mut conn)
105        .await?;
106
107        rows.into_iter().map(|r| r.into_record()).collect()
108    }
109
110    async fn get_instrument_count(&self) -> Result<i64> {
111        let mut conn = self.get_conn().await?;
112
113        #[derive(QueryableByName)]
114        struct CountRow {
115            #[diesel(sql_type = BigInt)]
116            count: i64,
117        }
118
119        let row = diesel::sql_query("SELECT COUNT(*)::bigint AS count FROM instruments")
120            .get_result::<CountRow>(&mut conn)
121            .await?;
122
123        Ok(row.count)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use crate::test_helpers::TestDb;
130    use hypercall_db::*;
131    use hypercall_types::api_models::InstrumentStatus;
132    use hypercall_types::OptionType;
133    use rust_decimal_macros::dec;
134
135    #[tokio::test]
136    async fn get_all_active_instruments_filters_by_status() {
137        let test_db = TestDb::new().await.unwrap();
138        let db = test_db.diesel_db().await;
139
140        let handler = test_db.handler.as_ref();
141
142        // Insert an ACTIVE instrument (expiry as YYYYMMDD code)
143        let active_instrument = InstrumentRecord {
144            instrument_numeric_id: 0,
145            id: "BTC-20260131-100000-C".to_string(),
146            underlying: "BTC".to_string(),
147            strike: dec!(100000),
148            expiry: 20260131,
149            option_type: OptionType::Call,
150            option_token_address: None,
151            status: InstrumentStatus::Active,
152            trading_mode: "orderbook".to_string(),
153        };
154        handler
155            .save_market_and_instrument_sync("BTC", 20260131, &active_instrument)
156            .unwrap();
157
158        // Insert a SETTLED instrument
159        let settled_instrument = InstrumentRecord {
160            instrument_numeric_id: 0,
161            id: "BTC-20260131-90000-P".to_string(),
162            underlying: "BTC".to_string(),
163            strike: dec!(90000),
164            expiry: 20260131,
165            option_type: OptionType::Put,
166            option_token_address: None,
167            status: InstrumentStatus::Active,
168            trading_mode: "orderbook".to_string(),
169        };
170        handler
171            .save_market_and_instrument_sync("BTC", 20260131, &settled_instrument)
172            .unwrap();
173
174        // Update the second instrument to SETTLED
175        handler
176            .update_instrument_status_sync(&["BTC-20260131-90000-P".to_string()], "SETTLED")
177            .unwrap();
178
179        let active = db.get_all_active_instruments().await.unwrap();
180        assert_eq!(active.len(), 1);
181        assert_eq!(active[0].id, "BTC-20260131-100000-C");
182        assert_eq!(active[0].status, InstrumentStatus::Active);
183    }
184
185    #[tokio::test]
186    async fn get_all_instruments_returns_all_statuses() {
187        let test_db = TestDb::new().await.unwrap();
188        let db = test_db.diesel_db().await;
189
190        let handler = test_db.handler.as_ref();
191
192        let inst1 = InstrumentRecord {
193            instrument_numeric_id: 0,
194            id: "BTC-20260131-100000-C".to_string(),
195            underlying: "BTC".to_string(),
196            strike: dec!(100000),
197            expiry: 20260131,
198            option_type: OptionType::Call,
199            option_token_address: None,
200            status: InstrumentStatus::Active,
201            trading_mode: "orderbook".to_string(),
202        };
203        handler
204            .save_market_and_instrument_sync("BTC", 20260131, &inst1)
205            .unwrap();
206
207        let inst2 = InstrumentRecord {
208            instrument_numeric_id: 0,
209            id: "ETH-20260131-4000-P".to_string(),
210            underlying: "ETH".to_string(),
211            strike: dec!(4000),
212            expiry: 20260131,
213            option_type: OptionType::Put,
214            option_token_address: None,
215            status: InstrumentStatus::Active,
216            trading_mode: "rfq".to_string(),
217        };
218        handler
219            .save_market_and_instrument_sync("ETH", 20260131, &inst2)
220            .unwrap();
221
222        // Settle one
223        handler
224            .update_instrument_status_sync(&["ETH-20260131-4000-P".to_string()], "SETTLED")
225            .unwrap();
226
227        let all = db.get_all_instruments().await.unwrap();
228        assert_eq!(all.len(), 2);
229        // Should include both ACTIVE and SETTLED
230        let statuses: Vec<_> = all.iter().map(|i| i.status.clone()).collect();
231        assert!(statuses.contains(&InstrumentStatus::Active));
232        assert!(statuses.contains(&InstrumentStatus::Settled));
233    }
234
235    #[tokio::test]
236    async fn get_instrument_count_matches_inserted() {
237        let test_db = TestDb::new().await.unwrap();
238        let db = test_db.diesel_db().await;
239
240        // Initially zero
241        let count = db.get_instrument_count().await.unwrap();
242        assert_eq!(count, 0);
243
244        let handler = test_db.handler.as_ref();
245        let inst = InstrumentRecord {
246            instrument_numeric_id: 0,
247            id: "BTC-20260131-100000-C".to_string(),
248            underlying: "BTC".to_string(),
249            strike: dec!(100000),
250            expiry: 20260131,
251            option_type: OptionType::Call,
252            option_token_address: None,
253            status: InstrumentStatus::Active,
254            trading_mode: "orderbook".to_string(),
255        };
256        handler
257            .save_market_and_instrument_sync("BTC", 20260131, &inst)
258            .unwrap();
259
260        let inst2 = InstrumentRecord {
261            instrument_numeric_id: 0,
262            id: "BTC-20260131-90000-P".to_string(),
263            underlying: "BTC".to_string(),
264            strike: dec!(90000),
265            expiry: 20260131,
266            option_type: OptionType::Put,
267            option_token_address: None,
268            status: InstrumentStatus::Active,
269            trading_mode: "orderbook".to_string(),
270        };
271        handler
272            .save_market_and_instrument_sync("BTC", 20260131, &inst2)
273            .unwrap();
274
275        let count = db.get_instrument_count().await.unwrap();
276        assert_eq!(count, 2);
277    }
278}