Skip to main content

ig_client/storage/
market_database.rs

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
9/// Maximum number of rows sent per multi-row (`UNNEST`) `INSERT` statement.
10///
11/// Each `UNNEST` insert binds a fixed set of array parameters regardless of the
12/// row count, so this bounds per-statement memory / server-side work rather than
13/// a Postgres bind-parameter limit. A full-exchange refresh is a handful of
14/// round trips at this size instead of tens of thousands of single-row inserts.
15const HIERARCHY_INSERT_BATCH_SIZE: usize = 5_000;
16
17/// Service for managing market data persistence in PostgreSQL
18pub struct MarketDatabaseService {
19    pool: PgPool,
20    exchange_name: String,
21}
22
23impl MarketDatabaseService {
24    /// Creates a new MarketDatabaseService
25    pub fn new(pool: PgPool, exchange_name: String) -> Self {
26        Self {
27            pool,
28            exchange_name,
29        }
30    }
31
32    /// Initializes the database tables and triggers
33    pub async fn initialize_database(&self) -> Result<(), sqlx::Error> {
34        info!("Initializing market database tables...");
35
36        // Create market_hierarchy_nodes table
37        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        // Create market_instruments table
55        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        // Create indexes for market_hierarchy_nodes
82        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        // Create indexes for market_instruments
95        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        // Create update timestamp function
110        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        // Create triggers
125        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    /// Stores the complete market hierarchy in the database
162    ///
163    /// Full-refresh semantics for the exchange are preserved: existing rows are
164    /// deleted and the incoming hierarchy is re-inserted. The re-insert is
165    /// batched with multi-row `INSERT ... SELECT * FROM UNNEST(...)` statements
166    /// (a few round trips) instead of one prepared statement per row. All values
167    /// still travel as bound array parameters.
168    ///
169    /// # Errors
170    ///
171    /// Returns [`sqlx::Error`] if any statement fails; the transaction rolls back
172    /// on drop so a partial hierarchy is never observable.
173    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        // Flatten the hierarchy into row structs first (network-free). Pre-size
183        // the collected vectors from an exact traversal count so there are no
184        // intermediate reallocations. `process_node_recursive` yields nodes in
185        // topological order (parent before child), which the batched insert and
186        // the self-referential `parent_id` FK rely on.
187        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        // Dedupe by primary key. A multi-row `INSERT ... ON CONFLICT DO UPDATE`
198        // errors if the same key appears twice in one statement, so duplicates
199        // must be collapsed here. Keep the first occurrence's position (parents
200        // stay before children for the FK) but the last occurrence's data,
201        // matching the previous per-row `ON CONFLICT DO UPDATE` last-wins result.
202        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        // Start a transaction
209        let mut tx = self.pool.begin().await?;
210
211        // Clear existing data for this exchange
212        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        // Insert nodes before instruments (instruments FK-reference node ids),
223        // both chunked into bounded multi-row statements.
224        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        // Commit transaction
233        tx.commit().await?;
234
235        info!(
236            "Successfully stored {} hierarchy nodes and {} instruments",
237            node_count, instrument_count
238        );
239        Ok(())
240    }
241
242    /// Stores filtered market nodes with specific epic format in a custom table
243    /// Only processes MarketNode.children where epic has format "XX.X.XXXXXXX.XX.XX" (4 dots)
244    /// Adds a symbol field based on the provided HashMap mapping
245    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        // Start a transaction
258        let mut tx = self.pool.begin().await?;
259
260        // Create table if it doesn't exist
261        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        // SQL only varies by `table_name`, an identifier chosen by the library
276        // caller (identifiers cannot be bind parameters in Postgres).
277        tx.execute(sqlx::query(AssertSqlSafe(create_table_sql)))
278            .await?;
279
280        // Note: No DELETE operation - using UPSERT to update existing records
281
282        let mut inserted_count = 0;
283
284        // Process all nodes recursively to find filtered markets
285        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        // Commit transaction
292        tx.commit().await?;
293
294        info!(
295            "Successfully stored {} filtered instruments in table '{}'",
296            inserted_count, table_name
297        );
298        Ok(())
299    }
300
301    /// Recursively processes nodes to find and insert filtered markets
302    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            // Process markets in current node
313            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            // Process children recursively
323            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    /// Checks if epic has the required format: "XX.X.XXXXXXX.XX.XX" (exactly 4 dots)
334    pub fn is_valid_epic_format(&self, epic: &str) -> bool {
335        epic.matches('.').count() == 4
336    }
337
338    /// Finds the appropriate symbol for a market based on its name
339    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        // Default symbol if no match found
353        "UNKNOWN".to_string()
354    }
355
356    /// Converts updateTime from milliseconds to formatted timestamp
357    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    /// Inserts a filtered market into the custom table
370    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            // SQL only varies by `table_name` (see above); all values are bound.
395            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    /// Processes a node recursively to extract all nodes and instruments
409    #[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            // Build path for the current node
428            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            // Create current node
438            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            // Process markets in this node
450            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            // Process child nodes recursively
457            for child in &node.children {
458                let (child_nodes, child_instruments) = self
459                    .process_node_recursive(child, Some(&node.id), level + 1, &current_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    /// Converts MarketData to MarketInstrument
470    pub fn convert_market_data_to_instrument(
471        &self,
472        market: &MarketData,
473        node_id: &str,
474    ) -> MarketInstrument {
475        // Persist the serde wire value of the instrument type (e.g.
476        // `OPT_CURRENCIES`, `INDICES`) WITHOUT surrounding quotes. Using
477        // `format!("{:?}", ..)` would emit the `DebugPretty` (serde_json)
478        // rendering, which includes literal quotes (e.g. `"\"OPT_CURRENCIES\""`).
479        // `instrument_type` is NOT NULL and used for filtering, so never store an
480        // empty string: fall back to an explicit `UNKNOWN` sentinel if the enum
481        // ever fails to serialize to a string (structurally unreachable today,
482        // but a wrong-but-visible value beats a silent empty one).
483        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    /// Retrieves market hierarchy from the database
510    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    /// Retrieves market instruments for a specific node
522    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    /// Searches for instruments by name or epic
538    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    /// Gets statistics about the stored data
565    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
605/// Counts `(nodes, instruments)` across the whole hierarchy (including
606/// descendants) so the flattened storage vectors can be pre-allocated exactly.
607fn 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
620/// Dedupes `items` by a string primary key.
621///
622/// Keeps the first occurrence's position (preserving topological order for the
623/// self-referential `parent_id` FK) while adopting the last occurrence's data,
624/// reproducing the previous per-row `ON CONFLICT DO UPDATE` last-wins behaviour.
625/// This is required because a multi-row `INSERT ... ON CONFLICT DO UPDATE`
626/// errors if the same key appears twice in one statement.
627fn 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        // Look up by the borrowed `&str` (HashMap<String, _> keys are
635        // `Borrow<str>`) and only allocate an owned key when inserting a new
636        // one, so duplicates cost no allocation.
637        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
649/// Inserts a batch of hierarchy nodes with a single multi-row statement.
650///
651/// Values are bound as parallel arrays and expanded server-side via `UNNEST`;
652/// no value is interpolated into the SQL string.
653async 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
714/// Inserts a batch of market instruments with a single multi-row statement.
715///
716/// Values are bound as parallel arrays and expanded server-side via `UNNEST`;
717/// no value is interpolated into the SQL string.
718async 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/// Statistics about the stored market data
821#[derive(Debug, Clone)]
822pub struct DatabaseStatistics {
823    /// Name of the exchange for which statistics are collected
824    pub exchange: String,
825    /// Total number of hierarchy nodes in the database
826    pub node_count: i64,
827    /// Total number of market instruments stored
828    pub instrument_count: i64,
829    /// List of instrument types with their respective counts (type_name, count)
830    pub instrument_types: Vec<(String, i64)>,
831    /// Maximum depth level found in the market hierarchy tree
832    pub max_hierarchy_depth: i32,
833}
834
835impl DatabaseStatistics {
836    /// Prints a formatted summary of the statistics
837    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    /// Builds a `MarketDatabaseService` backed by a lazily-connected pool.
856    ///
857    /// `connect_lazy_with` never opens a socket (it only needs a Tokio
858    /// context), so pure conversion helpers (which do not touch the pool) can
859    /// be unit-tested without a live database and therefore run in CI.
860    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        // Renamed variant: must persist the serde wire value with no quotes.
906        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        // UPPERCASE-renamed variant.
916        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        // root(2 markets) -> child_a(1 market) -> grandchild(3 markets)
940        //                 -> child_b(0 markets)
941        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        // Two unique ids remain.
978        assert_eq!(deduped.len(), 2);
979        // First occurrence position preserved: "parent" is still index 0 so it
980        // is inserted before any child that references it (FK safety).
981        assert_eq!(deduped[0].id, "parent");
982        assert_eq!(deduped[1].id, "child");
983        // Last occurrence's data won.
984        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}