1use crate::error::AppError;
2use crate::presentation::market::HistoricalPrice;
3use chrono::{DateTime, Utc};
4use sqlx::{PgPool, Row};
5use tracing::{info, warn};
6
7pub 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 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 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 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 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 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 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 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#[must_use]
160#[inline]
161fn is_benign_already_exists(code: &str) -> bool {
162 matches!(code, "42710" | "42P07")
163}
164
165#[derive(Debug, Default)]
167pub struct StorageStats {
168 pub inserted: usize,
170 pub updated: usize,
172 pub skipped: usize,
174 pub total_processed: usize,
176}
177
178pub 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 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 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 if row.get::<bool, _>("inserted") {
275 stats.inserted += 1;
276 } else {
277 stats.updated += 1;
278 }
279
280 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
292pub fn parse_snapshot_time(snapshot_time: &str) -> Result<DateTime<Utc>, AppError> {
298 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#[derive(Debug)]
324pub struct TableStats {
325 pub total_records: i64,
327 pub earliest_date: String,
329 pub latest_date: String,
331 pub avg_close_price: f64,
333 pub min_price: f64,
335 pub max_price: f64,
337}
338
339pub 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 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 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 for bad in [
478 "2025/13/01 00:00:00", "2025-10-32 00:00:00", "20-10-2025 00:00:00", "2025-10-20 19:22:33+02:00", ] {
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(×tamp);
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(×tamp);
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 assert!(is_benign_already_exists("42710"));
582 }
583
584 #[test]
585 fn test_is_benign_already_exists_duplicate_relation_true() {
586 assert!(is_benign_already_exists("42P07"));
588 }
589
590 #[test]
591 fn test_is_benign_already_exists_other_code_false() {
592 assert!(!is_benign_already_exists("23505")); assert!(!is_benign_already_exists("42P01")); assert!(!is_benign_already_exists(""));
596 }
597}