Files
foxhunt/crates/market-data/src/prices.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

622 lines
19 KiB
Rust

//! # Price Data Module
//!
//! This module provides repository abstractions and implementations for storing,
//! retrieving, and managing price data including tick data, OHLCV candles,
//! and historical price records for trading instruments.
//!
//! ## Features
//!
//! - Storage and retrieval of tick-level price data
//! - OHLCV candle data management
//! - Historical price queries with time range filtering
//! - Multi-symbol price retrieval
//! - Data cleanup and maintenance operations
//! - Batch operations for efficient data processing
//!
//! ## Usage
//!
//! ```rust
//! use market_data::prices::{PriceRepository, PostgresPriceRepository};
//! use market_data::models::{PriceRecord, TimePeriod};
//! use sqlx::PgPool;
//!
//! # async fn example(pool: PgPool) -> Result<(), Box<dyn std::error::Error>> {
//! let repo = PostgresPriceRepository::new(pool);
//!
//! // Get latest price for a symbol
//! let price = repo.get_latest_price("AAPL").await?;
//!
//! // Get candle data
//! let start = Utc::now() - Duration::days(30);
//! let end = Utc::now();
//! let candles = repo.get_candles("AAPL", TimePeriod::Daily, start, end).await?;
//! # Ok(())
//! # }
//! ```
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Row};
use std::collections::HashMap;
use crate::{
error::{MarketDataError, MarketDataResult},
models::{Candle, PriceRecord, TimePeriod},
};
/// Repository trait for price data operations
///
/// This trait defines the interface for storing, retrieving, and managing
/// price data including tick-level prices and aggregated candles.
///
/// Implementations should provide efficient access patterns optimized
/// for both real-time and historical data queries.
///
/// The trait supports:
/// - Individual and batch price storage
///
/// - Historical price data retrieval
/// - Multi-symbol price queries
///
/// - OHLCV candle data management
/// - Data maintenance and cleanup
#[async_trait]
pub trait PriceRepository {
/// Store a single price record
///
/// Stores a price record in the repository. If a record with the same ID
/// already exists, it will be updated with the new price information.
///
/// # Arguments
///
/// * `price` - The price record to store
///
/// # Returns
///
/// `Ok(())` on success, or a `MarketDataError` if the operation fails
///
/// # Errors
///
/// - `MarketDataError::InvalidSymbol` if the symbol is invalid
/// - `MarketDataError::Database` if the database operation fails
async fn store_price(&self, price: &PriceRecord) -> MarketDataResult<()>;
/// Store multiple price records in a batch
///
/// Efficiently stores multiple price records in a single transaction.
///
/// This is optimized for bulk data loading and reduces database overhead.
///
/// # Arguments
///
/// * `prices` - Slice of price records to store
///
/// # Returns
///
/// `Ok(())` on success, or a `MarketDataError` if any operation fails
///
/// # Errors
///
/// - `MarketDataError::InvalidSymbol` if any symbol is invalid
/// - `MarketDataError::Database` if the database transaction fails
async fn store_prices(&self, prices: &[PriceRecord]) -> MarketDataResult<()>;
/// Get the latest price for a symbol
///
/// Retrieves the most recent price record for the specified symbol,
/// ordered by timestamp.
///
/// # Arguments
///
/// * `symbol` - Trading symbol to query
///
/// # Returns
///
/// `Some(price)` if found, `None` if no data exists, or a `MarketDataError`
///
/// # Errors
///
/// - `MarketDataError::InvalidSymbol` if the symbol is invalid
/// - `MarketDataError::Database` if the query fails
async fn get_latest_price(&self, symbol: &str) -> MarketDataResult<Option<PriceRecord>>;
/// Get price history for a symbol within a time range
///
/// Retrieves historical price records within the specified time range,
/// ordered chronologically. This is useful for backtesting and analysis.
///
/// # Arguments
///
/// * `symbol` - Trading symbol to query
/// * `from` - Start of time range (inclusive)
///
/// * `to` - End of time range (inclusive)
///
/// # Returns
///
/// Vector of price records ordered by timestamp, or a `MarketDataError`
///
/// # Errors
///
/// - `MarketDataError::InvalidSymbol` if the symbol is invalid
/// - `MarketDataError::InvalidTimeRange` if from >= to
///
/// - `MarketDataError::Database` if the query fails
async fn get_price_history(
&self,
symbol: &str,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> MarketDataResult<Vec<PriceRecord>>;
/// Get latest prices for multiple symbols
///
/// Efficiently retrieves the latest price records for multiple symbols
/// in a single query. Useful for portfolio analysis and screening.
///
/// # Arguments
///
/// * `symbols` - List of trading symbols to query
///
/// # Returns
///
/// HashMap mapping symbols to their latest price records, or a `MarketDataError`
///
/// # Errors
///
/// - `MarketDataError::InvalidSymbol` if any symbol is invalid
/// - `MarketDataError::Database` if the query fails
async fn get_latest_prices(
&self,
symbols: &[String],
) -> MarketDataResult<HashMap<String, PriceRecord>>;
/// Store a candle (OHLCV) record
///
/// Stores an OHLCV candle record for a specific time period.
///
/// If a candle with the same symbol, period, and timestamp exists, it will be updated.
///
/// # Arguments
///
/// * `candle` - The candle record to store
///
/// # Returns
///
/// `Ok(())` on success, or a `MarketDataError` if the operation fails
///
/// # Errors
///
/// - `MarketDataError::InvalidSymbol` if the symbol is invalid
/// - `MarketDataError::Database` if the database operation fails
async fn store_candle(&self, candle: &Candle) -> MarketDataResult<()>;
/// Get candle history for a symbol and period
///
/// Retrieves historical OHLCV candle data for a specific symbol and time period
/// within the specified time range. Useful for technical analysis and charting.
///
/// # Arguments
///
/// * `symbol` - Trading symbol to query
/// * `period` - Time period for the candles (e.g., Daily, Hour)
///
/// * `from` - Start of time range (inclusive)
/// * `to` - End of time range (inclusive)
///
/// # Returns
///
/// Vector of candles ordered by timestamp, or a `MarketDataError`
///
/// # Errors
///
/// - `MarketDataError::InvalidSymbol` if the symbol is invalid
/// - `MarketDataError::InvalidTimeRange` if from >= to
///
/// - `MarketDataError::Database` if the query fails
async fn get_candles(
&self,
symbol: &str,
period: TimePeriod,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> MarketDataResult<Vec<Candle>>;
/// Delete old price data before a given timestamp
///
/// Removes historical price data older than the specified timestamp.
///
/// This is useful for data retention management and storage optimization.
///
/// # Arguments
///
/// * `before` - Timestamp before which all data will be deleted
///
/// # Returns
///
/// Number of records deleted, or a `MarketDataError`
///
/// # Errors
///
/// - `MarketDataError::Database` if the deletion fails
async fn cleanup_old_prices(&self, before: DateTime<Utc>) -> MarketDataResult<u64>;
}
/// PostgreSQL implementation of PriceRepository
///
/// Provides a production-ready implementation of the `PriceRepository` trait
/// using PostgreSQL as the backend storage. This implementation is optimized
/// for high-frequency price updates and efficient historical data retrieval.
///
/// ## Features
///
/// - Transactional batch operations for atomic updates
/// - Optimized time-series queries with proper indexing
///
/// - Input validation and error handling
/// - Conflict resolution with upsert semantics
///
/// - Support for both tick data and aggregated candles
pub struct PostgresPriceRepository {
/// PostgreSQL connection pool for database operations
pool: PgPool,
}
impl PostgresPriceRepository {
/// Create a new PostgreSQL price repository
///
/// # Arguments
///
/// * `pool` - PostgreSQL connection pool
///
/// # Returns
///
/// A new `PostgresPriceRepository` instance
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// Validate that a symbol meets format requirements
///
/// Ensures the symbol is non-empty and within length limits.
///
/// # Arguments
///
/// * `symbol` - Symbol to validate
///
/// # Returns
///
/// `Ok(())` if valid, `MarketDataError::InvalidSymbol` otherwise
async fn validate_symbol(&self, symbol: &str) -> MarketDataResult<()> {
if symbol.is_empty() || symbol.len() > 20 {
return Err(MarketDataError::InvalidSymbol {
symbol: symbol.to_string(),
});
}
Ok(())
}
/// Validate that a time range is logically correct
///
/// Ensures the start time is before the end time.
///
/// # Arguments
///
/// * `from` - Start time
/// * `to` - End time
///
/// # Returns
///
/// `Ok(())` if valid, `MarketDataError::InvalidTimeRange` otherwise
async fn validate_time_range(
&self,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> MarketDataResult<()> {
if from >= to {
return Err(MarketDataError::InvalidTimeRange { from, to });
}
Ok(())
}
}
#[async_trait]
impl PriceRepository for PostgresPriceRepository {
async fn store_price(&self, price: &PriceRecord) -> MarketDataResult<()> {
self.validate_symbol(&price.symbol).await?;
sqlx::query(
r#"
INSERT INTO prices (id, symbol, timestamp, bid, ask, last, volume, open, high, low, close, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO UPDATE SET
bid = EXCLUDED.bid,
ask = EXCLUDED.ask,
last = EXCLUDED.last,
volume = EXCLUDED.volume,
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close
"#
)
.bind(price.id)
.bind(&price.symbol)
.bind(price.timestamp)
.bind(price.bid)
.bind(price.ask)
.bind(price.last)
.bind(price.volume)
.bind(price.open)
.bind(price.high)
.bind(price.low)
.bind(price.close)
.bind(price.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn store_prices(&self, prices: &[PriceRecord]) -> MarketDataResult<()> {
if prices.is_empty() {
return Ok(());
}
// Validate all symbols first
for price in prices {
self.validate_symbol(&price.symbol).await?;
}
let mut tx = self.pool.begin().await?;
for price in prices {
sqlx::query(
r#"
INSERT INTO prices (id, symbol, timestamp, bid, ask, last, volume, open, high, low, close, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (id) DO UPDATE SET
bid = EXCLUDED.bid,
ask = EXCLUDED.ask,
last = EXCLUDED.last,
volume = EXCLUDED.volume,
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close
"#
)
.bind(price.id)
.bind(&price.symbol)
.bind(price.timestamp)
.bind(price.bid)
.bind(price.ask)
.bind(price.last)
.bind(price.volume)
.bind(price.open)
.bind(price.high)
.bind(price.low)
.bind(price.close)
.bind(price.created_at)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
async fn get_latest_price(&self, symbol: &str) -> MarketDataResult<Option<PriceRecord>> {
self.validate_symbol(symbol).await?;
let row = sqlx::query(
r#"
SELECT id, symbol, timestamp, bid, ask, last, volume, open, high, low, close, created_at
FROM prices
WHERE symbol = $1
ORDER BY timestamp DESC
LIMIT 1
"#,
)
.bind(symbol)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = row {
Ok(Some(PriceRecord {
id: row.get("id"),
symbol: row.get("symbol"),
timestamp: row.get("timestamp"),
bid: row.get("bid"),
ask: row.get("ask"),
last: row.get("last"),
volume: row.get("volume"),
open: row.get("open"),
high: row.get("high"),
low: row.get("low"),
close: row.get("close"),
created_at: row.get("created_at"),
}))
} else {
Ok(None)
}
}
async fn get_price_history(
&self,
symbol: &str,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> MarketDataResult<Vec<PriceRecord>> {
self.validate_symbol(symbol).await?;
self.validate_time_range(from, to).await?;
let rows = sqlx::query(
r#"
SELECT id, symbol, timestamp, bid, ask, last, volume, open, high, low, close, created_at
FROM prices
WHERE symbol = $1 AND timestamp >= $2 AND timestamp <= $3
ORDER BY timestamp ASC
"#,
)
.bind(symbol)
.bind(from)
.bind(to)
.fetch_all(&self.pool)
.await?;
let prices = rows
.into_iter()
.map(|row| PriceRecord {
id: row.get("id"),
symbol: row.get("symbol"),
timestamp: row.get("timestamp"),
bid: row.get("bid"),
ask: row.get("ask"),
last: row.get("last"),
volume: row.get("volume"),
open: row.get("open"),
high: row.get("high"),
low: row.get("low"),
close: row.get("close"),
created_at: row.get("created_at"),
})
.collect();
Ok(prices)
}
async fn get_latest_prices(
&self,
symbols: &[String],
) -> MarketDataResult<HashMap<String, PriceRecord>> {
if symbols.is_empty() {
return Ok(HashMap::new());
}
// Validate all symbols
for symbol in symbols {
self.validate_symbol(symbol).await?;
}
let rows = sqlx::query(
r#"
SELECT DISTINCT ON (symbol) id, symbol, timestamp, bid, ask, last, volume, open, high, low, close, created_at
FROM prices
WHERE symbol = ANY($1)
ORDER BY symbol, timestamp DESC
"#
)
.bind(symbols)
.fetch_all(&self.pool)
.await?;
let mut prices = HashMap::new();
for row in rows {
let price = PriceRecord {
id: row.get("id"),
symbol: row.get::<String, _>("symbol"),
timestamp: row.get("timestamp"),
bid: row.get("bid"),
ask: row.get("ask"),
last: row.get("last"),
volume: row.get("volume"),
open: row.get("open"),
high: row.get("high"),
low: row.get("low"),
close: row.get("close"),
created_at: row.get("created_at"),
};
prices.insert(price.symbol.clone(), price);
}
Ok(prices)
}
async fn store_candle(&self, candle: &Candle) -> MarketDataResult<()> {
self.validate_symbol(&candle.symbol).await?;
sqlx::query(
r#"
INSERT INTO candles (id, symbol, period, timestamp, open, high, low, close, volume, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (symbol, period, timestamp) DO UPDATE SET
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
volume = EXCLUDED.volume
"#
)
.bind(candle.id)
.bind(&candle.symbol)
.bind(&candle.period)
.bind(candle.timestamp)
.bind(candle.open)
.bind(candle.high)
.bind(candle.low)
.bind(candle.close)
.bind(candle.volume)
.bind(candle.created_at)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_candles(
&self,
symbol: &str,
period: TimePeriod,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> MarketDataResult<Vec<Candle>> {
self.validate_symbol(symbol).await?;
self.validate_time_range(from, to).await?;
let period_str = format!("{:?}", period);
let rows = sqlx::query(
r#"
SELECT id, symbol, period, timestamp, open, high, low, close, volume, created_at
FROM candles
WHERE symbol = $1 AND period = $2 AND timestamp >= $3 AND timestamp <= $4
ORDER BY timestamp ASC
"#,
)
.bind(symbol)
.bind(&period_str)
.bind(from)
.bind(to)
.fetch_all(&self.pool)
.await?;
let candles = rows
.into_iter()
.map(|row| Candle {
id: row.get("id"),
symbol: row.get("symbol"),
period: row.get("period"),
timestamp: row.get("timestamp"),
open: row.get("open"),
high: row.get("high"),
low: row.get("low"),
close: row.get("close"),
volume: row.get("volume"),
created_at: row.get("created_at"),
})
.collect();
Ok(candles)
}
async fn cleanup_old_prices(&self, before: DateTime<Utc>) -> MarketDataResult<u64> {
let result = sqlx::query("DELETE FROM prices WHERE timestamp < $1")
.bind(before)
.execute(&self.pool)
.await?;
Ok(result.rows_affected())
}
}