ig_client/storage/
market_persistence.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::FromRow;
4
5/// Represents a market hierarchy node in the database
6/// This structure is optimized for PostgreSQL storage with proper indexing
7#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
8pub struct MarketHierarchyNode {
9    /// Unique identifier for the node
10    pub id: String,
11    /// Human-readable name of the node
12    pub name: String,
13    /// Parent node ID (NULL for root nodes)
14    pub parent_id: Option<String>,
15    /// Exchange name (e.g., "IG")
16    pub exchange: String,
17    /// Depth level in the hierarchy (0 for root nodes)
18    pub level: i32,
19    /// Full path from root to this node (e.g., "/Indices/Europe/Germany")
20    pub path: String,
21    /// Timestamp when this record was created
22    pub created_at: DateTime<Utc>,
23    /// Timestamp when this record was last updated
24    pub updated_at: DateTime<Utc>,
25}
26
27/// Represents a market instrument in the database
28/// This structure is optimized for PostgreSQL storage with proper indexing
29#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
30pub struct MarketInstrument {
31    /// Unique identifier for the market (epic)
32    pub epic: String,
33    /// Human-readable name of the instrument
34    pub instrument_name: String,
35    /// Type of the instrument (e.g., "SHARES", "INDICES", "CURRENCIES")
36    pub instrument_type: String,
37    /// Node ID where this instrument belongs
38    pub node_id: String,
39    /// Exchange name (e.g., "IG")
40    pub exchange: String,
41    /// Expiry date of the instrument (empty string for perpetual instruments)
42    pub expiry: String,
43    /// Upper price limit for the market
44    pub high_limit_price: Option<f64>,
45    /// Lower price limit for the market
46    pub low_limit_price: Option<f64>,
47    /// Current status of the market
48    pub market_status: String,
49    /// Net change in price since previous close
50    pub net_change: Option<f64>,
51    /// Percentage change in price since previous close
52    pub percentage_change: Option<f64>,
53    /// Time of the last price update
54    pub update_time: Option<String>,
55    /// Time of the last price update in UTC
56    pub update_time_utc: Option<DateTime<Utc>>,
57    /// Current bid price
58    pub bid: Option<f64>,
59    /// Current offer/ask price
60    pub offer: Option<f64>,
61    /// Timestamp when this record was created
62    pub created_at: DateTime<Utc>,
63    /// Timestamp when this record was last updated
64    pub updated_at: DateTime<Utc>,
65}
66
67impl MarketInstrument {
68    /// Checks if the current financial instrument is a call option.
69    ///
70    /// A call option is a financial derivative that gives the holder the right (but not the obligation)
71    /// to buy an underlying asset at a specified price within a specified time period. This method checks
72    /// whether the instrument represented by this instance is a call option by inspecting the `instrument_name`
73    /// field.
74    ///
75    /// # Returns
76    ///
77    /// * `true` if the instrument's name contains the substring `"CALL"`, indicating it is a call option.
78    /// * `false` otherwise.
79    ///
80    pub fn is_call(&self) -> bool {
81        self.instrument_name.contains("CALL")
82    }
83
84    /// Checks if the financial instrument is a "PUT" option.
85    ///
86    /// This method examines the `instrument_name` field of the struct to determine
87    /// if it contains the substring "PUT". If the substring is found, the method
88    /// returns `true`, indicating that the instrument is categorized as a "PUT" option.
89    /// Otherwise, it returns `false`.
90    ///
91    /// # Returns
92    /// * `true` - If `instrument_name` contains the substring "PUT".
93    /// * `false` - If `instrument_name` does not contain the substring "PUT".
94    ///
95    pub fn is_put(&self) -> bool {
96        self.instrument_name.contains("PUT")
97    }
98}
99
100/// SQL DDL statements for creating the required tables
101pub const CREATE_MARKET_HIERARCHY_TABLE: &str = r#"
102CREATE TABLE IF NOT EXISTS market_hierarchy_nodes (
103    id VARCHAR(255) PRIMARY KEY,
104    name VARCHAR(500) NOT NULL,
105    parent_id VARCHAR(255) REFERENCES market_hierarchy_nodes(id),
106    exchange VARCHAR(50) NOT NULL,
107    level INTEGER NOT NULL DEFAULT 0,
108    path TEXT NOT NULL,
109    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
110    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
111);
112
113-- Indexes for efficient querying
114CREATE INDEX IF NOT EXISTS idx_market_hierarchy_parent_id ON market_hierarchy_nodes(parent_id);
115CREATE INDEX IF NOT EXISTS idx_market_hierarchy_exchange ON market_hierarchy_nodes(exchange);
116CREATE INDEX IF NOT EXISTS idx_market_hierarchy_level ON market_hierarchy_nodes(level);
117CREATE INDEX IF NOT EXISTS idx_market_hierarchy_path ON market_hierarchy_nodes USING gin(to_tsvector('english', path));
118CREATE INDEX IF NOT EXISTS idx_market_hierarchy_name ON market_hierarchy_nodes USING gin(to_tsvector('english', name));
119"#;
120
121/// SQL statement to create the market_instruments table with all necessary columns and indexes.
122/// This table stores detailed information about financial instruments including pricing data,
123/// market status, and relationships to hierarchy nodes.
124pub const CREATE_MARKET_INSTRUMENTS_TABLE: &str = r#"
125CREATE TABLE IF NOT EXISTS market_instruments (
126    epic VARCHAR(255) PRIMARY KEY,
127    instrument_name VARCHAR(500) NOT NULL,
128    instrument_type VARCHAR(100) NOT NULL,
129    node_id VARCHAR(255) NOT NULL REFERENCES market_hierarchy_nodes(id),
130    exchange VARCHAR(50) NOT NULL,
131    expiry VARCHAR(50) NOT NULL DEFAULT '',
132    high_limit_price DOUBLE PRECISION,
133    low_limit_price DOUBLE PRECISION,
134    market_status VARCHAR(50) NOT NULL,
135    net_change DOUBLE PRECISION,
136    percentage_change DOUBLE PRECISION,
137    update_time VARCHAR(50),
138    update_time_utc TIMESTAMPTZ,
139    bid DOUBLE PRECISION,
140    offer DOUBLE PRECISION,
141    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
142    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
143);
144
145-- Indexes for efficient querying
146CREATE INDEX IF NOT EXISTS idx_market_instruments_node_id ON market_instruments(node_id);
147CREATE INDEX IF NOT EXISTS idx_market_instruments_exchange ON market_instruments(exchange);
148CREATE INDEX IF NOT EXISTS idx_market_instruments_type ON market_instruments(instrument_type);
149CREATE INDEX IF NOT EXISTS idx_market_instruments_status ON market_instruments(market_status);
150CREATE INDEX IF NOT EXISTS idx_market_instruments_name ON market_instruments USING gin(to_tsvector('english', instrument_name));
151CREATE INDEX IF NOT EXISTS idx_market_instruments_epic ON market_instruments(epic);
152CREATE INDEX IF NOT EXISTS idx_market_instruments_expiry ON market_instruments(expiry);
153"#;
154
155/// Trigger to automatically update the updated_at timestamp
156pub const CREATE_UPDATE_TIMESTAMP_TRIGGER: &str = r#"
157-- Function to update the updated_at timestamp
158CREATE OR REPLACE FUNCTION update_updated_at_column()
159RETURNS TRIGGER AS $$
160BEGIN
161    NEW.updated_at = NOW();
162    RETURN NEW;
163END;
164$$ language 'plpgsql';
165
166-- Triggers for both tables
167DROP TRIGGER IF EXISTS update_market_hierarchy_nodes_updated_at ON market_hierarchy_nodes;
168CREATE TRIGGER update_market_hierarchy_nodes_updated_at
169    BEFORE UPDATE ON market_hierarchy_nodes
170    FOR EACH ROW
171    EXECUTE FUNCTION update_updated_at_column();
172
173DROP TRIGGER IF EXISTS update_market_instruments_updated_at ON market_instruments;
174CREATE TRIGGER update_market_instruments_updated_at
175    BEFORE UPDATE ON market_instruments
176    FOR EACH ROW
177    EXECUTE FUNCTION update_updated_at_column();
178"#;
179
180impl MarketHierarchyNode {
181    /// Creates a new MarketHierarchyNode
182    pub fn new(
183        id: String,
184        name: String,
185        parent_id: Option<String>,
186        exchange: String,
187        level: i32,
188        path: String,
189    ) -> Self {
190        let now = Utc::now();
191        Self {
192            id,
193            name,
194            parent_id,
195            exchange,
196            level,
197            path,
198            created_at: now,
199            updated_at: now,
200        }
201    }
202
203    /// Builds the full path for a node based on its parent path
204    pub fn build_path(parent_path: Option<&str>, node_name: &str) -> String {
205        match parent_path {
206            Some(parent) if !parent.is_empty() => format!("{parent}/{node_name}"),
207            _ => format!("/{node_name}"),
208        }
209    }
210}
211
212impl MarketInstrument {
213    /// Creates a new MarketInstrument
214    pub fn new(
215        epic: String,
216        instrument_name: String,
217        instrument_type: String,
218        node_id: String,
219        exchange: String,
220    ) -> Self {
221        let now = Utc::now();
222        Self {
223            epic,
224            instrument_name,
225            instrument_type,
226            node_id,
227            exchange,
228            expiry: String::new(),
229            high_limit_price: None,
230            low_limit_price: None,
231            market_status: String::new(),
232            net_change: None,
233            percentage_change: None,
234            update_time: None,
235            update_time_utc: None,
236            bid: None,
237            offer: None,
238            created_at: now,
239            updated_at: now,
240        }
241    }
242
243    /// Parses the update_time_utc from a string if available
244    pub fn parse_update_time_utc(&mut self) {
245        if let Some(ref time_str) = self.update_time
246            && let Ok(parsed_time) = DateTime::parse_from_rfc3339(time_str)
247        {
248            self.update_time_utc = Some(parsed_time.with_timezone(&Utc));
249        }
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_build_path() {
259        assert_eq!(MarketHierarchyNode::build_path(None, "Root"), "/Root");
260        assert_eq!(
261            MarketHierarchyNode::build_path(Some("/Root"), "Child"),
262            "/Root/Child"
263        );
264        assert_eq!(
265            MarketHierarchyNode::build_path(Some("/Root/Child"), "Grandchild"),
266            "/Root/Child/Grandchild"
267        );
268    }
269
270    #[test]
271    fn test_market_hierarchy_node_creation() {
272        let node = MarketHierarchyNode::new(
273            "test_id".to_string(),
274            "Test Node".to_string(),
275            Some("parent_id".to_string()),
276            "IG".to_string(),
277            1,
278            "/Test Node".to_string(),
279        );
280
281        assert_eq!(node.id, "test_id");
282        assert_eq!(node.name, "Test Node");
283        assert_eq!(node.parent_id, Some("parent_id".to_string()));
284        assert_eq!(node.exchange, "IG");
285        assert_eq!(node.level, 1);
286        assert_eq!(node.path, "/Test Node");
287    }
288
289    #[test]
290    fn test_market_instrument_creation() {
291        let mut instrument = MarketInstrument::new(
292            "IX.D.DAX.DAILY.IP".to_string(),
293            "Germany 40".to_string(),
294            "INDICES".to_string(),
295            "node_123".to_string(),
296            "IG".to_string(),
297        );
298
299        assert_eq!(instrument.epic, "IX.D.DAX.DAILY.IP");
300        assert_eq!(instrument.instrument_name, "Germany 40");
301        assert_eq!(instrument.instrument_type, "INDICES");
302        assert_eq!(instrument.node_id, "node_123");
303        assert_eq!(instrument.exchange, "IG");
304
305        // Test update_time_utc parsing
306        instrument.update_time = Some("2023-12-01T10:30:00Z".to_string());
307        instrument.parse_update_time_utc();
308        assert!(instrument.update_time_utc.is_some());
309    }
310}