1use crate::presentation::market::{MarketData, MarketNode};
2use crate::storage::market_persistence::{MarketHierarchyNode, MarketInstrument};
3use chrono::{DateTime, Utc};
4use sqlx::{AssertSqlSafe, Executor, PgPool, Row};
5use std::collections::HashMap;
6use std::future::Future;
7use tracing::info;
8
9const HIERARCHY_INSERT_BATCH_SIZE: usize = 5_000;
16
17pub struct MarketDatabaseService {
19 pool: PgPool,
20 exchange_name: String,
21}
22
23impl MarketDatabaseService {
24 pub fn new(pool: PgPool, exchange_name: String) -> Self {
26 Self {
27 pool,
28 exchange_name,
29 }
30 }
31
32 pub async fn initialize_database(&self) -> Result<(), sqlx::Error> {
34 info!("Initializing market database tables...");
35
36 sqlx::query(
38 r#"
39 CREATE TABLE IF NOT EXISTS market_hierarchy_nodes (
40 id VARCHAR(255) PRIMARY KEY,
41 name VARCHAR(500) NOT NULL,
42 parent_id VARCHAR(255) REFERENCES market_hierarchy_nodes(id),
43 exchange VARCHAR(50) NOT NULL,
44 level INTEGER NOT NULL DEFAULT 0,
45 path TEXT NOT NULL,
46 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
47 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
48 )
49 "#,
50 )
51 .execute(&self.pool)
52 .await?;
53
54 sqlx::query(
56 r#"
57 CREATE TABLE IF NOT EXISTS market_instruments (
58 epic VARCHAR(255) PRIMARY KEY,
59 instrument_name VARCHAR(500) NOT NULL,
60 instrument_type VARCHAR(100) NOT NULL,
61 node_id VARCHAR(255) NOT NULL REFERENCES market_hierarchy_nodes(id),
62 exchange VARCHAR(50) NOT NULL,
63 expiry VARCHAR(50) NOT NULL DEFAULT '',
64 high_limit_price DOUBLE PRECISION,
65 low_limit_price DOUBLE PRECISION,
66 market_status VARCHAR(50) NOT NULL,
67 net_change DOUBLE PRECISION,
68 percentage_change DOUBLE PRECISION,
69 update_time VARCHAR(50),
70 update_time_utc TIMESTAMPTZ,
71 bid DOUBLE PRECISION,
72 offer DOUBLE PRECISION,
73 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
74 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
75 )
76 "#,
77 )
78 .execute(&self.pool)
79 .await?;
80
81 let hierarchy_indexes = [
83 "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_parent_id ON market_hierarchy_nodes(parent_id)",
84 "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_exchange ON market_hierarchy_nodes(exchange)",
85 "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_level ON market_hierarchy_nodes(level)",
86 "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_path ON market_hierarchy_nodes USING gin(to_tsvector('english', path))",
87 "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_name ON market_hierarchy_nodes USING gin(to_tsvector('english', name))",
88 ];
89
90 for index_sql in hierarchy_indexes {
91 sqlx::query(index_sql).execute(&self.pool).await?;
92 }
93
94 let instrument_indexes = [
96 "CREATE INDEX IF NOT EXISTS idx_market_instruments_node_id ON market_instruments(node_id)",
97 "CREATE INDEX IF NOT EXISTS idx_market_instruments_exchange ON market_instruments(exchange)",
98 "CREATE INDEX IF NOT EXISTS idx_market_instruments_type ON market_instruments(instrument_type)",
99 "CREATE INDEX IF NOT EXISTS idx_market_instruments_status ON market_instruments(market_status)",
100 "CREATE INDEX IF NOT EXISTS idx_market_instruments_name ON market_instruments USING gin(to_tsvector('english', instrument_name))",
101 "CREATE INDEX IF NOT EXISTS idx_market_instruments_epic ON market_instruments(epic)",
102 "CREATE INDEX IF NOT EXISTS idx_market_instruments_expiry ON market_instruments(expiry)",
103 ];
104
105 for index_sql in instrument_indexes {
106 sqlx::query(index_sql).execute(&self.pool).await?;
107 }
108
109 sqlx::query(
111 r#"
112 CREATE OR REPLACE FUNCTION update_updated_at_column()
113 RETURNS TRIGGER AS $$
114 BEGIN
115 NEW.updated_at = NOW();
116 RETURN NEW;
117 END;
118 $$ language 'plpgsql'
119 "#,
120 )
121 .execute(&self.pool)
122 .await?;
123
124 sqlx::query("DROP TRIGGER IF EXISTS update_market_hierarchy_nodes_updated_at ON market_hierarchy_nodes")
126 .execute(&self.pool)
127 .await?;
128
129 sqlx::query(
130 r#"
131 CREATE TRIGGER update_market_hierarchy_nodes_updated_at
132 BEFORE UPDATE ON market_hierarchy_nodes
133 FOR EACH ROW
134 EXECUTE FUNCTION update_updated_at_column()
135 "#,
136 )
137 .execute(&self.pool)
138 .await?;
139
140 sqlx::query(
141 "DROP TRIGGER IF EXISTS update_market_instruments_updated_at ON market_instruments",
142 )
143 .execute(&self.pool)
144 .await?;
145
146 sqlx::query(
147 r#"
148 CREATE TRIGGER update_market_instruments_updated_at
149 BEFORE UPDATE ON market_instruments
150 FOR EACH ROW
151 EXECUTE FUNCTION update_updated_at_column()
152 "#,
153 )
154 .execute(&self.pool)
155 .await?;
156
157 info!("Market database tables initialized successfully");
158 Ok(())
159 }
160
161 pub async fn store_market_hierarchy(
174 &self,
175 hierarchy: &[MarketNode],
176 ) -> Result<(), sqlx::Error> {
177 info!(
178 "Storing market hierarchy with {} top-level nodes",
179 hierarchy.len()
180 );
181
182 let (node_capacity, instrument_capacity) = count_hierarchy(hierarchy);
188 let mut all_nodes: Vec<MarketHierarchyNode> = Vec::with_capacity(node_capacity);
189 let mut all_instruments: Vec<MarketInstrument> = Vec::with_capacity(instrument_capacity);
190
191 for node in hierarchy {
192 let (nodes, instruments) = self.process_node_recursive(node, None, 0, "").await?;
193 all_nodes.extend(nodes);
194 all_instruments.extend(instruments);
195 }
196
197 let nodes = dedupe_by_key(all_nodes, |node| &node.id);
203 let instruments = dedupe_by_key(all_instruments, |instrument| &instrument.epic);
204
205 let node_count = nodes.len();
206 let instrument_count = instruments.len();
207
208 let mut tx = self.pool.begin().await?;
210
211 sqlx::query("DELETE FROM market_instruments WHERE exchange = $1")
213 .bind(&self.exchange_name)
214 .execute(&mut *tx)
215 .await?;
216
217 sqlx::query("DELETE FROM market_hierarchy_nodes WHERE exchange = $1")
218 .bind(&self.exchange_name)
219 .execute(&mut *tx)
220 .await?;
221
222 for chunk in nodes.chunks(HIERARCHY_INSERT_BATCH_SIZE) {
225 insert_hierarchy_nodes_batch(&mut tx, chunk).await?;
226 }
227
228 for chunk in instruments.chunks(HIERARCHY_INSERT_BATCH_SIZE) {
229 insert_market_instruments_batch(&mut tx, chunk).await?;
230 }
231
232 tx.commit().await?;
234
235 info!(
236 "Successfully stored {} hierarchy nodes and {} instruments",
237 node_count, instrument_count
238 );
239 Ok(())
240 }
241
242 pub async fn store_filtered_market_nodes(
246 &self,
247 hierarchy: &[MarketNode],
248 symbol_map: &HashMap<&str, &str>,
249 table_name: &str,
250 ) -> Result<(), sqlx::Error> {
251 info!(
252 "Storing filtered market nodes to table '{}' with {} top-level nodes",
253 table_name,
254 hierarchy.len()
255 );
256
257 let mut tx = self.pool.begin().await?;
259
260 let create_table_sql = format!(
262 r#"
263 CREATE TABLE IF NOT EXISTS {} (
264 epic VARCHAR(255) PRIMARY KEY,
265 instrumentName TEXT NOT NULL,
266 instrumentType VARCHAR(50) NOT NULL,
267 expiry VARCHAR(50),
268 lastUpdateUTC TIMESTAMP,
269 symbol VARCHAR(50)
270 )
271 "#,
272 table_name
273 );
274
275 tx.execute(sqlx::query(AssertSqlSafe(create_table_sql)))
278 .await?;
279
280 let mut inserted_count = 0;
283
284 for node in hierarchy {
286 inserted_count += self
287 .process_filtered_node_recursive(node, symbol_map, table_name, &mut tx)
288 .await?;
289 }
290
291 tx.commit().await?;
293
294 info!(
295 "Successfully stored {} filtered instruments in table '{}'",
296 inserted_count, table_name
297 );
298 Ok(())
299 }
300
301 fn process_filtered_node_recursive<'a>(
303 &'a self,
304 node: &'a MarketNode,
305 symbol_map: &'a HashMap<&str, &str>,
306 table_name: &'a str,
307 tx: &'a mut sqlx::Transaction<'_, sqlx::Postgres>,
308 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i32, sqlx::Error>> + 'a>> {
309 Box::pin(async move {
310 let mut count = 0;
311
312 for market in &node.markets {
314 if self.is_valid_epic_format(&market.epic) {
315 let symbol = self.find_symbol_for_market(&market.instrument_name, symbol_map);
316 self.insert_filtered_market(market, &symbol, table_name, tx)
317 .await?;
318 count += 1;
319 }
320 }
321
322 for child in &node.children {
324 count += self
325 .process_filtered_node_recursive(child, symbol_map, table_name, tx)
326 .await?;
327 }
328
329 Ok(count)
330 })
331 }
332
333 pub fn is_valid_epic_format(&self, epic: &str) -> bool {
335 epic.matches('.').count() == 4
336 }
337
338 pub fn find_symbol_for_market(
340 &self,
341 instrument_name: &str,
342 symbol_map: &HashMap<&str, &str>,
343 ) -> String {
344 let name_lower = instrument_name.to_lowercase();
345
346 for (key, value) in symbol_map {
347 if name_lower.contains(&key.to_lowercase()) {
348 return value.to_string();
349 }
350 }
351
352 "UNKNOWN".to_string()
354 }
355
356 pub fn convert_update_time(&self, update_time: &Option<String>) -> Option<DateTime<Utc>> {
358 if let Some(time_str) = update_time
359 && let Ok(timestamp_ms) = time_str.parse::<i64>()
360 {
361 let timestamp_secs = timestamp_ms / 1000;
362 let nanosecs = ((timestamp_ms % 1000) * 1_000_000) as u32;
363
364 return DateTime::from_timestamp(timestamp_secs, nanosecs);
365 }
366 None
367 }
368
369 async fn insert_filtered_market(
371 &self,
372 market: &MarketData,
373 symbol: &str,
374 table_name: &str,
375 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
376 ) -> Result<(), sqlx::Error> {
377 let last_update_utc = self.convert_update_time(&market.update_time);
378
379 let insert_sql = format!(
380 r#"
381 INSERT INTO {} (epic, instrumentName, instrumentType, expiry, lastUpdateUTC, symbol)
382 VALUES ($1, $2, $3, $4, $5, $6)
383 ON CONFLICT (epic) DO UPDATE SET
384 instrumentName = EXCLUDED.instrumentName,
385 instrumentType = EXCLUDED.instrumentType,
386 expiry = EXCLUDED.expiry,
387 lastUpdateUTC = EXCLUDED.lastUpdateUTC,
388 symbol = EXCLUDED.symbol
389 "#,
390 table_name
391 );
392
393 tx.execute(
394 sqlx::query(AssertSqlSafe(insert_sql))
396 .bind(&market.epic)
397 .bind(&market.instrument_name)
398 .bind(format!("{:?}", market.instrument_type))
399 .bind(&market.expiry)
400 .bind(last_update_utc)
401 .bind(symbol),
402 )
403 .await?;
404
405 Ok(())
406 }
407
408 #[allow(clippy::type_complexity)]
410 fn process_node_recursive<'a>(
411 &'a self,
412 node: &'a MarketNode,
413 parent_id: Option<&'a str>,
414 level: i32,
415 parent_path: &'a str,
416 ) -> std::pin::Pin<
417 Box<
418 dyn Future<
419 Output = Result<(Vec<MarketHierarchyNode>, Vec<MarketInstrument>), sqlx::Error>,
420 > + 'a,
421 >,
422 > {
423 Box::pin(async move {
424 let mut all_nodes = Vec::new();
425 let mut all_instruments = Vec::new();
426
427 let current_path = MarketHierarchyNode::build_path(
429 if parent_path.is_empty() {
430 None
431 } else {
432 Some(parent_path)
433 },
434 &node.name,
435 );
436
437 let current_node = MarketHierarchyNode::new(
439 node.id.clone(),
440 node.name.clone(),
441 parent_id.map(|s| s.to_string()),
442 self.exchange_name.clone(),
443 level,
444 current_path.clone(),
445 );
446
447 all_nodes.push(current_node);
448
449 for market in &node.markets {
451 let mut instrument = self.convert_market_data_to_instrument(market, &node.id);
452 instrument.parse_update_time_utc();
453 all_instruments.push(instrument);
454 }
455
456 for child in &node.children {
458 let (child_nodes, child_instruments) = self
459 .process_node_recursive(child, Some(&node.id), level + 1, ¤t_path)
460 .await?;
461 all_nodes.extend(child_nodes);
462 all_instruments.extend(child_instruments);
463 }
464
465 Ok((all_nodes, all_instruments))
466 })
467 }
468
469 pub fn convert_market_data_to_instrument(
471 &self,
472 market: &MarketData,
473 node_id: &str,
474 ) -> MarketInstrument {
475 let instrument_type = serde_json::to_value(market.instrument_type)
484 .ok()
485 .and_then(|v| v.as_str().map(str::to_owned))
486 .unwrap_or_else(|| "UNKNOWN".to_string());
487
488 let mut instrument = MarketInstrument::new(
489 market.epic.clone(),
490 market.instrument_name.clone(),
491 instrument_type,
492 node_id.to_string(),
493 self.exchange_name.clone(),
494 );
495
496 instrument.expiry = market.expiry.clone();
497 instrument.high_limit_price = market.high_limit_price;
498 instrument.low_limit_price = market.low_limit_price;
499 instrument.market_status = market.market_status.clone();
500 instrument.net_change = market.net_change;
501 instrument.percentage_change = market.percentage_change;
502 instrument.update_time = market.update_time.clone();
503 instrument.bid = market.bid;
504 instrument.offer = market.offer;
505
506 instrument
507 }
508
509 pub async fn get_market_hierarchy(&self) -> Result<Vec<MarketHierarchyNode>, sqlx::Error> {
511 let nodes = sqlx::query_as::<_, MarketHierarchyNode>(
512 "SELECT * FROM market_hierarchy_nodes WHERE exchange = $1 ORDER BY level, name",
513 )
514 .bind(&self.exchange_name)
515 .fetch_all(&self.pool)
516 .await?;
517
518 Ok(nodes)
519 }
520
521 pub async fn get_instruments_by_node(
523 &self,
524 node_id: &str,
525 ) -> Result<Vec<MarketInstrument>, sqlx::Error> {
526 let instruments = sqlx::query_as::<_, MarketInstrument>(
527 "SELECT * FROM market_instruments WHERE node_id = $1 AND exchange = $2 ORDER BY instrument_name",
528 )
529 .bind(node_id)
530 .bind(&self.exchange_name)
531 .fetch_all(&self.pool)
532 .await?;
533
534 Ok(instruments)
535 }
536
537 pub async fn search_instruments(
539 &self,
540 search_term: &str,
541 ) -> Result<Vec<MarketInstrument>, sqlx::Error> {
542 let instruments = sqlx::query_as::<_, MarketInstrument>(
543 r#"
544 SELECT * FROM market_instruments
545 WHERE exchange = $1
546 AND (
547 instrument_name ILIKE $2
548 OR epic ILIKE $2
549 OR to_tsvector('english', instrument_name) @@ plainto_tsquery('english', $3)
550 )
551 ORDER BY instrument_name
552 LIMIT 100
553 "#,
554 )
555 .bind(&self.exchange_name)
556 .bind(format!("%{search_term}%"))
557 .bind(search_term)
558 .fetch_all(&self.pool)
559 .await?;
560
561 Ok(instruments)
562 }
563
564 pub async fn get_statistics(&self) -> Result<DatabaseStatistics, sqlx::Error> {
566 let node_count: i64 =
567 sqlx::query_scalar("SELECT COUNT(*) FROM market_hierarchy_nodes WHERE exchange = $1")
568 .bind(&self.exchange_name)
569 .fetch_one(&self.pool)
570 .await?;
571
572 let instrument_count: i64 =
573 sqlx::query_scalar("SELECT COUNT(*) FROM market_instruments WHERE exchange = $1")
574 .bind(&self.exchange_name)
575 .fetch_one(&self.pool)
576 .await?;
577
578 let instrument_types: Vec<(String, i64)> = sqlx::query(
579 "SELECT instrument_type, COUNT(*) as count FROM market_instruments WHERE exchange = $1 GROUP BY instrument_type ORDER BY count DESC",
580 )
581 .bind(&self.exchange_name)
582 .fetch_all(&self.pool)
583 .await?
584 .into_iter()
585 .map(|row| (row.get::<String, _>("instrument_type"), row.get::<i64, _>("count")))
586 .collect();
587
588 let max_depth: i32 = sqlx::query_scalar(
589 "SELECT COALESCE(MAX(level), 0) FROM market_hierarchy_nodes WHERE exchange = $1",
590 )
591 .bind(&self.exchange_name)
592 .fetch_one(&self.pool)
593 .await?;
594
595 Ok(DatabaseStatistics {
596 exchange: self.exchange_name.clone(),
597 node_count,
598 instrument_count,
599 instrument_types,
600 max_hierarchy_depth: max_depth,
601 })
602 }
603}
604
605fn count_hierarchy(nodes: &[MarketNode]) -> (usize, usize) {
608 let mut node_count = 0usize;
609 let mut instrument_count = 0usize;
610 for node in nodes {
611 node_count += 1;
612 instrument_count += node.markets.len();
613 let (child_nodes, child_instruments) = count_hierarchy(&node.children);
614 node_count += child_nodes;
615 instrument_count += child_instruments;
616 }
617 (node_count, instrument_count)
618}
619
620fn dedupe_by_key<T, F>(items: Vec<T>, key: F) -> Vec<T>
628where
629 F: Fn(&T) -> &str,
630{
631 let mut index: HashMap<String, usize> = HashMap::with_capacity(items.len());
632 let mut deduped: Vec<T> = Vec::with_capacity(items.len());
633 for item in items {
634 let key_ref = key(&item);
638 if let Some(&i) = index.get(key_ref) {
639 deduped[i] = item;
640 } else {
641 let owned_key = key_ref.to_owned();
642 index.insert(owned_key, deduped.len());
643 deduped.push(item);
644 }
645 }
646 deduped
647}
648
649async fn insert_hierarchy_nodes_batch(
654 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
655 nodes: &[MarketHierarchyNode],
656) -> Result<(), sqlx::Error> {
657 if nodes.is_empty() {
658 return Ok(());
659 }
660
661 let len = nodes.len();
662 let mut ids: Vec<String> = Vec::with_capacity(len);
663 let mut names: Vec<String> = Vec::with_capacity(len);
664 let mut parent_ids: Vec<Option<String>> = Vec::with_capacity(len);
665 let mut exchanges: Vec<String> = Vec::with_capacity(len);
666 let mut levels: Vec<i32> = Vec::with_capacity(len);
667 let mut paths: Vec<String> = Vec::with_capacity(len);
668 let mut created_ats: Vec<DateTime<Utc>> = Vec::with_capacity(len);
669 let mut updated_ats: Vec<DateTime<Utc>> = Vec::with_capacity(len);
670
671 for node in nodes {
672 ids.push(node.id.clone());
673 names.push(node.name.clone());
674 parent_ids.push(node.parent_id.clone());
675 exchanges.push(node.exchange.clone());
676 levels.push(node.level);
677 paths.push(node.path.clone());
678 created_ats.push(node.created_at);
679 updated_ats.push(node.updated_at);
680 }
681
682 tx.execute(
683 sqlx::query(
684 r#"
685 INSERT INTO market_hierarchy_nodes
686 (id, name, parent_id, exchange, level, path, created_at, updated_at)
687 SELECT * FROM UNNEST(
688 $1::text[], $2::text[], $3::text[], $4::text[],
689 $5::int4[], $6::text[], $7::timestamptz[], $8::timestamptz[]
690 )
691 ON CONFLICT (id) DO UPDATE SET
692 name = EXCLUDED.name,
693 parent_id = EXCLUDED.parent_id,
694 exchange = EXCLUDED.exchange,
695 level = EXCLUDED.level,
696 path = EXCLUDED.path,
697 updated_at = EXCLUDED.updated_at
698 "#,
699 )
700 .bind(ids)
701 .bind(names)
702 .bind(parent_ids)
703 .bind(exchanges)
704 .bind(levels)
705 .bind(paths)
706 .bind(created_ats)
707 .bind(updated_ats),
708 )
709 .await?;
710
711 Ok(())
712}
713
714async fn insert_market_instruments_batch(
719 tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
720 instruments: &[MarketInstrument],
721) -> Result<(), sqlx::Error> {
722 if instruments.is_empty() {
723 return Ok(());
724 }
725
726 let len = instruments.len();
727 let mut epics: Vec<String> = Vec::with_capacity(len);
728 let mut instrument_names: Vec<String> = Vec::with_capacity(len);
729 let mut instrument_types: Vec<String> = Vec::with_capacity(len);
730 let mut node_ids: Vec<String> = Vec::with_capacity(len);
731 let mut exchanges: Vec<String> = Vec::with_capacity(len);
732 let mut expiries: Vec<String> = Vec::with_capacity(len);
733 let mut high_limit_prices: Vec<Option<f64>> = Vec::with_capacity(len);
734 let mut low_limit_prices: Vec<Option<f64>> = Vec::with_capacity(len);
735 let mut market_statuses: Vec<String> = Vec::with_capacity(len);
736 let mut net_changes: Vec<Option<f64>> = Vec::with_capacity(len);
737 let mut percentage_changes: Vec<Option<f64>> = Vec::with_capacity(len);
738 let mut update_times: Vec<Option<String>> = Vec::with_capacity(len);
739 let mut update_time_utcs: Vec<Option<DateTime<Utc>>> = Vec::with_capacity(len);
740 let mut bids: Vec<Option<f64>> = Vec::with_capacity(len);
741 let mut offers: Vec<Option<f64>> = Vec::with_capacity(len);
742 let mut created_ats: Vec<DateTime<Utc>> = Vec::with_capacity(len);
743 let mut updated_ats: Vec<DateTime<Utc>> = Vec::with_capacity(len);
744
745 for instrument in instruments {
746 epics.push(instrument.epic.clone());
747 instrument_names.push(instrument.instrument_name.clone());
748 instrument_types.push(instrument.instrument_type.clone());
749 node_ids.push(instrument.node_id.clone());
750 exchanges.push(instrument.exchange.clone());
751 expiries.push(instrument.expiry.clone());
752 high_limit_prices.push(instrument.high_limit_price);
753 low_limit_prices.push(instrument.low_limit_price);
754 market_statuses.push(instrument.market_status.clone());
755 net_changes.push(instrument.net_change);
756 percentage_changes.push(instrument.percentage_change);
757 update_times.push(instrument.update_time.clone());
758 update_time_utcs.push(instrument.update_time_utc);
759 bids.push(instrument.bid);
760 offers.push(instrument.offer);
761 created_ats.push(instrument.created_at);
762 updated_ats.push(instrument.updated_at);
763 }
764
765 tx.execute(
766 sqlx::query(
767 r#"
768 INSERT INTO market_instruments
769 (epic, instrument_name, instrument_type, node_id, exchange, expiry,
770 high_limit_price, low_limit_price, market_status, net_change,
771 percentage_change, update_time, update_time_utc, bid, offer,
772 created_at, updated_at)
773 SELECT * FROM UNNEST(
774 $1::text[], $2::text[], $3::text[], $4::text[], $5::text[], $6::text[],
775 $7::float8[], $8::float8[], $9::text[], $10::float8[], $11::float8[],
776 $12::text[], $13::timestamptz[], $14::float8[], $15::float8[],
777 $16::timestamptz[], $17::timestamptz[]
778 )
779 ON CONFLICT (epic) DO UPDATE SET
780 instrument_name = EXCLUDED.instrument_name,
781 instrument_type = EXCLUDED.instrument_type,
782 node_id = EXCLUDED.node_id,
783 exchange = EXCLUDED.exchange,
784 expiry = EXCLUDED.expiry,
785 high_limit_price = EXCLUDED.high_limit_price,
786 low_limit_price = EXCLUDED.low_limit_price,
787 market_status = EXCLUDED.market_status,
788 net_change = EXCLUDED.net_change,
789 percentage_change = EXCLUDED.percentage_change,
790 update_time = EXCLUDED.update_time,
791 update_time_utc = EXCLUDED.update_time_utc,
792 bid = EXCLUDED.bid,
793 offer = EXCLUDED.offer,
794 updated_at = EXCLUDED.updated_at
795 "#,
796 )
797 .bind(epics)
798 .bind(instrument_names)
799 .bind(instrument_types)
800 .bind(node_ids)
801 .bind(exchanges)
802 .bind(expiries)
803 .bind(high_limit_prices)
804 .bind(low_limit_prices)
805 .bind(market_statuses)
806 .bind(net_changes)
807 .bind(percentage_changes)
808 .bind(update_times)
809 .bind(update_time_utcs)
810 .bind(bids)
811 .bind(offers)
812 .bind(created_ats)
813 .bind(updated_ats),
814 )
815 .await?;
816
817 Ok(())
818}
819
820#[derive(Debug, Clone)]
822pub struct DatabaseStatistics {
823 pub exchange: String,
825 pub node_count: i64,
827 pub instrument_count: i64,
829 pub instrument_types: Vec<(String, i64)>,
831 pub max_hierarchy_depth: i32,
833}
834
835impl DatabaseStatistics {
836 pub fn print_summary(&self) {
838 info!("=== Market Database Statistics for {} ===", self.exchange);
839 info!("Hierarchy nodes: {}", self.node_count);
840 info!("Market instruments: {}", self.instrument_count);
841 info!("Maximum hierarchy depth: {}", self.max_hierarchy_depth);
842 info!("Instrument types:");
843 for (instrument_type, count) in &self.instrument_types {
844 info!(" {}: {}", instrument_type, count);
845 }
846 }
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use crate::presentation::instrument::InstrumentType;
853 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
854
855 fn lazy_service() -> MarketDatabaseService {
861 let pool = PgPoolOptions::new().connect_lazy_with(PgConnectOptions::new());
862 MarketDatabaseService::new(pool, "IG".to_string())
863 }
864
865 fn market_data_with_type(instrument_type: InstrumentType) -> MarketData {
866 MarketData {
867 epic: "IX.D.DAX.DAILY.IP".to_string(),
868 instrument_name: "Germany 40".to_string(),
869 instrument_type,
870 expiry: "DFB".to_string(),
871 high_limit_price: Some(20000.0),
872 low_limit_price: Some(5000.0),
873 market_status: "TRADEABLE".to_string(),
874 net_change: Some(100.5),
875 percentage_change: Some(0.65),
876 update_time: Some("2023-12-01T10:30:00".to_string()),
877 update_time_utc: Some("2023-12-01T10:30:00Z".to_string()),
878 bid: Some(15450.2),
879 offer: Some(15451.8),
880 }
881 }
882
883 #[tokio::test]
884 async fn test_convert_market_data_to_instrument_maps_fields() {
885 let service = lazy_service();
886 let market_data = market_data_with_type(InstrumentType::Indices);
887
888 let instrument = service.convert_market_data_to_instrument(&market_data, "node_123");
889
890 assert_eq!(instrument.epic, "IX.D.DAX.DAILY.IP");
891 assert_eq!(instrument.instrument_name, "Germany 40");
892 assert_eq!(instrument.instrument_type, "INDICES");
893 assert_eq!(instrument.node_id, "node_123");
894 assert_eq!(instrument.exchange, "IG");
895 assert_eq!(instrument.expiry, "DFB");
896 assert_eq!(instrument.high_limit_price, Some(20000.0));
897 assert_eq!(instrument.bid, Some(15450.2));
898 assert_eq!(instrument.offer, Some(15451.8));
899 }
900
901 #[tokio::test]
902 async fn test_convert_market_data_to_instrument_type_is_unquoted_wire_value() {
903 let service = lazy_service();
904
905 let opt = service
907 .convert_market_data_to_instrument(
908 &market_data_with_type(InstrumentType::OptCurrencies),
909 "node_1",
910 )
911 .instrument_type;
912 assert_eq!(opt, "OPT_CURRENCIES");
913 assert!(!opt.contains('"'), "wire value must not contain quotes");
914
915 let currencies = service
917 .convert_market_data_to_instrument(
918 &market_data_with_type(InstrumentType::Currencies),
919 "node_2",
920 )
921 .instrument_type;
922 assert_eq!(currencies, "CURRENCIES");
923 assert!(!currencies.contains('"'));
924 }
925
926 fn market_node(id: &str, children: Vec<MarketNode>, markets: usize) -> MarketNode {
927 MarketNode {
928 id: id.to_string(),
929 name: format!("Node {id}"),
930 children,
931 markets: (0..markets)
932 .map(|_| market_data_with_type(InstrumentType::Indices))
933 .collect(),
934 }
935 }
936
937 #[test]
938 fn test_count_hierarchy_counts_nodes_and_markets_recursively() {
939 let grandchild = market_node("gc", vec![], 3);
942 let child_a = market_node("a", vec![grandchild], 1);
943 let child_b = market_node("b", vec![], 0);
944 let root = market_node("root", vec![child_a, child_b], 2);
945
946 let (nodes, instruments) = count_hierarchy(&[root]);
947 assert_eq!(nodes, 4, "root + a + gc + b");
948 assert_eq!(instruments, 6, "2 + 1 + 3 + 0");
949 }
950
951 #[test]
952 fn test_count_hierarchy_empty_is_zero() {
953 assert_eq!(count_hierarchy(&[]), (0, 0));
954 }
955
956 #[test]
957 fn test_dedupe_by_key_keeps_first_position_last_data() {
958 let node = |id: &str, name: &str| MarketHierarchyNode {
959 id: id.to_string(),
960 name: name.to_string(),
961 parent_id: None,
962 exchange: "IG".to_string(),
963 level: 0,
964 path: format!("/{name}"),
965 created_at: Utc::now(),
966 updated_at: Utc::now(),
967 };
968
969 let input = vec![
970 node("parent", "Parent"),
971 node("child", "Child"),
972 node("parent", "Parent Updated"),
973 ];
974
975 let deduped = dedupe_by_key(input, |n| &n.id);
976
977 assert_eq!(deduped.len(), 2);
979 assert_eq!(deduped[0].id, "parent");
982 assert_eq!(deduped[1].id, "child");
983 assert_eq!(deduped[0].name, "Parent Updated");
985 }
986
987 #[test]
988 fn test_dedupe_by_key_no_duplicates_is_identity_order() {
989 let inst = |epic: &str| {
990 MarketInstrument::new(
991 epic.to_string(),
992 epic.to_string(),
993 "INDICES".to_string(),
994 "node".to_string(),
995 "IG".to_string(),
996 )
997 };
998
999 let deduped = dedupe_by_key(vec![inst("A"), inst("B"), inst("C")], |i| &i.epic);
1000 let epics: Vec<&str> = deduped.iter().map(|i| i.epic.as_str()).collect();
1001 assert_eq!(epics, ["A", "B", "C"]);
1002 }
1003}