1use 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#[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#[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 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 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 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 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 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 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}