Skip to main content

ig_client/storage/
market_database.rs

1use crate::prelude::{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/// Service for managing market data persistence in PostgreSQL
10pub struct MarketDatabaseService {
11    pool: PgPool,
12    exchange_name: String,
13}
14
15impl MarketDatabaseService {
16    /// Creates a new MarketDatabaseService
17    pub fn new(pool: PgPool, exchange_name: String) -> Self {
18        Self {
19            pool,
20            exchange_name,
21        }
22    }
23
24    /// Initializes the database tables and triggers
25    pub async fn initialize_database(&self) -> Result<(), sqlx::Error> {
26        info!("Initializing market database tables...");
27
28        // Create market_hierarchy_nodes table
29        sqlx::query(
30            r#"
31            CREATE TABLE IF NOT EXISTS market_hierarchy_nodes (
32                id VARCHAR(255) PRIMARY KEY,
33                name VARCHAR(500) NOT NULL,
34                parent_id VARCHAR(255) REFERENCES market_hierarchy_nodes(id),
35                exchange VARCHAR(50) NOT NULL,
36                level INTEGER NOT NULL DEFAULT 0,
37                path TEXT NOT NULL,
38                created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
39                updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
40            )
41            "#,
42        )
43        .execute(&self.pool)
44        .await?;
45
46        // Create market_instruments table
47        sqlx::query(
48            r#"
49            CREATE TABLE IF NOT EXISTS market_instruments (
50                epic VARCHAR(255) PRIMARY KEY,
51                instrument_name VARCHAR(500) NOT NULL,
52                instrument_type VARCHAR(100) NOT NULL,
53                node_id VARCHAR(255) NOT NULL REFERENCES market_hierarchy_nodes(id),
54                exchange VARCHAR(50) NOT NULL,
55                expiry VARCHAR(50) NOT NULL DEFAULT '',
56                high_limit_price DOUBLE PRECISION,
57                low_limit_price DOUBLE PRECISION,
58                market_status VARCHAR(50) NOT NULL,
59                net_change DOUBLE PRECISION,
60                percentage_change DOUBLE PRECISION,
61                update_time VARCHAR(50),
62                update_time_utc TIMESTAMPTZ,
63                bid DOUBLE PRECISION,
64                offer DOUBLE PRECISION,
65                created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
66                updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
67            )
68            "#,
69        )
70        .execute(&self.pool)
71        .await?;
72
73        // Create indexes for market_hierarchy_nodes
74        let hierarchy_indexes = [
75            "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_parent_id ON market_hierarchy_nodes(parent_id)",
76            "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_exchange ON market_hierarchy_nodes(exchange)",
77            "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_level ON market_hierarchy_nodes(level)",
78            "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_path ON market_hierarchy_nodes USING gin(to_tsvector('english', path))",
79            "CREATE INDEX IF NOT EXISTS idx_market_hierarchy_name ON market_hierarchy_nodes USING gin(to_tsvector('english', name))",
80        ];
81
82        for index_sql in hierarchy_indexes {
83            sqlx::query(index_sql).execute(&self.pool).await?;
84        }
85
86        // Create indexes for market_instruments
87        let instrument_indexes = [
88            "CREATE INDEX IF NOT EXISTS idx_market_instruments_node_id ON market_instruments(node_id)",
89            "CREATE INDEX IF NOT EXISTS idx_market_instruments_exchange ON market_instruments(exchange)",
90            "CREATE INDEX IF NOT EXISTS idx_market_instruments_type ON market_instruments(instrument_type)",
91            "CREATE INDEX IF NOT EXISTS idx_market_instruments_status ON market_instruments(market_status)",
92            "CREATE INDEX IF NOT EXISTS idx_market_instruments_name ON market_instruments USING gin(to_tsvector('english', instrument_name))",
93            "CREATE INDEX IF NOT EXISTS idx_market_instruments_epic ON market_instruments(epic)",
94            "CREATE INDEX IF NOT EXISTS idx_market_instruments_expiry ON market_instruments(expiry)",
95        ];
96
97        for index_sql in instrument_indexes {
98            sqlx::query(index_sql).execute(&self.pool).await?;
99        }
100
101        // Create update timestamp function
102        sqlx::query(
103            r#"
104            CREATE OR REPLACE FUNCTION update_updated_at_column()
105            RETURNS TRIGGER AS $$
106            BEGIN
107                NEW.updated_at = NOW();
108                RETURN NEW;
109            END;
110            $$ language 'plpgsql'
111            "#,
112        )
113        .execute(&self.pool)
114        .await?;
115
116        // Create triggers
117        sqlx::query("DROP TRIGGER IF EXISTS update_market_hierarchy_nodes_updated_at ON market_hierarchy_nodes")
118            .execute(&self.pool)
119            .await?;
120
121        sqlx::query(
122            r#"
123            CREATE TRIGGER update_market_hierarchy_nodes_updated_at
124                BEFORE UPDATE ON market_hierarchy_nodes
125                FOR EACH ROW
126                EXECUTE FUNCTION update_updated_at_column()
127            "#,
128        )
129        .execute(&self.pool)
130        .await?;
131
132        sqlx::query(
133            "DROP TRIGGER IF EXISTS update_market_instruments_updated_at ON market_instruments",
134        )
135        .execute(&self.pool)
136        .await?;
137
138        sqlx::query(
139            r#"
140            CREATE TRIGGER update_market_instruments_updated_at
141                BEFORE UPDATE ON market_instruments
142                FOR EACH ROW
143                EXECUTE FUNCTION update_updated_at_column()
144            "#,
145        )
146        .execute(&self.pool)
147        .await?;
148
149        info!("Market database tables initialized successfully");
150        Ok(())
151    }
152
153    /// Stores the complete market hierarchy in the database
154    pub async fn store_market_hierarchy(
155        &self,
156        hierarchy: &[MarketNode],
157    ) -> Result<(), sqlx::Error> {
158        info!(
159            "Storing market hierarchy with {} top-level nodes",
160            hierarchy.len()
161        );
162
163        // Start a transaction
164        let mut tx = self.pool.begin().await?;
165
166        // Clear existing data for this exchange
167        sqlx::query("DELETE FROM market_instruments WHERE exchange = $1")
168            .bind(&self.exchange_name)
169            .execute(&mut *tx)
170            .await?;
171
172        sqlx::query("DELETE FROM market_hierarchy_nodes WHERE exchange = $1")
173            .bind(&self.exchange_name)
174            .execute(&mut *tx)
175            .await?;
176
177        // Store hierarchy nodes and instruments
178        let mut node_count = 0;
179        let mut instrument_count = 0;
180
181        for node in hierarchy {
182            let (nodes, instruments) = self.process_node_recursive(node, None, 0, "").await?;
183            node_count += nodes.len();
184            instrument_count += instruments.len();
185
186            // Insert nodes
187            for node in nodes {
188                self.insert_hierarchy_node(&mut tx, &node).await?;
189            }
190
191            // Insert instruments
192            for instrument in instruments {
193                self.insert_market_instrument(&mut tx, &instrument).await?;
194            }
195        }
196
197        // Commit transaction
198        tx.commit().await?;
199
200        info!(
201            "Successfully stored {} hierarchy nodes and {} instruments",
202            node_count, instrument_count
203        );
204        Ok(())
205    }
206
207    /// Stores filtered market nodes with specific epic format in a custom table
208    /// Only processes MarketNode.children where epic has format "XX.X.XXXXXXX.XX.XX" (4 dots)
209    /// Adds a symbol field based on the provided HashMap mapping
210    pub async fn store_filtered_market_nodes(
211        &self,
212        hierarchy: &[MarketNode],
213        symbol_map: &HashMap<&str, &str>,
214        table_name: &str,
215    ) -> Result<(), sqlx::Error> {
216        info!(
217            "Storing filtered market nodes to table '{}' with {} top-level nodes",
218            table_name,
219            hierarchy.len()
220        );
221
222        // Start a transaction
223        let mut tx = self.pool.begin().await?;
224
225        // Create table if it doesn't exist
226        let create_table_sql = format!(
227            r#"
228            CREATE TABLE IF NOT EXISTS {} (
229                epic VARCHAR(255) PRIMARY KEY,
230                instrumentName TEXT NOT NULL,
231                instrumentType VARCHAR(50) NOT NULL,
232                expiry VARCHAR(50),
233                lastUpdateUTC TIMESTAMP,
234                symbol VARCHAR(50)
235            )
236            "#,
237            table_name
238        );
239
240        // SQL only varies by `table_name`, an identifier chosen by the library
241        // caller (identifiers cannot be bind parameters in Postgres).
242        tx.execute(sqlx::query(AssertSqlSafe(create_table_sql)))
243            .await?;
244
245        // Note: No DELETE operation - using UPSERT to update existing records
246
247        let mut inserted_count = 0;
248
249        // Process all nodes recursively to find filtered markets
250        for node in hierarchy {
251            inserted_count += self
252                .process_filtered_node_recursive(node, symbol_map, table_name, &mut tx)
253                .await?;
254        }
255
256        // Commit transaction
257        tx.commit().await?;
258
259        info!(
260            "Successfully stored {} filtered instruments in table '{}'",
261            inserted_count, table_name
262        );
263        Ok(())
264    }
265
266    /// Recursively processes nodes to find and insert filtered markets
267    fn process_filtered_node_recursive<'a>(
268        &'a self,
269        node: &'a MarketNode,
270        symbol_map: &'a HashMap<&str, &str>,
271        table_name: &'a str,
272        tx: &'a mut sqlx::Transaction<'_, sqlx::Postgres>,
273    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<i32, sqlx::Error>> + 'a>> {
274        Box::pin(async move {
275            let mut count = 0;
276
277            // Process markets in current node
278            for market in &node.markets {
279                if self.is_valid_epic_format(&market.epic) {
280                    let symbol = self.find_symbol_for_market(&market.instrument_name, symbol_map);
281                    self.insert_filtered_market(market, &symbol, table_name, tx)
282                        .await?;
283                    count += 1;
284                }
285            }
286
287            // Process children recursively
288            for child in &node.children {
289                count += self
290                    .process_filtered_node_recursive(child, symbol_map, table_name, tx)
291                    .await?;
292            }
293
294            Ok(count)
295        })
296    }
297
298    /// Checks if epic has the required format: "XX.X.XXXXXXX.XX.XX" (exactly 4 dots)
299    pub fn is_valid_epic_format(&self, epic: &str) -> bool {
300        epic.matches('.').count() == 4
301    }
302
303    /// Finds the appropriate symbol for a market based on its name
304    pub fn find_symbol_for_market(
305        &self,
306        instrument_name: &str,
307        symbol_map: &HashMap<&str, &str>,
308    ) -> String {
309        let name_lower = instrument_name.to_lowercase();
310
311        for (key, value) in symbol_map {
312            if name_lower.contains(&key.to_lowercase()) {
313                return value.to_string();
314            }
315        }
316
317        // Default symbol if no match found
318        "UNKNOWN".to_string()
319    }
320
321    /// Converts updateTime from milliseconds to formatted timestamp
322    pub fn convert_update_time(&self, update_time: &Option<String>) -> Option<DateTime<Utc>> {
323        if let Some(time_str) = update_time
324            && let Ok(timestamp_ms) = time_str.parse::<i64>()
325        {
326            let timestamp_secs = timestamp_ms / 1000;
327            let nanosecs = ((timestamp_ms % 1000) * 1_000_000) as u32;
328
329            return DateTime::from_timestamp(timestamp_secs, nanosecs);
330        }
331        None
332    }
333
334    /// Inserts a filtered market into the custom table
335    async fn insert_filtered_market(
336        &self,
337        market: &MarketData,
338        symbol: &str,
339        table_name: &str,
340        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
341    ) -> Result<(), sqlx::Error> {
342        let last_update_utc = self.convert_update_time(&market.update_time);
343
344        let insert_sql = format!(
345            r#"
346            INSERT INTO {} (epic, instrumentName, instrumentType, expiry, lastUpdateUTC, symbol)
347            VALUES ($1, $2, $3, $4, $5, $6)
348            ON CONFLICT (epic) DO UPDATE SET
349                instrumentName = EXCLUDED.instrumentName,
350                instrumentType = EXCLUDED.instrumentType,
351                expiry = EXCLUDED.expiry,
352                lastUpdateUTC = EXCLUDED.lastUpdateUTC,
353                symbol = EXCLUDED.symbol
354            "#,
355            table_name
356        );
357
358        tx.execute(
359            // SQL only varies by `table_name` (see above); all values are bound.
360            sqlx::query(AssertSqlSafe(insert_sql))
361                .bind(&market.epic)
362                .bind(&market.instrument_name)
363                .bind(format!("{:?}", market.instrument_type))
364                .bind(&market.expiry)
365                .bind(last_update_utc)
366                .bind(symbol),
367        )
368        .await?;
369
370        Ok(())
371    }
372
373    /// Processes a node recursively to extract all nodes and instruments
374    #[allow(clippy::type_complexity)]
375    fn process_node_recursive<'a>(
376        &'a self,
377        node: &'a MarketNode,
378        parent_id: Option<&'a str>,
379        level: i32,
380        parent_path: &'a str,
381    ) -> std::pin::Pin<
382        Box<
383            dyn Future<
384                    Output = Result<(Vec<MarketHierarchyNode>, Vec<MarketInstrument>), sqlx::Error>,
385                > + 'a,
386        >,
387    > {
388        Box::pin(async move {
389            let mut all_nodes = Vec::new();
390            let mut all_instruments = Vec::new();
391
392            // Build path for the current node
393            let current_path = MarketHierarchyNode::build_path(
394                if parent_path.is_empty() {
395                    None
396                } else {
397                    Some(parent_path)
398                },
399                &node.name,
400            );
401
402            // Create current node
403            let current_node = MarketHierarchyNode::new(
404                node.id.clone(),
405                node.name.clone(),
406                parent_id.map(|s| s.to_string()),
407                self.exchange_name.clone(),
408                level,
409                current_path.clone(),
410            );
411
412            all_nodes.push(current_node);
413
414            // Process markets in this node
415            for market in &node.markets {
416                let mut instrument = self.convert_market_data_to_instrument(market, &node.id);
417                instrument.parse_update_time_utc();
418                all_instruments.push(instrument);
419            }
420
421            // Process child nodes recursively
422            for child in &node.children {
423                let (child_nodes, child_instruments) = self
424                    .process_node_recursive(child, Some(&node.id), level + 1, &current_path)
425                    .await?;
426                all_nodes.extend(child_nodes);
427                all_instruments.extend(child_instruments);
428            }
429
430            Ok((all_nodes, all_instruments))
431        })
432    }
433
434    /// Converts MarketData to MarketInstrument
435    pub fn convert_market_data_to_instrument(
436        &self,
437        market: &MarketData,
438        node_id: &str,
439    ) -> MarketInstrument {
440        let mut instrument = MarketInstrument::new(
441            market.epic.clone(),
442            market.instrument_name.clone(),
443            format!("{:?}", market.instrument_type).to_uppercase(),
444            node_id.to_string(),
445            self.exchange_name.clone(),
446        );
447
448        instrument.expiry = market.expiry.clone();
449        instrument.high_limit_price = market.high_limit_price;
450        instrument.low_limit_price = market.low_limit_price;
451        instrument.market_status = market.market_status.clone();
452        instrument.net_change = market.net_change;
453        instrument.percentage_change = market.percentage_change;
454        instrument.update_time = market.update_time.clone();
455        instrument.bid = market.bid;
456        instrument.offer = market.offer;
457
458        instrument
459    }
460
461    /// Inserts a hierarchy node into the database
462    async fn insert_hierarchy_node(
463        &self,
464        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
465        node: &MarketHierarchyNode,
466    ) -> Result<(), sqlx::Error> {
467        tx.execute(
468            sqlx::query(
469                r#"
470                INSERT INTO market_hierarchy_nodes 
471                (id, name, parent_id, exchange, level, path, created_at, updated_at)
472                VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
473                ON CONFLICT (id) DO UPDATE SET
474                    name = EXCLUDED.name,
475                    parent_id = EXCLUDED.parent_id,
476                    exchange = EXCLUDED.exchange,
477                    level = EXCLUDED.level,
478                    path = EXCLUDED.path,
479                    updated_at = EXCLUDED.updated_at
480                "#,
481            )
482            .bind(&node.id)
483            .bind(&node.name)
484            .bind(&node.parent_id)
485            .bind(&node.exchange)
486            .bind(node.level)
487            .bind(&node.path)
488            .bind(node.created_at)
489            .bind(node.updated_at),
490        )
491        .await?;
492
493        Ok(())
494    }
495
496    /// Inserts a market instrument into the database
497    async fn insert_market_instrument(
498        &self,
499        tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
500        instrument: &MarketInstrument,
501    ) -> Result<(), sqlx::Error> {
502        tx.execute(
503            sqlx::query(
504                r#"
505                INSERT INTO market_instruments 
506                (epic, instrument_name, instrument_type, node_id, exchange, expiry,
507                 high_limit_price, low_limit_price, market_status, net_change, 
508                 percentage_change, update_time, update_time_utc, bid, offer, 
509                 created_at, updated_at)
510                VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
511                ON CONFLICT (epic) DO UPDATE SET
512                    instrument_name = EXCLUDED.instrument_name,
513                    instrument_type = EXCLUDED.instrument_type,
514                    node_id = EXCLUDED.node_id,
515                    exchange = EXCLUDED.exchange,
516                    expiry = EXCLUDED.expiry,
517                    high_limit_price = EXCLUDED.high_limit_price,
518                    low_limit_price = EXCLUDED.low_limit_price,
519                    market_status = EXCLUDED.market_status,
520                    net_change = EXCLUDED.net_change,
521                    percentage_change = EXCLUDED.percentage_change,
522                    update_time = EXCLUDED.update_time,
523                    update_time_utc = EXCLUDED.update_time_utc,
524                    bid = EXCLUDED.bid,
525                    offer = EXCLUDED.offer,
526                    updated_at = EXCLUDED.updated_at
527                "#,
528            )
529            .bind(&instrument.epic)
530            .bind(&instrument.instrument_name)
531            .bind(&instrument.instrument_type)
532            .bind(&instrument.node_id)
533            .bind(&instrument.exchange)
534            .bind(&instrument.expiry)
535            .bind(instrument.high_limit_price)
536            .bind(instrument.low_limit_price)
537            .bind(&instrument.market_status)
538            .bind(instrument.net_change)
539            .bind(instrument.percentage_change)
540            .bind(&instrument.update_time)
541            .bind(instrument.update_time_utc)
542            .bind(instrument.bid)
543            .bind(instrument.offer)
544            .bind(instrument.created_at)
545            .bind(instrument.updated_at),
546        )
547        .await?;
548
549        Ok(())
550    }
551
552    /// Retrieves market hierarchy from the database
553    pub async fn get_market_hierarchy(&self) -> Result<Vec<MarketHierarchyNode>, sqlx::Error> {
554        let nodes = sqlx::query_as::<_, MarketHierarchyNode>(
555            "SELECT * FROM market_hierarchy_nodes WHERE exchange = $1 ORDER BY level, name",
556        )
557        .bind(&self.exchange_name)
558        .fetch_all(&self.pool)
559        .await?;
560
561        Ok(nodes)
562    }
563
564    /// Retrieves market instruments for a specific node
565    pub async fn get_instruments_by_node(
566        &self,
567        node_id: &str,
568    ) -> Result<Vec<MarketInstrument>, sqlx::Error> {
569        let instruments = sqlx::query_as::<_, MarketInstrument>(
570            "SELECT * FROM market_instruments WHERE node_id = $1 AND exchange = $2 ORDER BY instrument_name",
571        )
572        .bind(node_id)
573        .bind(&self.exchange_name)
574        .fetch_all(&self.pool)
575        .await?;
576
577        Ok(instruments)
578    }
579
580    /// Searches for instruments by name or epic
581    pub async fn search_instruments(
582        &self,
583        search_term: &str,
584    ) -> Result<Vec<MarketInstrument>, sqlx::Error> {
585        let instruments = sqlx::query_as::<_, MarketInstrument>(
586            r#"
587            SELECT * FROM market_instruments 
588            WHERE exchange = $1 
589            AND (
590                instrument_name ILIKE $2 
591                OR epic ILIKE $2
592                OR to_tsvector('english', instrument_name) @@ plainto_tsquery('english', $3)
593            )
594            ORDER BY instrument_name
595            LIMIT 100
596            "#,
597        )
598        .bind(&self.exchange_name)
599        .bind(format!("%{search_term}%"))
600        .bind(search_term)
601        .fetch_all(&self.pool)
602        .await?;
603
604        Ok(instruments)
605    }
606
607    /// Gets statistics about the stored data
608    pub async fn get_statistics(&self) -> Result<DatabaseStatistics, sqlx::Error> {
609        let node_count: i64 =
610            sqlx::query_scalar("SELECT COUNT(*) FROM market_hierarchy_nodes WHERE exchange = $1")
611                .bind(&self.exchange_name)
612                .fetch_one(&self.pool)
613                .await?;
614
615        let instrument_count: i64 =
616            sqlx::query_scalar("SELECT COUNT(*) FROM market_instruments WHERE exchange = $1")
617                .bind(&self.exchange_name)
618                .fetch_one(&self.pool)
619                .await?;
620
621        let instrument_types: Vec<(String, i64)> = sqlx::query(
622            "SELECT instrument_type, COUNT(*) as count FROM market_instruments WHERE exchange = $1 GROUP BY instrument_type ORDER BY count DESC",
623        )
624        .bind(&self.exchange_name)
625        .fetch_all(&self.pool)
626        .await?
627        .into_iter()
628        .map(|row| (row.get::<String, _>("instrument_type"), row.get::<i64, _>("count")))
629        .collect();
630
631        let max_depth: i32 = sqlx::query_scalar(
632            "SELECT COALESCE(MAX(level), 0) FROM market_hierarchy_nodes WHERE exchange = $1",
633        )
634        .bind(&self.exchange_name)
635        .fetch_one(&self.pool)
636        .await?;
637
638        Ok(DatabaseStatistics {
639            exchange: self.exchange_name.clone(),
640            node_count,
641            instrument_count,
642            instrument_types,
643            max_hierarchy_depth: max_depth,
644        })
645    }
646}
647
648/// Statistics about the stored market data
649#[derive(Debug, Clone)]
650pub struct DatabaseStatistics {
651    /// Name of the exchange for which statistics are collected
652    pub exchange: String,
653    /// Total number of hierarchy nodes in the database
654    pub node_count: i64,
655    /// Total number of market instruments stored
656    pub instrument_count: i64,
657    /// List of instrument types with their respective counts (type_name, count)
658    pub instrument_types: Vec<(String, i64)>,
659    /// Maximum depth level found in the market hierarchy tree
660    pub max_hierarchy_depth: i32,
661}
662
663impl DatabaseStatistics {
664    /// Prints a formatted summary of the statistics
665    pub fn print_summary(&self) {
666        info!("=== Market Database Statistics for {} ===", self.exchange);
667        info!("Hierarchy nodes: {}", self.node_count);
668        info!("Market instruments: {}", self.instrument_count);
669        info!("Maximum hierarchy depth: {}", self.max_hierarchy_depth);
670        info!("Instrument types:");
671        for (instrument_type, count) in &self.instrument_types {
672            info!("  {}: {}", instrument_type, count);
673        }
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::presentation::instrument::InstrumentType;
681
682    #[tokio::test]
683    #[ignore]
684    async fn test_convert_market_data_to_instrument() {
685        let service = MarketDatabaseService::new(
686            // This would be a real pool in actual tests
687            PgPool::connect("postgresql://test")
688                .await
689                .unwrap_or_else(|_| panic!("Test requires a PostgreSQL connection")),
690            "IG".to_string(),
691        );
692
693        let market_data = MarketData {
694            epic: "IX.D.DAX.DAILY.IP".to_string(),
695            instrument_name: "Germany 40".to_string(),
696            instrument_type: InstrumentType::Indices,
697            expiry: "DFB".to_string(),
698            high_limit_price: Some(20000.0),
699            low_limit_price: Some(5000.0),
700            market_status: "TRADEABLE".to_string(),
701            net_change: Some(100.5),
702            percentage_change: Some(0.65),
703            update_time: Some("2023-12-01T10:30:00".to_string()),
704            update_time_utc: Some("2023-12-01T10:30:00Z".to_string()),
705            bid: Some(15450.2),
706            offer: Some(15451.8),
707        };
708
709        let instrument = service.convert_market_data_to_instrument(&market_data, "node_123");
710
711        assert_eq!(instrument.epic, "IX.D.DAX.DAILY.IP");
712        assert_eq!(instrument.instrument_name, "Germany 40");
713        assert_eq!(instrument.instrument_type, "INDICES");
714        assert_eq!(instrument.node_id, "node_123");
715        assert_eq!(instrument.exchange, "IG");
716        assert_eq!(instrument.expiry, "DFB");
717        assert_eq!(instrument.high_limit_price, Some(20000.0));
718        assert_eq!(instrument.bid, Some(15450.2));
719        assert_eq!(instrument.offer, Some(15451.8));
720    }
721}