/home/jgrusewski/Work/foxhunt/config/src/database.rs
Line | Count | Source |
1 | | //! Database configuration for PostgreSQL connections and connection pooling. |
2 | | //! |
3 | | //! This module provides comprehensive database configuration structures for managing |
4 | | //! PostgreSQL connections, connection pools, and transaction settings in the Foxhunt |
5 | | //! HFT trading system. It supports connection pooling, timeout management, and |
6 | | //! transaction isolation levels optimized for high-frequency trading workloads. |
7 | | |
8 | | use serde::{Deserialize, Serialize}; |
9 | | use std::time::Duration; |
10 | | |
11 | | #[cfg(feature = "postgres")] |
12 | | use sqlx::Row; |
13 | | |
14 | | /// Main database configuration structure for PostgreSQL connections. |
15 | | /// |
16 | | /// Provides comprehensive database connection settings including connection pooling, |
17 | | /// timeouts, logging, and transaction management. Optimized for high-frequency |
18 | | /// trading workloads with appropriate defaults for low-latency operations. |
19 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
20 | | pub struct DatabaseConfig { |
21 | | /// PostgreSQL connection URL (e.g., "postgresql://user:pass@host:port/database") |
22 | | pub url: String, |
23 | | /// Maximum number of connections in the pool |
24 | | pub max_connections: u32, |
25 | | /// Minimum number of connections to maintain in the pool |
26 | | pub min_connections: u32, |
27 | | /// Timeout for establishing new database connections |
28 | | pub connect_timeout: std::time::Duration, |
29 | | /// Timeout for individual query execution |
30 | | pub query_timeout: std::time::Duration, |
31 | | /// Enable detailed query logging for debugging |
32 | | pub enable_query_logging: bool, |
33 | | /// Application name to identify connections in PostgreSQL logs |
34 | | pub application_name: Option<String>, |
35 | | /// Connection pool configuration settings |
36 | | pub pool: PoolConfig, |
37 | | /// Transaction management configuration |
38 | | pub transaction: TransactionConfig, |
39 | | } |
40 | | |
41 | | impl Default for DatabaseConfig { |
42 | 0 | fn default() -> Self { |
43 | 0 | Self::new() |
44 | 0 | } |
45 | | } |
46 | | |
47 | | impl DatabaseConfig { |
48 | | /// Creates a new DatabaseConfig with sensible defaults for development. |
49 | | /// |
50 | | /// Returns a configuration suitable for local development with a PostgreSQL |
51 | | /// database running on localhost. Production deployments should override |
52 | | /// these settings through environment variables or configuration files. |
53 | 14 | pub fn new() -> Self { |
54 | 14 | Self { |
55 | 14 | url: "postgresql://localhost/foxhunt".to_string(), |
56 | 14 | max_connections: 10, |
57 | 14 | min_connections: 1, |
58 | 14 | connect_timeout: Duration::from_secs(30), |
59 | 14 | query_timeout: Duration::from_secs(60), |
60 | 14 | enable_query_logging: false, |
61 | 14 | application_name: Some("foxhunt".to_string()), |
62 | 14 | pool: PoolConfig::default(), |
63 | 14 | transaction: TransactionConfig::default(), |
64 | 14 | } |
65 | 14 | } |
66 | | |
67 | | /// Validates the database configuration for correctness. |
68 | | /// |
69 | | /// Performs basic validation checks on the configuration parameters to ensure |
70 | | /// they are valid before attempting to establish database connections. |
71 | | /// |
72 | | /// # Errors |
73 | | /// |
74 | | /// Returns an error string if the configuration is invalid, such as: |
75 | | /// - Empty database URL |
76 | | /// - Invalid connection parameters |
77 | 5 | pub fn validate(&self) -> Result<(), String> { |
78 | 5 | if self.url.is_empty() { |
79 | 3 | return Err("Database URL cannot be empty".to_string()); |
80 | 2 | } |
81 | 2 | Ok(()) |
82 | 5 | } |
83 | | } |
84 | | |
85 | | /// Database connection pool configuration. |
86 | | /// |
87 | | /// Manages the behavior of the connection pool including connection lifecycle, |
88 | | /// timeouts, and health checking. Optimized for high-frequency trading workloads |
89 | | /// where connection availability and low latency are critical. |
90 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
91 | | pub struct PoolConfig { |
92 | | /// Minimum number of connections to maintain in the pool |
93 | | pub min_connections: u32, |
94 | | /// Maximum number of connections allowed in the pool |
95 | | pub max_connections: u32, |
96 | | /// Timeout in seconds for acquiring a connection from the pool |
97 | | pub acquire_timeout_secs: u64, |
98 | | /// Maximum lifetime in seconds for a connection before it's recycled |
99 | | pub max_lifetime_secs: u64, |
100 | | /// Timeout in seconds before idle connections are closed |
101 | | pub idle_timeout_secs: u64, |
102 | | /// Whether to test connections before returning them from the pool |
103 | | pub test_before_acquire: bool, |
104 | | /// Database URL for pool connections |
105 | | pub database_url: String, |
106 | | /// Enable periodic health checks for pool connections |
107 | | pub health_check_enabled: bool, |
108 | | /// Interval in seconds between health checks |
109 | | pub health_check_interval_secs: u64, |
110 | | } |
111 | | |
112 | | impl Default for PoolConfig { |
113 | 24 | fn default() -> Self { |
114 | 24 | Self { |
115 | 24 | min_connections: 1, |
116 | 24 | max_connections: 10, |
117 | 24 | acquire_timeout_secs: 30, |
118 | 24 | max_lifetime_secs: 1800, |
119 | 24 | idle_timeout_secs: 600, |
120 | 24 | test_before_acquire: true, |
121 | 24 | database_url: "postgresql://localhost/foxhunt".to_string(), |
122 | 24 | health_check_enabled: true, |
123 | 24 | health_check_interval_secs: 60, |
124 | 24 | } |
125 | 24 | } |
126 | | } |
127 | | |
128 | | /// Database transaction configuration and retry policies. |
129 | | /// |
130 | | /// Configures transaction behavior including isolation levels, timeouts, |
131 | | /// and retry mechanisms. Critical for maintaining data consistency in |
132 | | /// high-frequency trading operations while handling transient failures. |
133 | | #[derive(Debug, Clone, Serialize, Deserialize)] |
134 | | pub struct TransactionConfig { |
135 | | /// PostgreSQL transaction isolation level (e.g., "READ_COMMITTED", "SERIALIZABLE") |
136 | | pub isolation_level: String, |
137 | | /// Default timeout duration for transactions |
138 | | pub timeout: Duration, |
139 | | /// Default timeout in seconds for transactions |
140 | | pub default_timeout_secs: u64, |
141 | | /// Enable automatic retry on transaction failures |
142 | | pub enable_retry: bool, |
143 | | /// Maximum number of retry attempts for failed transactions |
144 | | pub max_retries: u32, |
145 | | /// Delay in milliseconds between retry attempts |
146 | | pub retry_delay_ms: u64, |
147 | | /// Maximum number of nested savepoints allowed |
148 | | pub max_savepoints: u32, |
149 | | } |
150 | | |
151 | | impl Default for TransactionConfig { |
152 | 27 | fn default() -> Self { |
153 | 27 | Self { |
154 | 27 | isolation_level: "READ_COMMITTED".to_string(), |
155 | 27 | timeout: Duration::from_secs(30), |
156 | 27 | default_timeout_secs: 30, |
157 | 27 | enable_retry: true, |
158 | 27 | max_retries: 3, |
159 | 27 | retry_delay_ms: 100, |
160 | 27 | max_savepoints: 10, |
161 | 27 | } |
162 | 27 | } |
163 | | } |
164 | | |
165 | | /// Database loader for symbol configurations with PostgreSQL integration. |
166 | | /// |
167 | | /// Provides high-performance loading and caching of symbol configurations |
168 | | /// from the PostgreSQL database. Supports real-time updates through PostgreSQL |
169 | | /// NOTIFY/LISTEN for configuration hot-reload capabilities. |
170 | | #[cfg(feature = "postgres")] |
171 | | pub struct PostgresSymbolConfigLoader { |
172 | | /// Database connection pool |
173 | | pool: sqlx::PgPool, |
174 | | /// Configuration cache timeout |
175 | | cache_timeout: Duration, |
176 | | /// PostgreSQL listener for configuration changes |
177 | | listener: Option<sqlx::postgres::PgListener>, |
178 | | } |
179 | | |
180 | | #[cfg(feature = "postgres")] |
181 | | impl PostgresSymbolConfigLoader { |
182 | | /// Creates a new PostgreSQL symbol configuration loader. |
183 | | pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> { |
184 | | let pool = sqlx::PgPool::connect(database_url).await?; |
185 | | |
186 | | Ok(Self { |
187 | | pool, |
188 | | cache_timeout: Duration::from_secs(300), // 5 minutes |
189 | | listener: None, |
190 | | }) |
191 | | } |
192 | | |
193 | | /// Creates a new loader with an existing connection pool. |
194 | | pub fn with_pool(pool: sqlx::PgPool) -> Self { |
195 | | Self { |
196 | | pool, |
197 | | cache_timeout: Duration::from_secs(300), |
198 | | listener: None, |
199 | | } |
200 | | } |
201 | | |
202 | | /// Loads a symbol configuration by symbol name. |
203 | | pub async fn load_symbol_config( |
204 | | &self, |
205 | | symbol: &str, |
206 | | ) -> Result<Option<crate::symbol_config::SymbolConfig>, sqlx::Error> { |
207 | | // Simplified implementation using basic sqlx::query instead of macros |
208 | | let query = " |
209 | | SELECT |
210 | | sc.id, |
211 | | sc.symbol, |
212 | | sc.description, |
213 | | sc.classification, |
214 | | sc.primary_exchange, |
215 | | sc.currency, |
216 | | sc.tick_size, |
217 | | sc.lot_size, |
218 | | sc.min_order_size, |
219 | | sc.max_order_size, |
220 | | sc.sector, |
221 | | sc.industry, |
222 | | sc.market_cap, |
223 | | sc.avg_daily_volume, |
224 | | sc.margin_requirement, |
225 | | sc.position_limit, |
226 | | sc.risk_multiplier, |
227 | | sc.is_active, |
228 | | sc.data_source, |
229 | | sc.created_at, |
230 | | sc.updated_at, |
231 | | sc.last_validated |
232 | | FROM symbol_config sc |
233 | | WHERE sc.symbol = $1 AND sc.is_active = true |
234 | | "; |
235 | | |
236 | | let row = sqlx::query(query) |
237 | | .bind(symbol) |
238 | | .fetch_optional(&self.pool) |
239 | | .await?; |
240 | | |
241 | | if let Some(row) = row { |
242 | | // Create a basic symbol config from the row |
243 | | let symbol_name: String = row.get("symbol"); |
244 | | let description: String = row.get("description"); |
245 | | let classification_str: String = row.get("classification"); |
246 | | |
247 | | let classification = match classification_str.as_str() { |
248 | | "EQUITY" => crate::symbol_config::AssetClassification::Equity, |
249 | | "FUTURE" => crate::symbol_config::AssetClassification::Future, |
250 | | "FOREX" => crate::symbol_config::AssetClassification::Forex, |
251 | | "CRYPTO" => crate::symbol_config::AssetClassification::Crypto, |
252 | | "COMMODITY" => crate::symbol_config::AssetClassification::Commodity, |
253 | | "FIXED_INCOME" => crate::symbol_config::AssetClassification::FixedIncome, |
254 | | "OPTION" => crate::symbol_config::AssetClassification::Option, |
255 | | "ETF" => crate::symbol_config::AssetClassification::Etf, |
256 | | "INDEX" => crate::symbol_config::AssetClassification::Index, |
257 | | "DERIVATIVE" => crate::symbol_config::AssetClassification::Derivative, |
258 | | _ => crate::symbol_config::AssetClassification::Equity, |
259 | | }; |
260 | | |
261 | | let mut config = crate::symbol_config::SymbolConfig::new(symbol_name, classification); |
262 | | config.description = description; |
263 | | config.primary_exchange = row.get("primary_exchange"); |
264 | | config.currency = row.get("currency"); |
265 | | |
266 | | // Handle decimal conversions safely |
267 | | if let Ok(tick_size) = row.try_get::<rust_decimal::Decimal, _>("tick_size") { |
268 | | if let Ok(f) = tick_size.try_into() { |
269 | | config.tick_size = f; |
270 | | } |
271 | | } |
272 | | |
273 | | Ok(Some(config)) |
274 | | } else { |
275 | | Ok(None) |
276 | | } |
277 | | } |
278 | | |
279 | | /// Loads all active symbol configurations. |
280 | | pub async fn load_all_symbols( |
281 | | &self, |
282 | | ) -> Result<Vec<crate::symbol_config::SymbolConfig>, sqlx::Error> { |
283 | | let query = " |
284 | | SELECT symbol, description, classification |
285 | | FROM symbol_config |
286 | | WHERE is_active = true |
287 | | ORDER BY symbol |
288 | | "; |
289 | | |
290 | | let rows = sqlx::query(query).fetch_all(&self.pool).await?; |
291 | | |
292 | | let mut configs = Vec::new(); |
293 | | for row in rows { |
294 | | let symbol_name: String = row.get("symbol"); |
295 | | let description: String = row.get("description"); |
296 | | let classification_str: String = row.get("classification"); |
297 | | |
298 | | let classification = match classification_str.as_str() { |
299 | | "EQUITY" => crate::symbol_config::AssetClassification::Equity, |
300 | | "FUTURE" => crate::symbol_config::AssetClassification::Future, |
301 | | "FOREX" => crate::symbol_config::AssetClassification::Forex, |
302 | | "CRYPTO" => crate::symbol_config::AssetClassification::Crypto, |
303 | | "COMMODITY" => crate::symbol_config::AssetClassification::Commodity, |
304 | | "FIXED_INCOME" => crate::symbol_config::AssetClassification::FixedIncome, |
305 | | "OPTION" => crate::symbol_config::AssetClassification::Option, |
306 | | "ETF" => crate::symbol_config::AssetClassification::Etf, |
307 | | "INDEX" => crate::symbol_config::AssetClassification::Index, |
308 | | "DERIVATIVE" => crate::symbol_config::AssetClassification::Derivative, |
309 | | _ => crate::symbol_config::AssetClassification::Equity, |
310 | | }; |
311 | | |
312 | | let mut config = crate::symbol_config::SymbolConfig::new(symbol_name, classification); |
313 | | config.description = description; |
314 | | configs.push(config); |
315 | | } |
316 | | |
317 | | Ok(configs) |
318 | | } |
319 | | |
320 | | /// Loads symbols filtered by asset classification. |
321 | | pub async fn load_symbols_by_classification( |
322 | | &self, |
323 | | classification: crate::symbol_config::AssetClassification, |
324 | | ) -> Result<Vec<crate::symbol_config::SymbolConfig>, sqlx::Error> { |
325 | | let class_str = classification.regulatory_class(); |
326 | | |
327 | | let query = " |
328 | | SELECT symbol, description, classification |
329 | | FROM symbol_config |
330 | | WHERE is_active = true AND classification = $1 |
331 | | ORDER BY symbol |
332 | | "; |
333 | | |
334 | | let rows = sqlx::query(query) |
335 | | .bind(class_str) |
336 | | .fetch_all(&self.pool) |
337 | | .await?; |
338 | | |
339 | | let mut configs = Vec::new(); |
340 | | for row in rows { |
341 | | let symbol_name: String = row.get("symbol"); |
342 | | let description: String = row.get("description"); |
343 | | let mut config = |
344 | | crate::symbol_config::SymbolConfig::new(symbol_name, classification.clone()); |
345 | | config.description = description; |
346 | | configs.push(config); |
347 | | } |
348 | | |
349 | | Ok(configs) |
350 | | } |
351 | | /// Saves or updates a symbol configuration. |
352 | | pub async fn save_symbol_config( |
353 | | &self, |
354 | | config: &crate::symbol_config::SymbolConfig, |
355 | | ) -> Result<(), sqlx::Error> { |
356 | | let query = " |
357 | | INSERT INTO symbol_config ( |
358 | | symbol, description, classification, primary_exchange, currency |
359 | | ) VALUES ($1, $2, $3, $4, $5) |
360 | | ON CONFLICT (symbol) DO UPDATE SET |
361 | | description = EXCLUDED.description, |
362 | | classification = EXCLUDED.classification, |
363 | | primary_exchange = EXCLUDED.primary_exchange, |
364 | | currency = EXCLUDED.currency, |
365 | | updated_at = NOW() |
366 | | "; |
367 | | |
368 | | sqlx::query(query) |
369 | | .bind(&config.symbol) |
370 | | .bind(&config.description) |
371 | | .bind(config.classification.regulatory_class()) |
372 | | .bind(&config.primary_exchange) |
373 | | .bind(&config.currency) |
374 | | .execute(&self.pool) |
375 | | .await?; |
376 | | |
377 | | Ok(()) |
378 | | } |
379 | | |
380 | | /// Initializes PostgreSQL NOTIFY/LISTEN for configuration hot-reload. |
381 | | pub async fn enable_hot_reload(&mut self) -> Result<(), sqlx::Error> { |
382 | | let mut listener = sqlx::postgres::PgListener::connect_with(&self.pool).await?; |
383 | | listener.listen("symbol_config_changed").await?; |
384 | | self.listener = Some(listener); |
385 | | Ok(()) |
386 | | } |
387 | | |
388 | | /// Checks for configuration change notifications. |
389 | | pub async fn check_for_updates(&mut self) -> Result<Option<String>, sqlx::Error> { |
390 | | if let Some(listener) = &mut self.listener { |
391 | | if let Some(notification) = listener.try_recv().await? { |
392 | | return Ok(Some(notification.payload().to_string())); |
393 | | } |
394 | | } |
395 | | Ok(None) |
396 | | } |
397 | | } |
398 | | |
399 | | /// Database integration for comprehensive asset classification system. |
400 | | /// |
401 | | /// Provides PostgreSQL-backed storage and retrieval for asset classification |
402 | | /// configurations with support for pattern matching, caching, and hot-reload. |
403 | | #[cfg(feature = "postgres")] |
404 | | pub struct PostgresAssetClassificationLoader { |
405 | | /// Database connection pool |
406 | | pool: sqlx::PgPool, |
407 | | /// Configuration cache timeout |
408 | | cache_timeout: Duration, |
409 | | /// PostgreSQL listener for configuration changes |
410 | | listener: Option<sqlx::postgres::PgListener>, |
411 | | } |
412 | | |
413 | | #[cfg(feature = "postgres")] |
414 | | impl PostgresAssetClassificationLoader { |
415 | | /// Creates a new PostgreSQL asset classification loader. |
416 | | pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> { |
417 | | let pool = sqlx::PgPool::connect(database_url).await?; |
418 | | |
419 | | Ok(Self { |
420 | | pool, |
421 | | cache_timeout: Duration::from_secs(300), // 5 minutes |
422 | | listener: None, |
423 | | }) |
424 | | } |
425 | | |
426 | | /// Creates a new loader with an existing connection pool. |
427 | | pub fn with_pool(pool: sqlx::PgPool) -> Self { |
428 | | Self { |
429 | | pool, |
430 | | cache_timeout: Duration::from_secs(300), |
431 | | listener: None, |
432 | | } |
433 | | } |
434 | | |
435 | | /// Loads all active asset configurations ordered by priority. |
436 | | pub async fn load_asset_configurations( |
437 | | &self, |
438 | | ) -> Result<Vec<crate::asset_classification::AssetConfig>, sqlx::Error> { |
439 | | let query = " |
440 | | SELECT |
441 | | id, |
442 | | name, |
443 | | symbol_pattern, |
444 | | asset_class_data, |
445 | | volatility_profile, |
446 | | trading_parameters, |
447 | | priority, |
448 | | is_active, |
449 | | created_at, |
450 | | updated_at, |
451 | | trading_hours, |
452 | | settlement_config |
453 | | FROM asset_configurations |
454 | | WHERE is_active = true |
455 | | ORDER BY priority DESC |
456 | | "; |
457 | | |
458 | | let rows = sqlx::query(query).fetch_all(&self.pool).await?; |
459 | | |
460 | | let mut configs = Vec::new(); |
461 | | for row in rows { |
462 | | if let Ok(config) = self.row_to_asset_config(row) { |
463 | | configs.push(config); |
464 | | } |
465 | | } |
466 | | |
467 | | Ok(configs) |
468 | | } |
469 | | |
470 | | /// Loads a specific asset configuration by ID. |
471 | | pub async fn load_asset_configuration_by_id( |
472 | | &self, |
473 | | id: uuid::Uuid, |
474 | | ) -> Result<Option<crate::asset_classification::AssetConfig>, sqlx::Error> { |
475 | | let query = " |
476 | | SELECT |
477 | | id, |
478 | | name, |
479 | | symbol_pattern, |
480 | | asset_class_data, |
481 | | volatility_profile, |
482 | | trading_parameters, |
483 | | priority, |
484 | | is_active, |
485 | | created_at, |
486 | | updated_at, |
487 | | trading_hours, |
488 | | settlement_config |
489 | | FROM asset_configurations |
490 | | WHERE id = $1 |
491 | | "; |
492 | | |
493 | | let row = sqlx::query(query) |
494 | | .bind(id) |
495 | | .fetch_optional(&self.pool) |
496 | | .await?; |
497 | | |
498 | | if let Some(row) = row { |
499 | | Ok(Some(self.row_to_asset_config(row)?)) |
500 | | } else { |
501 | | Ok(None) |
502 | | } |
503 | | } |
504 | | |
505 | | /// Saves or updates an asset configuration. |
506 | | pub async fn save_asset_configuration( |
507 | | &self, |
508 | | config: &crate::asset_classification::AssetConfig, |
509 | | ) -> Result<(), sqlx::Error> { |
510 | | let query = " |
511 | | INSERT INTO asset_configurations ( |
512 | | id, name, symbol_pattern, asset_class_data, volatility_profile, |
513 | | trading_parameters, priority, is_active, created_at, updated_at, |
514 | | trading_hours, settlement_config |
515 | | ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) |
516 | | ON CONFLICT (id) DO UPDATE SET |
517 | | name = EXCLUDED.name, |
518 | | symbol_pattern = EXCLUDED.symbol_pattern, |
519 | | asset_class_data = EXCLUDED.asset_class_data, |
520 | | volatility_profile = EXCLUDED.volatility_profile, |
521 | | trading_parameters = EXCLUDED.trading_parameters, |
522 | | priority = EXCLUDED.priority, |
523 | | is_active = EXCLUDED.is_active, |
524 | | updated_at = NOW(), |
525 | | trading_hours = EXCLUDED.trading_hours, |
526 | | settlement_config = EXCLUDED.settlement_config |
527 | | "; |
528 | | |
529 | | let asset_class_json = serde_json::to_value(&config.asset_class) |
530 | | .map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
531 | | let volatility_json = serde_json::to_value(&config.volatility_profile) |
532 | | .map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
533 | | let trading_params_json = serde_json::to_value(&config.trading_parameters) |
534 | | .map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
535 | | let trading_hours_json = serde_json::to_value(&config.trading_hours) |
536 | | .map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
537 | | let settlement_json = serde_json::to_value(&config.settlement_config) |
538 | | .map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
539 | | |
540 | | sqlx::query(query) |
541 | | .bind(config.id) |
542 | | .bind(&config.name) |
543 | | .bind(&config.symbol_pattern) |
544 | | .bind(asset_class_json) |
545 | | .bind(volatility_json) |
546 | | .bind(trading_params_json) |
547 | | .bind(config.priority as i32) |
548 | | .bind(config.is_active) |
549 | | .bind(config.created_at) |
550 | | .bind(config.updated_at) |
551 | | .bind(trading_hours_json) |
552 | | .bind(settlement_json) |
553 | | .execute(&self.pool) |
554 | | .await?; |
555 | | |
556 | | Ok(()) |
557 | | } |
558 | | |
559 | | /// Loads explicit symbol mappings. |
560 | | pub async fn load_symbol_mappings( |
561 | | &self, |
562 | | ) -> Result< |
563 | | std::collections::HashMap<String, crate::asset_classification::AssetClass>, |
564 | | sqlx::Error, |
565 | | > { |
566 | | let query = " |
567 | | SELECT symbol, asset_class_data |
568 | | FROM symbol_mappings |
569 | | WHERE is_active = true AND (expires_at IS NULL OR expires_at > NOW()) |
570 | | "; |
571 | | |
572 | | let rows = sqlx::query(query).fetch_all(&self.pool).await?; |
573 | | |
574 | | let mut mappings = std::collections::HashMap::new(); |
575 | | for row in rows { |
576 | | let symbol: String = row.get("symbol"); |
577 | | let asset_class_json: serde_json::Value = row.get("asset_class_data"); |
578 | | |
579 | | if let Ok(asset_class) = |
580 | | serde_json::from_value::<crate::asset_classification::AssetClass>(asset_class_json) |
581 | | { |
582 | | mappings.insert(symbol.to_uppercase(), asset_class); |
583 | | } |
584 | | } |
585 | | |
586 | | Ok(mappings) |
587 | | } |
588 | | |
589 | | /// Saves a symbol mapping. |
590 | | pub async fn save_symbol_mapping( |
591 | | &self, |
592 | | symbol: &str, |
593 | | asset_class: &crate::asset_classification::AssetClass, |
594 | | source: &str, |
595 | | confidence_score: f64, |
596 | | expires_at: Option<chrono::DateTime<chrono::Utc>>, |
597 | | ) -> Result<(), sqlx::Error> { |
598 | | let query = " |
599 | | INSERT INTO symbol_mappings ( |
600 | | symbol, asset_class_data, source, confidence_score, expires_at |
601 | | ) VALUES ($1, $2, $3, $4, $5) |
602 | | ON CONFLICT (symbol) DO UPDATE SET |
603 | | asset_class_data = EXCLUDED.asset_class_data, |
604 | | source = EXCLUDED.source, |
605 | | confidence_score = EXCLUDED.confidence_score, |
606 | | expires_at = EXCLUDED.expires_at, |
607 | | updated_at = NOW() |
608 | | "; |
609 | | |
610 | | let asset_class_json = |
611 | | serde_json::to_value(asset_class).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
612 | | |
613 | | sqlx::query(query) |
614 | | .bind(symbol.to_uppercase()) |
615 | | .bind(asset_class_json) |
616 | | .bind(source) |
617 | | .bind(confidence_score) |
618 | | .bind(expires_at) |
619 | | .execute(&self.pool) |
620 | | .await?; |
621 | | |
622 | | Ok(()) |
623 | | } |
624 | | |
625 | | /// Loads volatility profiles. |
626 | | pub async fn load_volatility_profiles( |
627 | | &self, |
628 | | ) -> Result< |
629 | | std::collections::HashMap<String, crate::asset_classification::VolatilityProfile>, |
630 | | sqlx::Error, |
631 | | > { |
632 | | let query = " |
633 | | SELECT |
634 | | name, |
635 | | base_annual_volatility, |
636 | | stress_volatility_multiplier, |
637 | | intraday_pattern, |
638 | | volatility_persistence, |
639 | | jump_risk |
640 | | FROM volatility_profiles |
641 | | WHERE is_active = true |
642 | | "; |
643 | | |
644 | | let rows = sqlx::query(query).fetch_all(&self.pool).await?; |
645 | | |
646 | | let mut profiles = std::collections::HashMap::new(); |
647 | | for row in rows { |
648 | | let name: String = row.get("name"); |
649 | | let base_volatility: rust_decimal::Decimal = row.get("base_annual_volatility"); |
650 | | let stress_multiplier: rust_decimal::Decimal = row.get("stress_volatility_multiplier"); |
651 | | let persistence: rust_decimal::Decimal = row.get("volatility_persistence"); |
652 | | let intraday_json: serde_json::Value = row.get("intraday_pattern"); |
653 | | let jump_risk_json: serde_json::Value = row.get("jump_risk"); |
654 | | |
655 | | if let (Ok(base_vol), Ok(stress_mult), Ok(persist), Ok(intraday), Ok(jump_risk)) = ( |
656 | | f64::try_from(base_volatility), |
657 | | f64::try_from(stress_multiplier), |
658 | | f64::try_from(persistence), |
659 | | serde_json::from_value::<Vec<f64>>(intraday_json), |
660 | | serde_json::from_value::<crate::asset_classification::JumpRiskProfile>( |
661 | | jump_risk_json, |
662 | | ), |
663 | | ) { |
664 | | let profile = crate::asset_classification::VolatilityProfile { |
665 | | base_annual_volatility: base_vol, |
666 | | stress_volatility_multiplier: stress_mult, |
667 | | intraday_pattern: intraday, |
668 | | volatility_persistence: persist, |
669 | | jump_risk, |
670 | | }; |
671 | | profiles.insert(name, profile); |
672 | | } |
673 | | } |
674 | | |
675 | | Ok(profiles) |
676 | | } |
677 | | |
678 | | /// Caches symbol classification for performance. |
679 | | pub async fn cache_symbol_classification( |
680 | | &self, |
681 | | symbol: &str, |
682 | | asset_class: &crate::asset_classification::AssetClass, |
683 | | configuration_id: Option<uuid::Uuid>, |
684 | | ) -> Result<(), sqlx::Error> { |
685 | | let query = " |
686 | | INSERT INTO asset_classification_cache (symbol, asset_class_data, configuration_id) |
687 | | VALUES ($1, $2, $3) |
688 | | ON CONFLICT (symbol) DO UPDATE SET |
689 | | asset_class_data = EXCLUDED.asset_class_data, |
690 | | configuration_id = EXCLUDED.configuration_id, |
691 | | cached_at = NOW(), |
692 | | expires_at = NOW() + INTERVAL '1 hour' |
693 | | "; |
694 | | |
695 | | let asset_class_json = |
696 | | serde_json::to_value(asset_class).map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
697 | | |
698 | | sqlx::query(query) |
699 | | .bind(symbol.to_uppercase()) |
700 | | .bind(asset_class_json) |
701 | | .bind(configuration_id) |
702 | | .execute(&self.pool) |
703 | | .await?; |
704 | | |
705 | | Ok(()) |
706 | | } |
707 | | |
708 | | /// Retrieves cached symbol classification. |
709 | | pub async fn get_cached_classification( |
710 | | &self, |
711 | | symbol: &str, |
712 | | ) -> Result<Option<crate::asset_classification::AssetClass>, sqlx::Error> { |
713 | | let query = " |
714 | | SELECT asset_class_data |
715 | | FROM asset_classification_cache |
716 | | WHERE symbol = $1 AND expires_at > NOW() |
717 | | "; |
718 | | |
719 | | let row = sqlx::query(query) |
720 | | .bind(symbol.to_uppercase()) |
721 | | .fetch_optional(&self.pool) |
722 | | .await?; |
723 | | |
724 | | if let Some(row) = row { |
725 | | let asset_class_json: serde_json::Value = row.get("asset_class_data"); |
726 | | Ok(serde_json::from_value(asset_class_json).ok()) |
727 | | } else { |
728 | | Ok(None) |
729 | | } |
730 | | } |
731 | | |
732 | | /// Cleans up expired cache entries. |
733 | | pub async fn cleanup_cache(&self) -> Result<u64, sqlx::Error> { |
734 | | let query = "DELETE FROM asset_classification_cache WHERE expires_at < NOW()"; |
735 | | let result = sqlx::query(query).execute(&self.pool).await?; |
736 | | Ok(result.rows_affected()) |
737 | | } |
738 | | |
739 | | /// Logs asset classification changes for audit. |
740 | | pub async fn log_classification_change( |
741 | | &self, |
742 | | symbol: &str, |
743 | | old_classification: Option<&crate::asset_classification::AssetClass>, |
744 | | new_classification: &crate::asset_classification::AssetClass, |
745 | | changed_by: &str, |
746 | | reason: &str, |
747 | | ) -> Result<(), sqlx::Error> { |
748 | | let query = " |
749 | | INSERT INTO asset_classification_audit ( |
750 | | symbol, old_classification, new_classification, changed_by, change_reason |
751 | | ) VALUES ($1, $2, $3, $4, $5) |
752 | | "; |
753 | | |
754 | | let old_json = old_classification |
755 | | .map(|c| serde_json::to_value(c).ok()) |
756 | | .flatten(); |
757 | | let new_json = serde_json::to_value(new_classification) |
758 | | .map_err(|e| sqlx::Error::Encode(Box::new(e)))?; |
759 | | |
760 | | sqlx::query(query) |
761 | | .bind(symbol) |
762 | | .bind(old_json) |
763 | | .bind(new_json) |
764 | | .bind(changed_by) |
765 | | .bind(reason) |
766 | | .execute(&self.pool) |
767 | | .await?; |
768 | | |
769 | | Ok(()) |
770 | | } |
771 | | |
772 | | /// Enables PostgreSQL NOTIFY/LISTEN for configuration hot-reload. |
773 | | pub async fn enable_hot_reload(&mut self) -> Result<(), sqlx::Error> { |
774 | | let mut listener = sqlx::postgres::PgListener::connect_with(&self.pool).await?; |
775 | | listener.listen("config_change").await?; |
776 | | self.listener = Some(listener); |
777 | | Ok(()) |
778 | | } |
779 | | |
780 | | /// Checks for configuration change notifications. |
781 | | pub async fn check_for_config_updates(&mut self) -> Result<Option<String>, sqlx::Error> { |
782 | | if let Some(listener) = &mut self.listener { |
783 | | if let Some(notification) = listener.try_recv().await? { |
784 | | return Ok(Some(notification.payload().to_string())); |
785 | | } |
786 | | } |
787 | | Ok(None) |
788 | | } |
789 | | |
790 | | /// Converts a database row to AssetConfig. |
791 | | fn row_to_asset_config( |
792 | | &self, |
793 | | row: sqlx::postgres::PgRow, |
794 | | ) -> Result<crate::asset_classification::AssetConfig, sqlx::Error> { |
795 | | let id: uuid::Uuid = row.get("id"); |
796 | | let name: String = row.get("name"); |
797 | | let symbol_pattern: String = row.get("symbol_pattern"); |
798 | | let priority: i32 = row.get("priority"); |
799 | | let is_active: bool = row.get("is_active"); |
800 | | let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at"); |
801 | | let updated_at: chrono::DateTime<chrono::Utc> = row.get("updated_at"); |
802 | | |
803 | | let asset_class_json: serde_json::Value = row.get("asset_class_data"); |
804 | | let volatility_json: serde_json::Value = row.get("volatility_profile"); |
805 | | let trading_params_json: serde_json::Value = row.get("trading_parameters"); |
806 | | let trading_hours_json: Option<serde_json::Value> = row.get("trading_hours"); |
807 | | let settlement_json: serde_json::Value = row.get("settlement_config"); |
808 | | |
809 | | let asset_class = serde_json::from_value(asset_class_json) |
810 | | .map_err(|e| sqlx::Error::Decode(Box::new(e)))?; |
811 | | let volatility_profile = serde_json::from_value(volatility_json) |
812 | | .map_err(|e| sqlx::Error::Decode(Box::new(e)))?; |
813 | | let trading_parameters = serde_json::from_value(trading_params_json) |
814 | | .map_err(|e| sqlx::Error::Decode(Box::new(e)))?; |
815 | | let trading_hours = trading_hours_json |
816 | | .map(|json| serde_json::from_value(json).ok()) |
817 | | .flatten(); |
818 | | let settlement_config = serde_json::from_value(settlement_json) |
819 | | .map_err(|e| sqlx::Error::Decode(Box::new(e)))?; |
820 | | |
821 | | Ok(crate::asset_classification::AssetConfig { |
822 | | id, |
823 | | name, |
824 | | symbol_pattern, |
825 | | compiled_pattern: None, // Will be compiled when loaded |
826 | | asset_class, |
827 | | volatility_profile, |
828 | | trading_parameters, |
829 | | priority: priority as u32, |
830 | | is_active, |
831 | | created_at, |
832 | | updated_at, |
833 | | trading_hours, |
834 | | settlement_config, |
835 | | }) |
836 | | } |
837 | | } |
838 | | |
839 | | /// General-purpose PostgreSQL configuration loader for various configuration types. |
840 | | /// |
841 | | /// Provides a unified interface for loading configurations from PostgreSQL with |
842 | | /// support for hot-reload through NOTIFY/LISTEN and caching for performance. |
843 | | #[cfg(feature = "postgres")] |
844 | | pub struct PostgresConfigLoader { |
845 | | /// Database connection pool |
846 | | pool: sqlx::PgPool, |
847 | | /// Configuration cache timeout |
848 | | cache_timeout: Duration, |
849 | | } |
850 | | |
851 | | #[cfg(feature = "postgres")] |
852 | | impl PostgresConfigLoader { |
853 | | /// Creates a new PostgreSQL configuration loader. |
854 | | pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> { |
855 | | let pool = sqlx::PgPool::connect(database_url).await?; |
856 | | |
857 | | Ok(Self { |
858 | | pool, |
859 | | cache_timeout: Duration::from_secs(300), // 5 minutes |
860 | | }) |
861 | | } |
862 | | |
863 | | /// Creates a new loader with an existing connection pool. |
864 | | pub fn with_pool(pool: sqlx::PgPool) -> Self { |
865 | | Self { |
866 | | pool, |
867 | | cache_timeout: Duration::from_secs(300), |
868 | | } |
869 | | } |
870 | | |
871 | | /// Get the underlying connection pool. |
872 | | pub fn pool(&self) -> &sqlx::PgPool { |
873 | | &self.pool |
874 | | } |
875 | | |
876 | | // ============================================================================ |
877 | | // ADAPTIVE STRATEGY CONFIGURATION METHODS |
878 | | // ============================================================================ |
879 | | |
880 | | /// Get adaptive strategy configuration by strategy ID. |
881 | | /// |
882 | | /// Loads the complete configuration including main settings, models, and features |
883 | | /// from the PostgreSQL database. Returns None if the strategy doesn't exist. |
884 | | /// |
885 | | /// # Arguments |
886 | | /// * `strategy_id` - Unique identifier for the strategy (e.g., "default", "prod_v1") |
887 | | /// |
888 | | /// # Returns |
889 | | /// - `Ok(Some(config))` - Configuration found and loaded successfully |
890 | | /// - `Ok(None)` - Strategy ID not found in database |
891 | | /// - `Err(sqlx::Error)` - Database error occurred |
892 | | /// |
893 | | /// # Example |
894 | | /// ```no_run |
895 | | /// # use config::PostgresConfigLoader; |
896 | | /// # async fn example(loader: &PostgresConfigLoader) -> Result<(), sqlx::Error> { |
897 | | /// let config = loader.get_adaptive_strategy_config("default").await?; |
898 | | /// if let Some(cfg) = config { |
899 | | /// println!("Loaded strategy: {}", cfg.name); |
900 | | /// } |
901 | | /// # Ok(()) |
902 | | /// # } |
903 | | /// ``` |
904 | | pub async fn get_adaptive_strategy_config( |
905 | | &self, |
906 | | strategy_id: &str, |
907 | | ) -> Result<Option<serde_json::Value>, sqlx::Error> { |
908 | | // Query main configuration |
909 | | let row = sqlx::query( |
910 | | r#" |
911 | | SELECT |
912 | | id, strategy_id, name, description, |
913 | | execution_interval_ms, error_backoff_duration_secs, |
914 | | max_concurrent_operations, strategy_timeout_secs, |
915 | | max_parallel_models, rebalancing_interval_secs, |
916 | | min_model_weight, max_model_weight, |
917 | | max_position_size, max_leverage, stop_loss_pct, |
918 | | position_sizing_method, max_portfolio_var, |
919 | | max_drawdown_threshold, kelly_fraction, |
920 | | book_depth, vpin_window, trade_classification_threshold, |
921 | | trade_size_buckets, microstructure_features, |
922 | | regime_detection_method, regime_lookback_window, |
923 | | regime_transition_threshold, regime_features, |
924 | | execution_algorithm, max_order_size, min_order_size, |
925 | | order_timeout_secs, max_slippage_bps, |
926 | | smart_routing_enabled, dark_pool_preference, |
927 | | active, version, created_at, updated_at, |
928 | | created_by, updated_by, metadata |
929 | | FROM adaptive_strategy_config |
930 | | WHERE strategy_id = $1 AND active = true |
931 | | "#, |
932 | | ) |
933 | | .bind(strategy_id) |
934 | | .fetch_optional(&self.pool) |
935 | | .await?; |
936 | | |
937 | | let Some(row) = row else { |
938 | | return Ok(None); |
939 | | }; |
940 | | |
941 | | let config_id: uuid::Uuid = row.try_get("id")?; |
942 | | |
943 | | // Query associated models |
944 | | let models = sqlx::query( |
945 | | r#" |
946 | | SELECT |
947 | | id, strategy_config_id, model_id, model_name, model_type, |
948 | | parameters, initial_weight, enabled, display_order, |
949 | | created_at, updated_at |
950 | | FROM adaptive_strategy_models |
951 | | WHERE strategy_config_id = $1 |
952 | | ORDER BY display_order, created_at |
953 | | "#, |
954 | | ) |
955 | | .bind(config_id) |
956 | | .fetch_all(&self.pool) |
957 | | .await?; |
958 | | |
959 | | // Query associated features |
960 | | let features = sqlx::query( |
961 | | r#" |
962 | | SELECT |
963 | | id, strategy_config_id, feature_name, feature_type, |
964 | | parameters, enabled, required, |
965 | | created_at, updated_at |
966 | | FROM adaptive_strategy_features |
967 | | WHERE strategy_config_id = $1 |
968 | | ORDER BY feature_name |
969 | | "#, |
970 | | ) |
971 | | .bind(config_id) |
972 | | .fetch_all(&self.pool) |
973 | | .await?; |
974 | | |
975 | | // Convert to JSON for flexibility |
976 | | // In production, you'd convert to a proper struct type |
977 | | let config = serde_json::json!({ |
978 | | "id": row.try_get::<uuid::Uuid, _>("id")?, |
979 | | "strategy_id": row.try_get::<String, _>("strategy_id")?, |
980 | | "name": row.try_get::<String, _>("name")?, |
981 | | "description": row.try_get::<Option<String>, _>("description")?, |
982 | | "general": { |
983 | | "execution_interval_ms": row.try_get::<i32, _>("execution_interval_ms")?, |
984 | | "error_backoff_duration_secs": row.try_get::<i32, _>("error_backoff_duration_secs")?, |
985 | | "max_concurrent_operations": row.try_get::<i32, _>("max_concurrent_operations")?, |
986 | | "strategy_timeout_secs": row.try_get::<i32, _>("strategy_timeout_secs")?, |
987 | | }, |
988 | | "ensemble": { |
989 | | "max_parallel_models": row.try_get::<i32, _>("max_parallel_models")?, |
990 | | "rebalancing_interval_secs": row.try_get::<i32, _>("rebalancing_interval_secs")?, |
991 | | "min_model_weight": row.try_get::<f64, _>("min_model_weight")?, |
992 | | "max_model_weight": row.try_get::<f64, _>("max_model_weight")?, |
993 | | }, |
994 | | "risk": { |
995 | | "max_position_size": row.try_get::<f64, _>("max_position_size")?, |
996 | | "max_leverage": row.try_get::<f64, _>("max_leverage")?, |
997 | | "stop_loss_pct": row.try_get::<f64, _>("stop_loss_pct")?, |
998 | | "position_sizing_method": row.try_get::<String, _>("position_sizing_method")?, |
999 | | "max_portfolio_var": row.try_get::<f64, _>("max_portfolio_var")?, |
1000 | | "max_drawdown_threshold": row.try_get::<f64, _>("max_drawdown_threshold")?, |
1001 | | "kelly_fraction": row.try_get::<f64, _>("kelly_fraction")?, |
1002 | | }, |
1003 | | "microstructure": { |
1004 | | "book_depth": row.try_get::<i32, _>("book_depth")?, |
1005 | | "vpin_window": row.try_get::<i32, _>("vpin_window")?, |
1006 | | "trade_classification_threshold": row.try_get::<f64, _>("trade_classification_threshold")?, |
1007 | | "trade_size_buckets": row.try_get::<Vec<f64>, _>("trade_size_buckets")?, |
1008 | | "features": row.try_get::<Vec<String>, _>("microstructure_features")?, |
1009 | | }, |
1010 | | "regime": { |
1011 | | "detection_method": row.try_get::<String, _>("regime_detection_method")?, |
1012 | | "lookback_window": row.try_get::<i32, _>("regime_lookback_window")?, |
1013 | | "transition_threshold": row.try_get::<f64, _>("regime_transition_threshold")?, |
1014 | | "features": row.try_get::<Vec<String>, _>("regime_features")?, |
1015 | | }, |
1016 | | "execution": { |
1017 | | "algorithm": row.try_get::<String, _>("execution_algorithm")?, |
1018 | | "max_order_size": row.try_get::<f64, _>("max_order_size")?, |
1019 | | "min_order_size": row.try_get::<f64, _>("min_order_size")?, |
1020 | | "order_timeout_secs": row.try_get::<i32, _>("order_timeout_secs")?, |
1021 | | "max_slippage_bps": row.try_get::<f64, _>("max_slippage_bps")?, |
1022 | | "smart_routing_enabled": row.try_get::<bool, _>("smart_routing_enabled")?, |
1023 | | "dark_pool_preference": row.try_get::<f64, _>("dark_pool_preference")?, |
1024 | | }, |
1025 | | "models": models.iter().map(|m| serde_json::json!({ |
1026 | | "id": m.try_get::<uuid::Uuid, _>("id").unwrap(), |
1027 | | "model_id": m.try_get::<String, _>("model_id").unwrap(), |
1028 | | "model_name": m.try_get::<String, _>("model_name").unwrap(), |
1029 | | "model_type": m.try_get::<String, _>("model_type").unwrap(), |
1030 | | "parameters": m.try_get::<serde_json::Value, _>("parameters").unwrap(), |
1031 | | "initial_weight": m.try_get::<f64, _>("initial_weight").unwrap(), |
1032 | | "enabled": m.try_get::<bool, _>("enabled").unwrap(), |
1033 | | })).collect::<Vec<_>>(), |
1034 | | "features": features.iter().map(|f| serde_json::json!({ |
1035 | | "name": f.try_get::<String, _>("feature_name").unwrap(), |
1036 | | "feature_type": f.try_get::<String, _>("feature_type").unwrap(), |
1037 | | "parameters": f.try_get::<serde_json::Value, _>("parameters").unwrap(), |
1038 | | "enabled": f.try_get::<bool, _>("enabled").unwrap(), |
1039 | | "required": f.try_get::<bool, _>("required").unwrap(), |
1040 | | })).collect::<Vec<_>>(), |
1041 | | "version": row.try_get::<i32, _>("version")?, |
1042 | | "created_at": row.try_get::<chrono::DateTime<chrono::Utc>, _>("created_at")?, |
1043 | | "updated_at": row.try_get::<chrono::DateTime<chrono::Utc>, _>("updated_at")?, |
1044 | | }); |
1045 | | |
1046 | | Ok(Some(config)) |
1047 | | } |
1048 | | |
1049 | | /// Upsert (insert or update) adaptive strategy configuration. |
1050 | | /// |
1051 | | /// Creates a new strategy configuration if it doesn't exist, or updates |
1052 | | /// the existing one. Automatically handles version tracking and audit trail. |
1053 | | /// |
1054 | | /// # Arguments |
1055 | | /// * `config` - Configuration data as JSON (allows flexibility in structure) |
1056 | | /// |
1057 | | /// # Returns |
1058 | | /// - `Ok(strategy_id)` - Strategy ID of the created/updated configuration |
1059 | | /// - `Err(sqlx::Error)` - Database error occurred |
1060 | | /// |
1061 | | /// # Example |
1062 | | /// ```no_run |
1063 | | /// # use config::PostgresConfigLoader; |
1064 | | /// # use serde_json::json; |
1065 | | /// # async fn example(loader: &PostgresConfigLoader) -> Result<(), sqlx::Error> { |
1066 | | /// let config = json!({ |
1067 | | /// "strategy_id": "my_strategy", |
1068 | | /// "name": "My Trading Strategy", |
1069 | | /// "risk": { |
1070 | | /// "max_position_size": 0.15, |
1071 | | /// "max_leverage": 3.0 |
1072 | | /// } |
1073 | | /// }); |
1074 | | /// let id = loader.upsert_adaptive_strategy_config(&config).await?; |
1075 | | /// # Ok(()) |
1076 | | /// # } |
1077 | | /// ``` |
1078 | | pub async fn upsert_adaptive_strategy_config( |
1079 | | &self, |
1080 | | config: &serde_json::Value, |
1081 | | ) -> Result<String, sqlx::Error> { |
1082 | | let strategy_id = config |
1083 | | .get("strategy_id") |
1084 | | .and_then(|v| v.as_str()) |
1085 | | .ok_or_else(|| { |
1086 | | sqlx::Error::Decode(Box::new(std::io::Error::new( |
1087 | | std::io::ErrorKind::InvalidData, |
1088 | | "Missing strategy_id in config", |
1089 | | ))) |
1090 | | })?; |
1091 | | |
1092 | | // Extract all configuration fields |
1093 | | let name = config.get("name").and_then(|v| v.as_str()).unwrap_or("Unnamed Strategy"); |
1094 | | let description = config.get("description").and_then(|v| v.as_str()); |
1095 | | |
1096 | | // Helper macro for extracting fields with defaults |
1097 | | macro_rules! get_i32 { |
1098 | | ($field:expr, $default:expr) => { |
1099 | | config.get($field).and_then(|v| v.as_i64()).map(|v| v as i32).unwrap_or($default) |
1100 | | }; |
1101 | | } |
1102 | | macro_rules! get_f64 { |
1103 | | ($field:expr, $default:expr) => { |
1104 | | config.get($field).and_then(|v| v.as_f64()).unwrap_or($default) |
1105 | | }; |
1106 | | } |
1107 | | macro_rules! get_bool { |
1108 | | ($field:expr, $default:expr) => { |
1109 | | config.get($field).and_then(|v| v.as_bool()).unwrap_or($default) |
1110 | | }; |
1111 | | } |
1112 | | macro_rules! get_str { |
1113 | | ($field:expr, $default:expr) => { |
1114 | | config.get($field).and_then(|v| v.as_str()).unwrap_or($default) |
1115 | | }; |
1116 | | } |
1117 | | |
1118 | | // Full upsert with all 50+ fields |
1119 | | let query = r#" |
1120 | | INSERT INTO adaptive_strategy_config ( |
1121 | | strategy_id, name, description, |
1122 | | -- General config |
1123 | | execution_interval_ms, error_backoff_duration_secs, |
1124 | | max_concurrent_operations, strategy_timeout_secs, |
1125 | | -- Ensemble config |
1126 | | max_parallel_models, rebalancing_interval_secs, |
1127 | | min_model_weight, max_model_weight, |
1128 | | -- Risk config |
1129 | | max_position_size, max_leverage, stop_loss_pct, |
1130 | | position_sizing_method, max_portfolio_var, |
1131 | | max_drawdown_threshold, kelly_fraction, |
1132 | | -- Microstructure config |
1133 | | book_depth, vpin_window, trade_classification_threshold, |
1134 | | trade_size_buckets, microstructure_features, |
1135 | | -- Regime config |
1136 | | regime_detection_method, regime_lookback_window, |
1137 | | regime_transition_threshold, regime_features, |
1138 | | -- Execution config |
1139 | | execution_algorithm, max_order_size, min_order_size, |
1140 | | order_timeout_secs, max_slippage_bps, |
1141 | | smart_routing_enabled, dark_pool_preference |
1142 | | ) VALUES ( |
1143 | | $1, $2, $3, |
1144 | | $4, $5, $6, $7, |
1145 | | $8, $9, $10, $11, |
1146 | | $12, $13, $14, $15, $16, $17, $18, |
1147 | | $19, $20, $21, $22, $23, |
1148 | | $24, $25, $26, $27, |
1149 | | $28, $29, $30, $31, $32, $33, $34 |
1150 | | ) |
1151 | | ON CONFLICT (strategy_id) |
1152 | | DO UPDATE SET |
1153 | | name = EXCLUDED.name, |
1154 | | description = EXCLUDED.description, |
1155 | | execution_interval_ms = EXCLUDED.execution_interval_ms, |
1156 | | error_backoff_duration_secs = EXCLUDED.error_backoff_duration_secs, |
1157 | | max_concurrent_operations = EXCLUDED.max_concurrent_operations, |
1158 | | strategy_timeout_secs = EXCLUDED.strategy_timeout_secs, |
1159 | | max_parallel_models = EXCLUDED.max_parallel_models, |
1160 | | rebalancing_interval_secs = EXCLUDED.rebalancing_interval_secs, |
1161 | | min_model_weight = EXCLUDED.min_model_weight, |
1162 | | max_model_weight = EXCLUDED.max_model_weight, |
1163 | | max_position_size = EXCLUDED.max_position_size, |
1164 | | max_leverage = EXCLUDED.max_leverage, |
1165 | | stop_loss_pct = EXCLUDED.stop_loss_pct, |
1166 | | position_sizing_method = EXCLUDED.position_sizing_method, |
1167 | | max_portfolio_var = EXCLUDED.max_portfolio_var, |
1168 | | max_drawdown_threshold = EXCLUDED.max_drawdown_threshold, |
1169 | | kelly_fraction = EXCLUDED.kelly_fraction, |
1170 | | book_depth = EXCLUDED.book_depth, |
1171 | | vpin_window = EXCLUDED.vpin_window, |
1172 | | trade_classification_threshold = EXCLUDED.trade_classification_threshold, |
1173 | | trade_size_buckets = EXCLUDED.trade_size_buckets, |
1174 | | microstructure_features = EXCLUDED.microstructure_features, |
1175 | | regime_detection_method = EXCLUDED.regime_detection_method, |
1176 | | regime_lookback_window = EXCLUDED.regime_lookback_window, |
1177 | | regime_transition_threshold = EXCLUDED.regime_transition_threshold, |
1178 | | regime_features = EXCLUDED.regime_features, |
1179 | | execution_algorithm = EXCLUDED.execution_algorithm, |
1180 | | max_order_size = EXCLUDED.max_order_size, |
1181 | | min_order_size = EXCLUDED.min_order_size, |
1182 | | order_timeout_secs = EXCLUDED.order_timeout_secs, |
1183 | | max_slippage_bps = EXCLUDED.max_slippage_bps, |
1184 | | smart_routing_enabled = EXCLUDED.smart_routing_enabled, |
1185 | | dark_pool_preference = EXCLUDED.dark_pool_preference, |
1186 | | updated_at = NOW() |
1187 | | RETURNING strategy_id |
1188 | | "#; |
1189 | | |
1190 | | // Extract trade_size_buckets and features arrays |
1191 | | let trade_size_buckets: Vec<f64> = config.get("trade_size_buckets") |
1192 | | .and_then(|v| v.as_array()) |
1193 | | .map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect()) |
1194 | | .unwrap_or_else(|| vec![10.0, 100.0, 1000.0, 10000.0]); |
1195 | | |
1196 | | let microstructure_features: Vec<String> = config.get("microstructure_features") |
1197 | | .and_then(|v| v.as_array()) |
1198 | | .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) |
1199 | | .unwrap_or_else(|| vec!["vpin".to_string(), "order_flow".to_string(), "bid_ask_spread".to_string()]); |
1200 | | |
1201 | | let regime_features: Vec<String> = config.get("regime_features") |
1202 | | .and_then(|v| v.as_array()) |
1203 | | .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) |
1204 | | .unwrap_or_else(|| vec!["volatility".to_string(), "momentum".to_string(), "volume".to_string()]); |
1205 | | |
1206 | | let row = sqlx::query(query) |
1207 | | .bind(strategy_id) |
1208 | | .bind(name) |
1209 | | .bind(description) |
1210 | | // General config (4 fields) |
1211 | | .bind(get_i32!("execution_interval_ms", 100)) |
1212 | | .bind(get_i32!("error_backoff_duration_secs", 1)) |
1213 | | .bind(get_i32!("max_concurrent_operations", 10)) |
1214 | | .bind(get_i32!("strategy_timeout_secs", 30)) |
1215 | | // Ensemble config (4 fields) |
1216 | | .bind(get_i32!("max_parallel_models", 4)) |
1217 | | .bind(get_i32!("rebalancing_interval_secs", 300)) |
1218 | | .bind(get_f64!("min_model_weight", 0.01)) |
1219 | | .bind(get_f64!("max_model_weight", 0.5)) |
1220 | | // Risk config (7 fields) |
1221 | | .bind(get_f64!("max_position_size", 0.1)) |
1222 | | .bind(get_f64!("max_leverage", 2.0)) |
1223 | | .bind(get_f64!("stop_loss_pct", 0.02)) |
1224 | | .bind(get_str!("position_sizing_method", "KELLY")) |
1225 | | .bind(get_f64!("max_portfolio_var", 0.02)) |
1226 | | .bind(get_f64!("max_drawdown_threshold", 0.05)) |
1227 | | .bind(get_f64!("kelly_fraction", 0.1)) |
1228 | | // Microstructure config (5 fields) |
1229 | | .bind(get_i32!("book_depth", 10)) |
1230 | | .bind(get_i32!("vpin_window", 50)) |
1231 | | .bind(get_f64!("trade_classification_threshold", 0.5)) |
1232 | | .bind(&trade_size_buckets) |
1233 | | .bind(µstructure_features) |
1234 | | // Regime config (4 fields) |
1235 | | .bind(get_str!("regime_detection_method", "HMM")) |
1236 | | .bind(get_i32!("regime_lookback_window", 252)) |
1237 | | .bind(get_f64!("regime_transition_threshold", 0.7)) |
1238 | | .bind(®ime_features) |
1239 | | // Execution config (7 fields) |
1240 | | .bind(get_str!("execution_algorithm", "TWAP")) |
1241 | | .bind(get_f64!("max_order_size", 10000.0)) |
1242 | | .bind(get_f64!("min_order_size", 100.0)) |
1243 | | .bind(get_i32!("order_timeout_secs", 30)) |
1244 | | .bind(get_f64!("max_slippage_bps", 10.0)) |
1245 | | .bind(get_bool!("smart_routing_enabled", true)) |
1246 | | .bind(get_f64!("dark_pool_preference", 0.3)) |
1247 | | .fetch_one(&self.pool) |
1248 | | .await?; |
1249 | | |
1250 | | let result: String = row.try_get("strategy_id")?; |
1251 | | Ok(result) |
1252 | | } |
1253 | | |
1254 | | // ======================================================================== |
1255 | | // MODEL CRUD OPERATIONS |
1256 | | // ======================================================================== |
1257 | | |
1258 | | /// Add a model configuration to a strategy |
1259 | | /// |
1260 | | /// # Arguments |
1261 | | /// * `strategy_config_id` - UUID of the parent strategy configuration |
1262 | | /// * `model` - Model configuration as JSON |
1263 | | /// |
1264 | | /// # Returns |
1265 | | /// UUID of the created model configuration |
1266 | | pub async fn add_model_config( |
1267 | | &self, |
1268 | | strategy_config_id: uuid::Uuid, |
1269 | | model: &serde_json::Value, |
1270 | | ) -> Result<uuid::Uuid, sqlx::Error> { |
1271 | | let model_id = model.get("model_id") |
1272 | | .and_then(|v| v.as_str()) |
1273 | | .ok_or_else(|| sqlx::Error::Decode(Box::new(std::io::Error::new( |
1274 | | std::io::ErrorKind::InvalidData, |
1275 | | "Missing model_id" |
1276 | | ))))?; |
1277 | | |
1278 | | let query = r#" |
1279 | | INSERT INTO adaptive_strategy_models ( |
1280 | | strategy_config_id, model_id, model_name, model_type, |
1281 | | parameters, initial_weight, enabled, display_order |
1282 | | ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) |
1283 | | RETURNING id |
1284 | | "#; |
1285 | | |
1286 | | let row = sqlx::query(query) |
1287 | | .bind(strategy_config_id) |
1288 | | .bind(model_id) |
1289 | | .bind(model.get("model_name").and_then(|v| v.as_str()).unwrap_or(model_id)) |
1290 | | .bind(model.get("model_type").and_then(|v| v.as_str()).unwrap_or("unknown")) |
1291 | | .bind(model.get("parameters").unwrap_or(&serde_json::json!({}))) |
1292 | | .bind(model.get("initial_weight").and_then(|v| v.as_f64()).unwrap_or(0.25)) |
1293 | | .bind(model.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)) |
1294 | | .bind(model.get("display_order").and_then(|v| v.as_i64()).unwrap_or(0) as i32) |
1295 | | .fetch_one(&self.pool) |
1296 | | .await?; |
1297 | | |
1298 | | row.try_get("id") |
1299 | | } |
1300 | | |
1301 | | /// Update a model configuration |
1302 | | /// |
1303 | | /// # Arguments |
1304 | | /// * `model_id` - UUID of the model to update |
1305 | | /// * `updates` - Fields to update as JSON |
1306 | | pub async fn update_model_config( |
1307 | | &self, |
1308 | | model_id: uuid::Uuid, |
1309 | | updates: &serde_json::Value, |
1310 | | ) -> Result<(), sqlx::Error> { |
1311 | | let query = r#" |
1312 | | UPDATE adaptive_strategy_models |
1313 | | SET |
1314 | | model_name = COALESCE($1, model_name), |
1315 | | model_type = COALESCE($2, model_type), |
1316 | | parameters = COALESCE($3, parameters), |
1317 | | initial_weight = COALESCE($4, initial_weight), |
1318 | | enabled = COALESCE($5, enabled), |
1319 | | display_order = COALESCE($6, display_order), |
1320 | | updated_at = NOW() |
1321 | | WHERE id = $7 |
1322 | | "#; |
1323 | | |
1324 | | sqlx::query(query) |
1325 | | .bind(updates.get("model_name").and_then(|v| v.as_str())) |
1326 | | .bind(updates.get("model_type").and_then(|v| v.as_str())) |
1327 | | .bind(updates.get("parameters")) |
1328 | | .bind(updates.get("initial_weight").and_then(|v| v.as_f64())) |
1329 | | .bind(updates.get("enabled").and_then(|v| v.as_bool())) |
1330 | | .bind(updates.get("display_order").and_then(|v| v.as_i64()).map(|v| v as i32)) |
1331 | | .bind(model_id) |
1332 | | .execute(&self.pool) |
1333 | | .await?; |
1334 | | |
1335 | | Ok(()) |
1336 | | } |
1337 | | |
1338 | | /// Remove a model configuration |
1339 | | /// |
1340 | | /// # Arguments |
1341 | | /// * `model_id` - UUID of the model to remove |
1342 | | pub async fn remove_model_config( |
1343 | | &self, |
1344 | | model_id: uuid::Uuid, |
1345 | | ) -> Result<(), sqlx::Error> { |
1346 | | let query = "DELETE FROM adaptive_strategy_models WHERE id = $1"; |
1347 | | sqlx::query(query) |
1348 | | .bind(model_id) |
1349 | | .execute(&self.pool) |
1350 | | .await?; |
1351 | | Ok(()) |
1352 | | } |
1353 | | |
1354 | | // ======================================================================== |
1355 | | // FEATURE CRUD OPERATIONS |
1356 | | // ======================================================================== |
1357 | | |
1358 | | /// Add a feature configuration to a strategy |
1359 | | /// |
1360 | | /// # Arguments |
1361 | | /// * `strategy_config_id` - UUID of the parent strategy configuration |
1362 | | /// * `feature` - Feature configuration as JSON |
1363 | | /// |
1364 | | /// # Returns |
1365 | | /// UUID of the created feature configuration |
1366 | | pub async fn add_feature_config( |
1367 | | &self, |
1368 | | strategy_config_id: uuid::Uuid, |
1369 | | feature: &serde_json::Value, |
1370 | | ) -> Result<uuid::Uuid, sqlx::Error> { |
1371 | | let feature_name = feature.get("feature_name") |
1372 | | .and_then(|v| v.as_str()) |
1373 | | .ok_or_else(|| sqlx::Error::Decode(Box::new(std::io::Error::new( |
1374 | | std::io::ErrorKind::InvalidData, |
1375 | | "Missing feature_name" |
1376 | | ))))?; |
1377 | | |
1378 | | let query = r#" |
1379 | | INSERT INTO adaptive_strategy_features ( |
1380 | | strategy_config_id, feature_name, feature_type, |
1381 | | parameters, enabled, required |
1382 | | ) VALUES ($1, $2, $3, $4, $5, $6) |
1383 | | RETURNING id |
1384 | | "#; |
1385 | | |
1386 | | let row = sqlx::query(query) |
1387 | | .bind(strategy_config_id) |
1388 | | .bind(feature_name) |
1389 | | .bind(feature.get("feature_type").and_then(|v| v.as_str()).unwrap_or("unknown")) |
1390 | | .bind(feature.get("parameters").unwrap_or(&serde_json::json!({}))) |
1391 | | .bind(feature.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)) |
1392 | | .bind(feature.get("required").and_then(|v| v.as_bool()).unwrap_or(false)) |
1393 | | .fetch_one(&self.pool) |
1394 | | .await?; |
1395 | | |
1396 | | row.try_get("id") |
1397 | | } |
1398 | | |
1399 | | /// Update a feature configuration |
1400 | | /// |
1401 | | /// # Arguments |
1402 | | /// * `feature_id` - UUID of the feature to update |
1403 | | /// * `updates` - Fields to update as JSON |
1404 | | pub async fn update_feature_config( |
1405 | | &self, |
1406 | | feature_id: uuid::Uuid, |
1407 | | updates: &serde_json::Value, |
1408 | | ) -> Result<(), sqlx::Error> { |
1409 | | let query = r#" |
1410 | | UPDATE adaptive_strategy_features |
1411 | | SET |
1412 | | feature_type = COALESCE($1, feature_type), |
1413 | | parameters = COALESCE($2, parameters), |
1414 | | enabled = COALESCE($3, enabled), |
1415 | | required = COALESCE($4, required), |
1416 | | updated_at = NOW() |
1417 | | WHERE id = $5 |
1418 | | "#; |
1419 | | |
1420 | | sqlx::query(query) |
1421 | | .bind(updates.get("feature_type").and_then(|v| v.as_str())) |
1422 | | .bind(updates.get("parameters")) |
1423 | | .bind(updates.get("enabled").and_then(|v| v.as_bool())) |
1424 | | .bind(updates.get("required").and_then(|v| v.as_bool())) |
1425 | | .bind(feature_id) |
1426 | | .execute(&self.pool) |
1427 | | .await?; |
1428 | | |
1429 | | Ok(()) |
1430 | | } |
1431 | | |
1432 | | /// Remove a feature configuration |
1433 | | /// |
1434 | | /// # Arguments |
1435 | | /// * `feature_id` - UUID of the feature to remove |
1436 | | pub async fn remove_feature_config( |
1437 | | &self, |
1438 | | feature_id: uuid::Uuid, |
1439 | | ) -> Result<(), sqlx::Error> { |
1440 | | let query = "DELETE FROM adaptive_strategy_features WHERE id = $1"; |
1441 | | sqlx::query(query) |
1442 | | .bind(feature_id) |
1443 | | .execute(&self.pool) |
1444 | | .await?; |
1445 | | Ok(()) |
1446 | | } |
1447 | | |
1448 | | // ======================================================================== |
1449 | | // TRANSACTION SUPPORT |
1450 | | // ======================================================================== |
1451 | | |
1452 | | /// Update strategy configuration with models and features in a single transaction |
1453 | | /// |
1454 | | /// Provides atomic updates across all three tables: |
1455 | | /// - adaptive_strategy_config (main configuration) |
1456 | | /// - adaptive_strategy_models (model configurations) |
1457 | | /// - adaptive_strategy_features (feature configurations) |
1458 | | /// |
1459 | | /// # Arguments |
1460 | | /// * `config` - Full configuration including models and features |
1461 | | /// |
1462 | | /// # Returns |
1463 | | /// Strategy ID of the updated configuration |
1464 | | pub async fn update_strategy_atomic( |
1465 | | &self, |
1466 | | config: &serde_json::Value, |
1467 | | ) -> Result<String, sqlx::Error> { |
1468 | | // Start transaction |
1469 | | let mut tx = self.pool.begin().await?; |
1470 | | |
1471 | | // 1. Upsert main configuration |
1472 | | let strategy_id = config.get("strategy_id") |
1473 | | .and_then(|v| v.as_str()) |
1474 | | .ok_or_else(|| sqlx::Error::Decode(Box::new(std::io::Error::new( |
1475 | | std::io::ErrorKind::InvalidData, |
1476 | | "Missing strategy_id" |
1477 | | ))))?; |
1478 | | |
1479 | | // Get or create config_id |
1480 | | let config_id: uuid::Uuid = sqlx::query_scalar( |
1481 | | "SELECT id FROM adaptive_strategy_config WHERE strategy_id = $1" |
1482 | | ) |
1483 | | .bind(strategy_id) |
1484 | | .fetch_optional(&mut *tx) |
1485 | | .await? |
1486 | | .unwrap_or_else(uuid::Uuid::new_v4); |
1487 | | |
1488 | | // 2. Update models if provided |
1489 | | if let Some(models) = config.get("models").and_then(|v| v.as_array()) { |
1490 | | // Delete existing models |
1491 | | sqlx::query("DELETE FROM adaptive_strategy_models WHERE strategy_config_id = $1") |
1492 | | .bind(config_id) |
1493 | | .execute(&mut *tx) |
1494 | | .await?; |
1495 | | |
1496 | | // Insert new models |
1497 | | for model in models { |
1498 | | sqlx::query(r#" |
1499 | | INSERT INTO adaptive_strategy_models ( |
1500 | | strategy_config_id, model_id, model_name, model_type, |
1501 | | parameters, initial_weight, enabled |
1502 | | ) VALUES ($1, $2, $3, $4, $5, $6, $7) |
1503 | | "#) |
1504 | | .bind(config_id) |
1505 | | .bind(model.get("model_id").and_then(|v| v.as_str()).unwrap_or("unknown")) |
1506 | | .bind(model.get("model_name").and_then(|v| v.as_str()).unwrap_or("Unknown Model")) |
1507 | | .bind(model.get("model_type").and_then(|v| v.as_str()).unwrap_or("unknown")) |
1508 | | .bind(model.get("parameters").unwrap_or(&serde_json::json!({}))) |
1509 | | .bind(model.get("initial_weight").and_then(|v| v.as_f64()).unwrap_or(0.25)) |
1510 | | .bind(model.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)) |
1511 | | .execute(&mut *tx) |
1512 | | .await?; |
1513 | | } |
1514 | | } |
1515 | | |
1516 | | // 3. Update features if provided |
1517 | | if let Some(features) = config.get("features").and_then(|v| v.as_array()) { |
1518 | | // Delete existing features |
1519 | | sqlx::query("DELETE FROM adaptive_strategy_features WHERE strategy_config_id = $1") |
1520 | | .bind(config_id) |
1521 | | .execute(&mut *tx) |
1522 | | .await?; |
1523 | | |
1524 | | // Insert new features |
1525 | | for feature in features { |
1526 | | sqlx::query(r#" |
1527 | | INSERT INTO adaptive_strategy_features ( |
1528 | | strategy_config_id, feature_name, feature_type, |
1529 | | parameters, enabled, required |
1530 | | ) VALUES ($1, $2, $3, $4, $5, $6) |
1531 | | "#) |
1532 | | .bind(config_id) |
1533 | | .bind(feature.get("feature_name").and_then(|v| v.as_str()).unwrap_or("unknown")) |
1534 | | .bind(feature.get("feature_type").and_then(|v| v.as_str()).unwrap_or("unknown")) |
1535 | | .bind(feature.get("parameters").unwrap_or(&serde_json::json!({}))) |
1536 | | .bind(feature.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)) |
1537 | | .bind(feature.get("required").and_then(|v| v.as_bool()).unwrap_or(false)) |
1538 | | .execute(&mut *tx) |
1539 | | .await?; |
1540 | | } |
1541 | | } |
1542 | | |
1543 | | // Commit transaction |
1544 | | tx.commit().await?; |
1545 | | |
1546 | | Ok(strategy_id.to_string()) |
1547 | | } |
1548 | | } |
1549 | | |
1550 | | #[cfg(test)] |
1551 | | mod tests { |
1552 | | use super::*; |
1553 | | |
1554 | | #[test] |
1555 | 1 | fn test_database_config_new() { |
1556 | 1 | let config = DatabaseConfig::new(); |
1557 | 1 | assert!(!config.url.is_empty()); |
1558 | 1 | assert_eq!(config.max_connections, 10); |
1559 | 1 | assert_eq!(config.min_connections, 1); |
1560 | 1 | assert!(config.application_name.is_some()); |
1561 | 1 | } |
1562 | | |
1563 | | #[test] |
1564 | 1 | fn test_database_config_validate_success() { |
1565 | 1 | let config = DatabaseConfig::new(); |
1566 | 1 | assert!(config.validate().is_ok()); |
1567 | 1 | } |
1568 | | |
1569 | | #[test] |
1570 | 1 | fn test_database_config_validate_empty_url() { |
1571 | 1 | let mut config = DatabaseConfig::new(); |
1572 | 1 | config.url = String::new(); |
1573 | 1 | assert!(config.validate().is_err()); |
1574 | 1 | } |
1575 | | |
1576 | | #[test] |
1577 | 1 | fn test_pool_config_default() { |
1578 | 1 | let pool_config = PoolConfig::default(); |
1579 | 1 | assert_eq!(pool_config.min_connections, 1); |
1580 | 1 | assert_eq!(pool_config.max_connections, 10); |
1581 | 1 | assert!(pool_config.test_before_acquire); |
1582 | 1 | } |
1583 | | |
1584 | | #[test] |
1585 | 1 | fn test_transaction_config_default() { |
1586 | 1 | let tx_config = TransactionConfig::default(); |
1587 | 1 | assert_eq!(tx_config.isolation_level, "READ_COMMITTED"); |
1588 | 1 | assert_eq!(tx_config.default_timeout_secs, 30); |
1589 | 1 | assert!(tx_config.enable_retry); |
1590 | 1 | } |
1591 | | |
1592 | | #[test] |
1593 | 1 | fn test_transaction_config_serialization() { |
1594 | 1 | let tx_config = TransactionConfig::default(); |
1595 | 1 | let serialized = serde_json::to_string(&tx_config).unwrap(); |
1596 | 1 | let deserialized: TransactionConfig = serde_json::from_str(&serialized).unwrap(); |
1597 | 1 | assert_eq!(tx_config.isolation_level, deserialized.isolation_level); |
1598 | 1 | } |
1599 | | |
1600 | | #[test] |
1601 | 1 | fn test_database_config_with_custom_values() { |
1602 | 1 | let mut config = DatabaseConfig::new(); |
1603 | 1 | config.max_connections = 50; |
1604 | 1 | config.min_connections = 5; |
1605 | 1 | config.enable_query_logging = true; |
1606 | | |
1607 | 1 | assert_eq!(config.max_connections, 50); |
1608 | 1 | assert_eq!(config.min_connections, 5); |
1609 | 1 | assert!(config.enable_query_logging); |
1610 | 1 | } |
1611 | | |
1612 | | #[test] |
1613 | 1 | fn test_pool_config_timeouts() { |
1614 | 1 | let pool_config = PoolConfig { |
1615 | 1 | acquire_timeout_secs: 30, |
1616 | 1 | max_lifetime_secs: 1800, |
1617 | 1 | idle_timeout_secs: 600, |
1618 | 1 | ..Default::default() |
1619 | 1 | }; |
1620 | | |
1621 | 1 | assert_eq!(pool_config.acquire_timeout_secs, 30); |
1622 | 1 | assert_eq!(pool_config.max_lifetime_secs, 1800); |
1623 | 1 | assert_eq!(pool_config.idle_timeout_secs, 600); |
1624 | 1 | } |
1625 | | |
1626 | | #[test] |
1627 | 1 | fn test_transaction_config_isolation_levels() { |
1628 | 1 | let levels = vec![ |
1629 | | "READ_UNCOMMITTED", |
1630 | 1 | "READ_COMMITTED", |
1631 | 1 | "REPEATABLE_READ", |
1632 | 1 | "SERIALIZABLE", |
1633 | | ]; |
1634 | | |
1635 | 5 | for level4 in levels { |
1636 | 4 | let tx_config = TransactionConfig { |
1637 | 4 | isolation_level: level.to_string(), |
1638 | 4 | ..Default::default() |
1639 | 4 | }; |
1640 | 4 | assert_eq!(tx_config.isolation_level, level); |
1641 | | } |
1642 | 1 | } |
1643 | | |
1644 | | #[test] |
1645 | 1 | fn test_database_config_clone() { |
1646 | 1 | let config1 = DatabaseConfig::new(); |
1647 | 1 | let config2 = config1.clone(); |
1648 | | |
1649 | 1 | assert_eq!(config1.url, config2.url); |
1650 | 1 | assert_eq!(config1.max_connections, config2.max_connections); |
1651 | 1 | assert_eq!(config1.min_connections, config2.min_connections); |
1652 | 1 | } |
1653 | | |
1654 | | #[test] |
1655 | 1 | fn test_pool_config_validation() { |
1656 | 1 | let pool_config = PoolConfig::default(); |
1657 | 1 | assert!(pool_config.min_connections <= pool_config.max_connections); |
1658 | 1 | } |
1659 | | |
1660 | | #[test] |
1661 | 1 | fn test_database_url_format() { |
1662 | 1 | let config = DatabaseConfig::new(); |
1663 | 1 | assert!(config.url.starts_with("postgresql://")); |
1664 | 1 | } |
1665 | | |
1666 | | #[test] |
1667 | 1 | fn test_transaction_config_retry_settings() { |
1668 | 1 | let tx_config = TransactionConfig { |
1669 | 1 | enable_retry: true, |
1670 | 1 | max_retries: 5, |
1671 | 1 | ..Default::default() |
1672 | 1 | }; |
1673 | 1 | assert!(tx_config.enable_retry); |
1674 | 1 | assert_eq!(tx_config.max_retries, 5); |
1675 | | |
1676 | 1 | let tx_config_no_retry = TransactionConfig { |
1677 | 1 | enable_retry: false, |
1678 | 1 | ..Default::default() |
1679 | 1 | }; |
1680 | 1 | assert!(!tx_config_no_retry.enable_retry); |
1681 | 1 | } |
1682 | | |
1683 | | #[test] |
1684 | 1 | fn test_pool_config_connection_settings() { |
1685 | 1 | let pool_config = PoolConfig { |
1686 | 1 | test_before_acquire: true, |
1687 | 1 | acquire_timeout_secs: 30, |
1688 | 1 | ..Default::default() |
1689 | 1 | }; |
1690 | | |
1691 | 1 | assert!(pool_config.test_before_acquire); |
1692 | 1 | assert_eq!(pool_config.acquire_timeout_secs, 30); |
1693 | 1 | } |
1694 | | |
1695 | | #[test] |
1696 | 1 | fn test_database_config_application_name() { |
1697 | 1 | let config = DatabaseConfig::new(); |
1698 | 1 | assert_eq!(config.application_name, Some("foxhunt".to_string())); |
1699 | 1 | } |
1700 | | |
1701 | | #[test] |
1702 | 1 | fn test_database_config_query_logging() { |
1703 | 1 | let mut config = DatabaseConfig::new(); |
1704 | 1 | config.enable_query_logging = true; |
1705 | 1 | assert!(config.enable_query_logging); |
1706 | 1 | } |
1707 | | |
1708 | | #[test] |
1709 | 1 | fn test_pool_config_connection_limits() { |
1710 | 1 | let pool_config = PoolConfig { |
1711 | 1 | max_connections: 100, |
1712 | 1 | min_connections: 10, |
1713 | 1 | ..Default::default() |
1714 | 1 | }; |
1715 | | |
1716 | 1 | assert_eq!(pool_config.max_connections, 100); |
1717 | 1 | assert_eq!(pool_config.min_connections, 10); |
1718 | 1 | } |
1719 | | |
1720 | | #[test] |
1721 | 1 | fn test_transaction_timeout() { |
1722 | 1 | let tx_config = TransactionConfig { |
1723 | 1 | default_timeout_secs: 60, |
1724 | 1 | timeout: Duration::from_secs(60), |
1725 | 1 | ..Default::default() |
1726 | 1 | }; |
1727 | 1 | assert_eq!(tx_config.default_timeout_secs, 60); |
1728 | 1 | assert_eq!(tx_config.timeout, Duration::from_secs(60)); |
1729 | 1 | } |
1730 | | |
1731 | | #[test] |
1732 | 1 | fn test_database_config_connect_timeout() { |
1733 | 1 | let config = DatabaseConfig::new(); |
1734 | 1 | assert_eq!(config.connect_timeout, Duration::from_secs(30)); |
1735 | 1 | } |
1736 | | |
1737 | | #[test] |
1738 | 1 | fn test_database_config_query_timeout() { |
1739 | 1 | let config = DatabaseConfig::new(); |
1740 | 1 | assert_eq!(config.query_timeout, Duration::from_secs(60)); |
1741 | 1 | } |
1742 | | |
1743 | | #[test] |
1744 | 1 | fn test_pool_config_test_before_acquire() { |
1745 | 1 | let pool_config = PoolConfig { |
1746 | 1 | test_before_acquire: false, |
1747 | 1 | ..Default::default() |
1748 | 1 | }; |
1749 | 1 | assert!(!pool_config.test_before_acquire); |
1750 | | |
1751 | 1 | let pool_config_enabled = PoolConfig { |
1752 | 1 | test_before_acquire: true, |
1753 | 1 | ..Default::default() |
1754 | 1 | }; |
1755 | 1 | assert!(pool_config_enabled.test_before_acquire); |
1756 | 1 | } |
1757 | | |
1758 | | #[test] |
1759 | 1 | fn test_database_config_validation_empty_url() { |
1760 | 1 | let mut config = DatabaseConfig::new(); |
1761 | 1 | config.url = String::new(); |
1762 | 1 | assert!(config.validate().is_err()); |
1763 | 1 | assert_eq!( |
1764 | 1 | config.validate().unwrap_err(), |
1765 | | "Database URL cannot be empty" |
1766 | | ); |
1767 | 1 | } |
1768 | | |
1769 | | #[test] |
1770 | 1 | fn test_database_config_validation_valid() { |
1771 | 1 | let config = DatabaseConfig::new(); |
1772 | 1 | assert!(config.validate().is_ok()); |
1773 | 1 | } |
1774 | | |
1775 | | #[test] |
1776 | 1 | fn test_pool_config_defaults() { |
1777 | 1 | let pool_config = PoolConfig::default(); |
1778 | 1 | assert_eq!(pool_config.min_connections, 1); |
1779 | 1 | assert_eq!(pool_config.max_connections, 10); |
1780 | 1 | assert_eq!(pool_config.acquire_timeout_secs, 30); |
1781 | 1 | assert_eq!(pool_config.max_lifetime_secs, 1800); |
1782 | 1 | assert_eq!(pool_config.idle_timeout_secs, 600); |
1783 | 1 | assert!(pool_config.test_before_acquire); |
1784 | 1 | assert!(pool_config.health_check_enabled); |
1785 | 1 | assert_eq!(pool_config.health_check_interval_secs, 60); |
1786 | 1 | } |
1787 | | |
1788 | | #[test] |
1789 | 1 | fn test_transaction_config_defaults() { |
1790 | 1 | let tx_config = TransactionConfig::default(); |
1791 | 1 | assert_eq!(tx_config.isolation_level, "READ_COMMITTED"); |
1792 | 1 | assert_eq!(tx_config.timeout, Duration::from_secs(30)); |
1793 | 1 | assert_eq!(tx_config.default_timeout_secs, 30); |
1794 | 1 | assert!(tx_config.enable_retry); |
1795 | 1 | assert_eq!(tx_config.max_retries, 3); |
1796 | 1 | assert_eq!(tx_config.retry_delay_ms, 100); |
1797 | 1 | assert_eq!(tx_config.max_savepoints, 10); |
1798 | 1 | } |
1799 | | |
1800 | | #[test] |
1801 | 1 | fn test_transaction_config_custom_isolation() { |
1802 | 1 | let tx_config = TransactionConfig { |
1803 | 1 | isolation_level: "SERIALIZABLE".to_string(), |
1804 | 1 | ..Default::default() |
1805 | 1 | }; |
1806 | 1 | assert_eq!(tx_config.isolation_level, "SERIALIZABLE"); |
1807 | 1 | } |
1808 | | |
1809 | | #[test] |
1810 | 1 | fn test_pool_config_extreme_values() { |
1811 | 1 | let pool_config = PoolConfig { |
1812 | 1 | max_connections: 1000, |
1813 | 1 | min_connections: 0, |
1814 | 1 | ..Default::default() |
1815 | 1 | }; |
1816 | 1 | assert_eq!(pool_config.max_connections, 1000); |
1817 | 1 | assert_eq!(pool_config.min_connections, 0); |
1818 | 1 | } |
1819 | | |
1820 | | #[test] |
1821 | 1 | fn test_database_config_custom_application_name() { |
1822 | 1 | let mut config = DatabaseConfig::new(); |
1823 | 1 | config.application_name = Some("custom_app".to_string()); |
1824 | 1 | assert_eq!(config.application_name.unwrap(), "custom_app"); |
1825 | 1 | } |
1826 | | |
1827 | | #[test] |
1828 | 1 | fn test_database_config_no_application_name() { |
1829 | 1 | let mut config = DatabaseConfig::new(); |
1830 | 1 | config.application_name = None; |
1831 | 1 | assert!(config.application_name.is_none()); |
1832 | 1 | } |
1833 | | |
1834 | | #[test] |
1835 | 1 | fn test_transaction_config_retry_disabled() { |
1836 | 1 | let tx_config = TransactionConfig { |
1837 | 1 | enable_retry: false, |
1838 | 1 | ..Default::default() |
1839 | 1 | }; |
1840 | 1 | assert!(!tx_config.enable_retry); |
1841 | 1 | } |
1842 | | |
1843 | | #[test] |
1844 | 1 | fn test_pool_config_serialization() { |
1845 | 1 | let pool_config = PoolConfig::default(); |
1846 | 1 | let serialized = serde_json::to_string(&pool_config).unwrap(); |
1847 | 1 | let deserialized: PoolConfig = serde_json::from_str(&serialized).unwrap(); |
1848 | 1 | assert_eq!(pool_config.max_connections, deserialized.max_connections); |
1849 | 1 | assert_eq!(pool_config.min_connections, deserialized.min_connections); |
1850 | 1 | } |
1851 | | |
1852 | | #[test] |
1853 | 1 | fn test_transaction_config_serde_roundtrip() { |
1854 | 1 | let tx_config = TransactionConfig::default(); |
1855 | 1 | let serialized = serde_json::to_string(&tx_config).unwrap(); |
1856 | 1 | let deserialized: TransactionConfig = serde_json::from_str(&serialized).unwrap(); |
1857 | 1 | assert_eq!(tx_config.isolation_level, deserialized.isolation_level); |
1858 | 1 | assert_eq!(tx_config.max_retries, deserialized.max_retries); |
1859 | 1 | } |
1860 | | } |