Skip to main content

ig_client/storage/
historical_prices.rs

1use crate::error::AppError;
2use crate::presentation::market::HistoricalPrice;
3use chrono::{DateTime, Utc};
4use sqlx::{PgPool, Row};
5use tracing::{info, warn};
6
7/// Initialize the historical_prices table in PostgreSQL
8pub async fn initialize_historical_prices_table(pool: &PgPool) -> Result<(), sqlx::Error> {
9    info!("Initializing historical_prices table...");
10
11    sqlx::query(
12        r#"
13        CREATE TABLE IF NOT EXISTS historical_prices (
14            id BIGSERIAL PRIMARY KEY,
15            epic VARCHAR(255) NOT NULL,
16            resolution VARCHAR(50) NOT NULL,
17            snapshot_time TIMESTAMPTZ NOT NULL,
18            open_bid DOUBLE PRECISION,
19            open_ask DOUBLE PRECISION,
20            open_last_traded DOUBLE PRECISION,
21            high_bid DOUBLE PRECISION,
22            high_ask DOUBLE PRECISION,
23            high_last_traded DOUBLE PRECISION,
24            low_bid DOUBLE PRECISION,
25            low_ask DOUBLE PRECISION,
26            low_last_traded DOUBLE PRECISION,
27            close_bid DOUBLE PRECISION,
28            close_ask DOUBLE PRECISION,
29            close_last_traded DOUBLE PRECISION,
30            last_traded_volume BIGINT,
31            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
32            updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
33            UNIQUE(epic, resolution, snapshot_time)
34        )
35        "#,
36    )
37    .execute(pool)
38    .await?;
39
40    // Ensure the table schema has 'resolution' for backwards compatibility
41    if let Err(e) = sqlx::query(
42        r#"
43        ALTER TABLE historical_prices 
44        ADD COLUMN IF NOT EXISTS resolution VARCHAR(50) NOT NULL DEFAULT 'UNKNOWN'
45        "#,
46    )
47    .execute(pool)
48    .await
49    {
50        info!(
51            "Column 'resolution' check/migration skipped or already present: {}",
52            e
53        );
54    }
55
56    // Migrate the unique constraint to (epic, resolution, snapshot_time).
57    //
58    // Postgres' extended/prepared protocol rejects multiple statements in a
59    // single query, so each DDL statement must be issued on its own. Errors
60    // are surfaced rather than silently discarded.
61
62    // Drop the legacy two-column constraint if a pre-migration database has it.
63    // `IF EXISTS` makes this idempotent. The target three-column constraint is
64    // NOT dropped here: `CREATE TABLE ... UNIQUE(epic, resolution, snapshot_time)`
65    // above already creates it (auto-named `historical_prices_epic_resolution_snapshot_time_key`),
66    // so dropping and re-adding it on every startup would churn an
67    // AccessExclusive lock for nothing. The tolerant ADD below handles the
68    // fresh case (constraint already present → 42710 → skipped), the migration
69    // case (old two-column DB whose legacy constraint was just dropped), and
70    // the orphan-index case (an index by the constraint's name exists without
71    // an attached constraint → 42P07 → skipped).
72    sqlx::query(
73        "ALTER TABLE historical_prices \
74         DROP CONSTRAINT IF EXISTS historical_prices_epic_snapshot_time_key",
75    )
76    .execute(pool)
77    .await?;
78
79    // Add the three-column unique constraint. There is no `IF NOT EXISTS` for
80    // `ADD CONSTRAINT`, so tolerate the "already exists" SQLSTATEs (42710 for
81    // the constraint, 42P07 for its backing index) while surfacing any other
82    // failure.
83    if let Err(e) = sqlx::query(
84        "ALTER TABLE historical_prices \
85         ADD CONSTRAINT historical_prices_epic_resolution_snapshot_time_key \
86         UNIQUE (epic, resolution, snapshot_time)",
87    )
88    .execute(pool)
89    .await
90    {
91        match e.as_database_error().and_then(|db| db.code()) {
92            Some(code) if is_benign_already_exists(&code) => {
93                warn!(
94                    constraint = "historical_prices_epic_resolution_snapshot_time_key",
95                    sqlstate = %code,
96                    "unique constraint or its backing index already exists, skipping add"
97                );
98            }
99            _ => return Err(e),
100        }
101    }
102
103    // Create index for better query performance
104    sqlx::query(
105        r#"
106        CREATE INDEX IF NOT EXISTS idx_historical_prices_epic_res_time 
107        ON historical_prices(epic, resolution, snapshot_time DESC)
108        "#,
109    )
110    .execute(pool)
111    .await?;
112
113    // Create trigger for updating updated_at timestamp
114    sqlx::query(
115        r#"
116        CREATE OR REPLACE FUNCTION update_updated_at_column()
117        RETURNS TRIGGER AS $$
118        BEGIN
119            NEW.updated_at = NOW();
120            RETURN NEW;
121        END;
122        $$ language 'plpgsql'
123        "#,
124    )
125    .execute(pool)
126    .await?;
127
128    // Drop existing trigger if it exists
129    sqlx::query(
130        r#"
131        DROP TRIGGER IF EXISTS update_historical_prices_updated_at ON historical_prices
132        "#,
133    )
134    .execute(pool)
135    .await?;
136
137    // Create the trigger
138    sqlx::query(
139        r#"
140        CREATE TRIGGER update_historical_prices_updated_at
141            BEFORE UPDATE ON historical_prices
142            FOR EACH ROW
143            EXECUTE FUNCTION update_updated_at_column()
144        "#,
145    )
146    .execute(pool)
147    .await?;
148
149    info!("✅ Historical prices table initialized successfully");
150    Ok(())
151}
152
153/// Returns `true` for the PostgreSQL SQLSTATEs that mean the unique
154/// constraint (or its backing index) already exists: `42710`
155/// (duplicate_object — the constraint is already present) and `42P07`
156/// (duplicate_table — `ADD CONSTRAINT ... UNIQUE` implicitly creates a
157/// backing index of the same name, so a pre-existing relation by that name
158/// surfaces as "relation already exists").
159#[must_use]
160#[inline]
161fn is_benign_already_exists(code: &str) -> bool {
162    matches!(code, "42710" | "42P07")
163}
164
165/// Storage statistics for tracking insert/update operations
166#[derive(Debug, Default)]
167pub struct StorageStats {
168    /// Number of new records inserted into the database
169    pub inserted: usize,
170    /// Number of existing records updated in the database
171    pub updated: usize,
172    /// Number of records skipped due to errors or validation issues
173    pub skipped: usize,
174    /// Total number of records processed (inserted + updated + skipped)
175    pub total_processed: usize,
176}
177
178/// Store historical prices in PostgreSQL with UPSERT logic
179pub async fn store_historical_prices(
180    pool: &PgPool,
181    epic: &str,
182    resolution: &str,
183    prices: &[HistoricalPrice],
184) -> Result<StorageStats, sqlx::Error> {
185    let mut stats = StorageStats::default();
186    let mut tx = pool.begin().await?;
187
188    info!(
189        "Processing {} price records for epic: {}",
190        prices.len(),
191        epic
192    );
193
194    for (i, price) in prices.iter().enumerate() {
195        stats.total_processed += 1;
196
197        // Parse snapshot time. Prefer the UTC value IG provides in
198        // `snapshotTimeUTC`; the plain `snapshotTime` is in the account's
199        // timezone, so storing it would shift every row by the account offset.
200        let raw_snapshot_time = price
201            .snapshot_time_utc
202            .as_deref()
203            .unwrap_or(&price.snapshot_time);
204        let snapshot_time = match parse_snapshot_time(raw_snapshot_time) {
205            Ok(time) => time,
206            Err(e) => {
207                warn!(
208                    "⚠️  Skipping record {}: Invalid timestamp '{}': {}",
209                    i + 1,
210                    raw_snapshot_time,
211                    e
212                );
213                stats.skipped += 1;
214                continue;
215            }
216        };
217
218        // Use UPSERT (INSERT ... ON CONFLICT ... DO UPDATE) and classify the
219        // outcome from the SAME statement via `RETURNING (xmax = 0)`:
220        // `xmax = 0` on the returned tuple means a fresh insert, while a
221        // non-zero `xmax` means the conflicting row was updated. This avoids a
222        // second round trip and is correct for same-batch duplicates (the older
223        // `created_at = updated_at` heuristic double-counted them as inserts).
224        let row = sqlx::query(
225            r#"
226            INSERT INTO historical_prices (
227                epic, resolution, snapshot_time,
228                open_bid, open_ask, open_last_traded,
229                high_bid, high_ask, high_last_traded,
230                low_bid, low_ask, low_last_traded,
231                close_bid, close_ask, close_last_traded,
232                last_traded_volume
233            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
234            ON CONFLICT (epic, resolution, snapshot_time)
235            DO UPDATE SET
236                open_bid = EXCLUDED.open_bid,
237                open_ask = EXCLUDED.open_ask,
238                open_last_traded = EXCLUDED.open_last_traded,
239                high_bid = EXCLUDED.high_bid,
240                high_ask = EXCLUDED.high_ask,
241                high_last_traded = EXCLUDED.high_last_traded,
242                low_bid = EXCLUDED.low_bid,
243                low_ask = EXCLUDED.low_ask,
244                low_last_traded = EXCLUDED.low_last_traded,
245                close_bid = EXCLUDED.close_bid,
246                close_ask = EXCLUDED.close_ask,
247                close_last_traded = EXCLUDED.close_last_traded,
248                last_traded_volume = EXCLUDED.last_traded_volume,
249                updated_at = NOW()
250            RETURNING (xmax = 0) AS inserted
251            "#,
252        )
253        .bind(epic)
254        .bind(resolution)
255        .bind(snapshot_time)
256        .bind(price.open_price.bid)
257        .bind(price.open_price.ask)
258        .bind(price.open_price.last_traded)
259        .bind(price.high_price.bid)
260        .bind(price.high_price.ask)
261        .bind(price.high_price.last_traded)
262        .bind(price.low_price.bid)
263        .bind(price.low_price.ask)
264        .bind(price.low_price.last_traded)
265        .bind(price.close_price.bid)
266        .bind(price.close_price.ask)
267        .bind(price.close_price.last_traded)
268        .bind(price.last_traded_volume)
269        .fetch_one(&mut *tx)
270        .await?;
271
272        // `DO UPDATE` always returns exactly one row, so classification is
273        // unambiguous.
274        if row.get::<bool, _>("inserted") {
275            stats.inserted += 1;
276        } else {
277            stats.updated += 1;
278        }
279
280        // Log progress every 100 records
281        if (i + 1) % 100 == 0 {
282            info!("  Processed {}/{} records...", i + 1, prices.len());
283        }
284    }
285
286    tx.commit().await?;
287    info!("✅ Transaction committed successfully");
288
289    Ok(stats)
290}
291
292/// Parse snapshot time from IG format to `DateTime<Utc>`
293///
294/// # Errors
295///
296/// Returns `AppError::Generic` if the timestamp cannot be parsed with any supported format.
297pub fn parse_snapshot_time(snapshot_time: &str) -> Result<DateTime<Utc>, AppError> {
298    // IG formats: "yyyy/MM/dd hh:mm:ss" / "yyyy-MM-dd hh:mm:ss" (snapshotTime),
299    // and the ISO-8601 "yyyy-MM-ddTHH:mm:ss" that snapshotTimeUTC uses.
300    let formats = [
301        "%Y/%m/%d %H:%M:%S",
302        "%Y-%m-%d %H:%M:%S",
303        "%Y-%m-%dT%H:%M:%S",
304        "%Y-%m-%dT%H:%M:%SZ",
305        "%Y/%m/%d %H:%M",
306        "%Y-%m-%d %H:%M",
307        "%Y-%m-%dT%H:%M",
308    ];
309
310    for format in &formats {
311        if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(snapshot_time, format) {
312            return Ok(DateTime::from_naive_utc_and_offset(naive_dt, Utc));
313        }
314    }
315
316    Err(AppError::Generic(format!(
317        "Unable to parse timestamp: {}",
318        snapshot_time
319    )))
320}
321
322/// Database statistics for a specific epic
323#[derive(Debug)]
324pub struct TableStats {
325    /// Total number of records in the database for this epic
326    pub total_records: i64,
327    /// Earliest date in the dataset (formatted as string)
328    pub earliest_date: String,
329    /// Latest date in the dataset (formatted as string)
330    pub latest_date: String,
331    /// Average closing price across all records
332    pub avg_close_price: f64,
333    /// Minimum price (lowest of all low prices) in the dataset
334    pub min_price: f64,
335    /// Maximum price (highest of all high prices) in the dataset
336    pub max_price: f64,
337}
338
339/// Get statistics for the historical_prices table
340pub async fn get_table_statistics(
341    pool: &PgPool,
342    epic: &str,
343    resolution: Option<&str>,
344) -> Result<TableStats, sqlx::Error> {
345    let row = if let Some(res) = resolution {
346        sqlx::query(
347            r#"
348            SELECT 
349                COUNT(*) as total_records,
350                MIN(snapshot_time)::text as earliest_date,
351                MAX(snapshot_time)::text as latest_date,
352                AVG(close_bid) as avg_close_price,
353                MIN(LEAST(low_bid, low_ask)) as min_price,
354                MAX(GREATEST(high_bid, high_ask)) as max_price
355            FROM historical_prices 
356            WHERE epic = $1 AND resolution = $2
357            "#,
358        )
359        .bind(epic)
360        .bind(res)
361        .fetch_one(pool)
362        .await?
363    } else {
364        sqlx::query(
365            r#"
366            SELECT 
367                COUNT(*) as total_records,
368                MIN(snapshot_time)::text as earliest_date,
369                MAX(snapshot_time)::text as latest_date,
370                AVG(close_bid) as avg_close_price,
371                MIN(LEAST(low_bid, low_ask)) as min_price,
372                MAX(GREATEST(high_bid, high_ask)) as max_price
373            FROM historical_prices 
374            WHERE epic = $1
375            "#,
376        )
377        .bind(epic)
378        .fetch_one(pool)
379        .await?
380    };
381
382    // `COUNT(*)` is never NULL, but the aggregate columns
383    // (`MIN`/`MAX`/`AVG`, and the `::text` date bounds) are all NULL for an
384    // epic with no rows. Read every nullable column as an `Option` so a
385    // zero-row epic returns zeroed stats instead of panicking in `Row::get`.
386    let total_records: i64 = row.get("total_records");
387    if total_records == 0 {
388        return Ok(TableStats {
389            total_records: 0,
390            earliest_date: String::new(),
391            latest_date: String::new(),
392            avg_close_price: 0.0,
393            min_price: 0.0,
394            max_price: 0.0,
395        });
396    }
397
398    Ok(TableStats {
399        total_records,
400        earliest_date: row
401            .get::<Option<String>, _>("earliest_date")
402            .unwrap_or_default(),
403        latest_date: row
404            .get::<Option<String>, _>("latest_date")
405            .unwrap_or_default(),
406        avg_close_price: row.get::<Option<f64>, _>("avg_close_price").unwrap_or(0.0),
407        min_price: row.get::<Option<f64>, _>("min_price").unwrap_or(0.0),
408        max_price: row.get::<Option<f64>, _>("max_price").unwrap_or(0.0),
409    })
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn test_parse_snapshot_time_slash_format() {
418        let result = parse_snapshot_time("2024/01/15 14:30:00");
419        assert!(result.is_ok());
420        let dt = result.expect("should parse");
421        assert_eq!(
422            dt.format("%Y-%m-%d %H:%M:%S").to_string(),
423            "2024-01-15 14:30:00"
424        );
425    }
426
427    #[test]
428    fn test_parse_snapshot_time_dash_format() {
429        let result = parse_snapshot_time("2024-01-15 14:30:00");
430        assert!(result.is_ok());
431        let dt = result.expect("should parse");
432        assert_eq!(
433            dt.format("%Y-%m-%d %H:%M:%S").to_string(),
434            "2024-01-15 14:30:00"
435        );
436    }
437
438    #[test]
439    fn test_parse_snapshot_time_iso_utc_format() {
440        // snapshotTimeUTC uses the ISO-8601 `T` separator, with or without `Z`.
441        for input in ["2024-01-15T14:30:00", "2024-01-15T14:30:00Z"] {
442            let dt =
443                parse_snapshot_time(input).unwrap_or_else(|e| panic!("should parse {input}: {e}"));
444            assert_eq!(
445                dt.format("%Y-%m-%d %H:%M:%S").to_string(),
446                "2024-01-15 14:30:00"
447            );
448        }
449    }
450
451    #[test]
452    fn test_parse_snapshot_time_without_seconds_slash() {
453        let result = parse_snapshot_time("2024/01/15 14:30");
454        assert!(result.is_ok());
455        let dt = result.expect("should parse");
456        assert_eq!(dt.format("%Y-%m-%d %H:%M").to_string(), "2024-01-15 14:30");
457    }
458
459    #[test]
460    fn test_parse_snapshot_time_without_seconds_dash() {
461        let result = parse_snapshot_time("2024-01-15 14:30");
462        assert!(result.is_ok());
463        let dt = result.expect("should parse");
464        assert_eq!(dt.format("%Y-%m-%d %H:%M").to_string(), "2024-01-15 14:30");
465    }
466
467    #[test]
468    fn test_parse_snapshot_time_invalid_format() {
469        let result = parse_snapshot_time("invalid-date");
470        assert!(result.is_err());
471    }
472
473    #[test]
474    fn test_parse_snapshot_time_rejects_out_of_range_and_wrong_shapes() {
475        // Out-of-range components and unsupported separators/orderings must all
476        // fail rather than silently coerce.
477        for bad in [
478            "2025/13/01 00:00:00",       // invalid month
479            "2025-10-32 00:00:00",       // invalid day
480            "20-10-2025 00:00:00",       // day-month-year order (unsupported)
481            "2025-10-20 19:22:33+02:00", // numeric offset (unsupported)
482        ] {
483            assert!(
484                parse_snapshot_time(bad).is_err(),
485                "should fail for input: {bad}"
486            );
487        }
488    }
489
490    #[test]
491    fn test_parse_snapshot_time_empty_string() {
492        let result = parse_snapshot_time("");
493        assert!(result.is_err());
494    }
495
496    #[test]
497    fn test_parse_snapshot_time_partial_date() {
498        let result = parse_snapshot_time("2024-01-15");
499        assert!(result.is_err());
500    }
501
502    #[test]
503    fn test_parse_snapshot_time_midnight() {
504        let result = parse_snapshot_time("2024/12/31 00:00:00");
505        assert!(result.is_ok());
506        let dt = result.expect("should parse");
507        assert_eq!(dt.format("%H:%M:%S").to_string(), "00:00:00");
508    }
509
510    #[test]
511    fn test_parse_snapshot_time_end_of_day() {
512        let result = parse_snapshot_time("2024/12/31 23:59:59");
513        assert!(result.is_ok());
514        let dt = result.expect("should parse");
515        assert_eq!(dt.format("%H:%M:%S").to_string(), "23:59:59");
516    }
517
518    #[test]
519    fn test_storage_stats_default() {
520        let stats = StorageStats::default();
521        assert_eq!(stats.inserted, 0);
522        assert_eq!(stats.updated, 0);
523        assert_eq!(stats.skipped, 0);
524        assert_eq!(stats.total_processed, 0);
525    }
526
527    #[test]
528    fn test_storage_stats_creation() {
529        let stats = StorageStats {
530            inserted: 10,
531            updated: 5,
532            skipped: 2,
533            total_processed: 17,
534        };
535        assert_eq!(stats.inserted, 10);
536        assert_eq!(stats.updated, 5);
537        assert_eq!(stats.skipped, 2);
538        assert_eq!(stats.total_processed, 17);
539    }
540
541    #[test]
542    fn test_table_stats_creation() {
543        let stats = TableStats {
544            total_records: 100,
545            earliest_date: "2024-01-01".to_string(),
546            latest_date: "2024-12-31".to_string(),
547            avg_close_price: 150.5,
548            min_price: 100.0,
549            max_price: 200.0,
550        };
551        assert_eq!(stats.total_records, 100);
552        assert_eq!(stats.earliest_date, "2024-01-01");
553        assert_eq!(stats.latest_date, "2024-12-31");
554        assert!((stats.avg_close_price - 150.5).abs() < f64::EPSILON);
555        assert!((stats.min_price - 100.0).abs() < f64::EPSILON);
556        assert!((stats.max_price - 200.0).abs() < f64::EPSILON);
557    }
558
559    #[test]
560    fn test_parse_snapshot_time_different_years() {
561        let years = ["2020", "2021", "2022", "2023", "2024", "2025"];
562        for year in years {
563            let timestamp = format!("{}/06/15 12:00:00", year);
564            let result = parse_snapshot_time(&timestamp);
565            assert!(result.is_ok(), "Failed for year: {}", year);
566        }
567    }
568
569    #[test]
570    fn test_parse_snapshot_time_all_months() {
571        for month in 1..=12 {
572            let timestamp = format!("2024/{:02}/15 12:00:00", month);
573            let result = parse_snapshot_time(&timestamp);
574            assert!(result.is_ok(), "Failed for month: {}", month);
575        }
576    }
577
578    #[test]
579    fn test_is_benign_already_exists_duplicate_object_true() {
580        // 42710 = duplicate_object: the constraint itself already exists.
581        assert!(is_benign_already_exists("42710"));
582    }
583
584    #[test]
585    fn test_is_benign_already_exists_duplicate_relation_true() {
586        // 42P07 = duplicate_table: the backing index name already exists.
587        assert!(is_benign_already_exists("42P07"));
588    }
589
590    #[test]
591    fn test_is_benign_already_exists_other_code_false() {
592        // Unrelated SQLSTATEs must keep propagating as errors.
593        assert!(!is_benign_already_exists("23505")); // unique_violation
594        assert!(!is_benign_already_exists("42P01")); // undefined_table
595        assert!(!is_benign_already_exists(""));
596    }
597}