1use serde::{Deserialize, Serialize};
28use std::env;
29use std::fmt;
30use std::path::Path;
31
32use crate::adjuster_guardrails::AdjusterGuardrails;
33
34#[derive(Debug)]
40pub enum ConfigError {
41 IoError(std::io::Error),
43 ParseError(toml::de::Error),
45}
46
47impl fmt::Display for ConfigError {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 ConfigError::IoError(e) => write!(f, "config I/O error: {}", e),
51 ConfigError::ParseError(e) => write!(f, "config parse error: {}", e),
52 }
53 }
54}
55
56impl std::error::Error for ConfigError {}
57
58impl From<std::io::Error> for ConfigError {
59 fn from(e: std::io::Error) -> Self {
60 ConfigError::IoError(e)
61 }
62}
63
64impl From<toml::de::Error> for ConfigError {
65 fn from(e: toml::de::Error) -> Self {
66 ConfigError::ParseError(e)
67 }
68}
69
70fn default_mode() -> String {
75 "dry-run".to_string()
76}
77
78fn default_interval_secs() -> u64 {
79 60
80}
81
82fn default_true() -> bool {
83 true
84}
85
86fn default_max_position_usdc() -> f64 {
87 10_000.0
88}
89
90fn default_max_leverage() -> f64 {
91 3.0
92}
93
94fn default_max_open_positions() -> u32 {
95 5
96}
97
98fn default_max_daily_loss_usdc() -> f64 {
99 1_000.0
100}
101
102fn default_strategy_name() -> String {
103 "conservative".to_string()
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112pub struct AgentSection {
113 #[serde(default = "default_mode")]
115 pub mode: String,
116
117 #[serde(default = "default_interval_secs")]
119 pub interval_secs: u64,
120
121 #[serde(default)]
123 pub guardrails: AdjusterGuardrails,
124}
125
126impl Default for AgentSection {
127 fn default() -> Self {
128 Self {
129 mode: default_mode(),
130 interval_secs: default_interval_secs(),
131 guardrails: AdjusterGuardrails::default(),
132 }
133 }
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138pub struct ExchangeSection {
139 #[serde(default = "default_true")]
141 pub is_mainnet: bool,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub vault_address: Option<String>,
146}
147
148impl Default for ExchangeSection {
149 fn default() -> Self {
150 Self {
151 is_mainnet: default_true(),
152 vault_address: None,
153 }
154 }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub struct RiskSection {
160 #[serde(default = "default_max_position_usdc")]
162 pub max_position_usdc: f64,
163
164 #[serde(default = "default_max_leverage")]
166 pub max_leverage: f64,
167
168 #[serde(default = "default_max_open_positions")]
170 pub max_open_positions: u32,
171
172 #[serde(default = "default_max_daily_loss_usdc")]
174 pub max_daily_loss_usdc: f64,
175}
176
177impl Default for RiskSection {
178 fn default() -> Self {
179 Self {
180 max_position_usdc: default_max_position_usdc(),
181 max_leverage: default_max_leverage(),
182 max_open_positions: default_max_open_positions(),
183 max_daily_loss_usdc: default_max_daily_loss_usdc(),
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct StrategySection {
191 #[serde(default = "default_strategy_name")]
193 pub name: String,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub composer_profile: Option<String>,
198}
199
200impl Default for StrategySection {
201 fn default() -> Self {
202 Self {
203 name: default_strategy_name(),
204 composer_profile: None,
205 }
206 }
207}
208
209pub use hyper_agent_notify::config::NotifierSection;
211
212#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
217pub struct CredentialsSection {
218 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub anthropic_api_key: Option<String>,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub hyperliquid_private_key: Option<String>,
225}
226
227impl Default for CredentialsSection {
228 fn default() -> Self {
229 Self {
230 anthropic_api_key: None,
231 hyperliquid_private_key: None,
232 }
233 }
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242pub struct AppConfig {
243 #[serde(default)]
244 pub agent: AgentSection,
245
246 #[serde(default)]
247 pub exchange: ExchangeSection,
248
249 #[serde(default)]
250 pub risk: RiskSection,
251
252 #[serde(default)]
253 pub strategy: StrategySection,
254
255 #[serde(default)]
256 pub notifier: NotifierSection,
257
258 #[serde(default)]
259 pub credentials: CredentialsSection,
260}
261
262impl Default for AppConfig {
263 fn default() -> Self {
264 Self {
265 agent: AgentSection::default(),
266 exchange: ExchangeSection::default(),
267 risk: RiskSection::default(),
268 strategy: StrategySection::default(),
269 notifier: NotifierSection::default(),
270 credentials: CredentialsSection::default(),
271 }
272 }
273}
274
275impl AppConfig {
276 pub fn load(path: Option<&str>) -> Result<Self, ConfigError> {
281 let mut config = match path {
282 Some(p) if Path::new(p).exists() => {
283 let content = std::fs::read_to_string(p)?;
284 toml::from_str::<AppConfig>(&content)?
285 }
286 _ => AppConfig::default(),
287 };
288 config.apply_env_overrides();
289 Ok(config)
290 }
291
292 pub fn from_toml_str(toml_str: &str) -> Result<Self, ConfigError> {
294 let mut config = toml::from_str::<AppConfig>(toml_str)?;
295 config.apply_env_overrides();
296 Ok(config)
297 }
298
299 pub fn apply_env_overrides(&mut self) {
302 if let Ok(v) = env::var("AGENT_MODE") {
304 self.agent.mode = v;
305 }
306 if let Ok(v) = env::var("AGENT_INTERVAL_SECS") {
307 if let Ok(n) = v.parse::<u64>() {
308 self.agent.interval_secs = n;
309 }
310 }
311
312 if let Ok(v) = env::var("EXCHANGE_IS_MAINNET") {
314 match v.to_lowercase().as_str() {
315 "true" | "1" | "yes" => self.exchange.is_mainnet = true,
316 "false" | "0" | "no" => self.exchange.is_mainnet = false,
317 _ => {}
318 }
319 }
320 if let Ok(v) = env::var("EXCHANGE_VAULT_ADDRESS") {
321 self.exchange.vault_address = if v.is_empty() { None } else { Some(v) };
322 }
323
324 if let Ok(v) = env::var("RISK_MAX_POSITION_USDC") {
326 if let Ok(n) = v.parse::<f64>() {
327 self.risk.max_position_usdc = n;
328 }
329 }
330 if let Ok(v) = env::var("RISK_MAX_LEVERAGE") {
331 if let Ok(n) = v.parse::<f64>() {
332 self.risk.max_leverage = n;
333 }
334 }
335 if let Ok(v) = env::var("RISK_MAX_OPEN_POSITIONS") {
336 if let Ok(n) = v.parse::<u32>() {
337 self.risk.max_open_positions = n;
338 }
339 }
340 if let Ok(v) = env::var("RISK_MAX_DAILY_LOSS_USDC") {
341 if let Ok(n) = v.parse::<f64>() {
342 self.risk.max_daily_loss_usdc = n;
343 }
344 }
345
346 if let Ok(v) = env::var("STRATEGY_NAME") {
348 self.strategy.name = v;
349 }
350 if let Ok(v) = env::var("STRATEGY_COMPOSER_PROFILE") {
351 self.strategy.composer_profile = if v.is_empty() { None } else { Some(v) };
352 }
353
354 if let Ok(v) = env::var("NOTIFIER_DISCORD_WEBHOOK") {
356 self.notifier.discord_webhook = if v.is_empty() { None } else { Some(v) };
357 }
358 if let Ok(v) = env::var("NOTIFIER_ENABLED") {
359 match v.to_lowercase().as_str() {
360 "true" | "1" | "yes" => self.notifier.enabled = true,
361 "false" | "0" | "no" => self.notifier.enabled = false,
362 _ => {}
363 }
364 }
365
366 if let Ok(v) = env::var("ANTHROPIC_API_KEY") {
368 self.credentials.anthropic_api_key = if v.is_empty() { None } else { Some(v) };
369 }
370 if let Ok(v) = env::var("HYPERLIQUID_PRIVATE_KEY") {
371 self.credentials.hyperliquid_private_key = if v.is_empty() { None } else { Some(v) };
372 }
373 }
374
375 pub fn to_agent_config(&self, agent_id: &str) -> crate::agent_config::AgentConfig {
386 use crate::agent_config::{AgentConfig, PromptTemplate, TradingMode};
387
388 let trading_mode = match self.agent.mode.as_str() {
389 "live" => TradingMode::Live,
390 _ => TradingMode::Paper,
391 };
392
393 let (prompt_template, system_prompt) = match self.strategy.name.as_str() {
395 "trend_following" => {
396 let t = PromptTemplate::TrendFollowing;
397 let p = t.default_prompt();
398 (t, p)
399 }
400 "mean_reversion" => {
401 let t = PromptTemplate::MeanReversion;
402 let p = t.default_prompt();
403 (t, p)
404 }
405 _ => {
406 let t = PromptTemplate::Conservative;
407 let p = t.default_prompt();
408 (t, p)
409 }
410 };
411
412 let analysis_frequency_minutes = (self.agent.interval_secs / 60).max(1);
414
415 AgentConfig {
416 agent_id: agent_id.to_string(),
417 prompt_template,
418 system_prompt,
419 analysis_frequency_minutes,
420 trading_pairs: vec!["BTC-PERP".to_string(), "ETH-PERP".to_string()],
421 max_position_size_usd: self.risk.max_position_usdc,
422 enabled: true,
423 trading_mode,
424 composer_profile: self.strategy.composer_profile.clone(),
425 max_retries: 3,
426 max_tool_turns: 5,
427 }
428 }
429
430 pub fn build_risk_config(&self) -> hyper_risk::risk::RiskConfig {
439 use hyper_risk::risk::*;
440
441 RiskConfig {
442 position_limits: PositionLimits {
443 enabled: true,
444 max_total_position: self.risk.max_position_usdc * self.risk.max_leverage,
445 max_per_symbol: self.risk.max_position_usdc,
446 },
447 daily_loss_limits: DailyLossLimits {
448 enabled: true,
449 max_daily_loss: self.risk.max_daily_loss_usdc,
450 max_daily_loss_percent: 5.0,
451 },
452 anomaly_detection: AnomalyDetection {
453 enabled: true,
454 max_order_size: self.risk.max_position_usdc * 2.0,
455 max_orders_per_minute: 10,
456 block_duplicate_orders: true,
457 },
458 circuit_breaker: CircuitBreaker {
459 enabled: false,
460 trigger_loss: self.risk.max_daily_loss_usdc * 2.0,
461 trigger_window_minutes: 60,
462 action: "pause_all".to_string(),
463 cooldown_minutes: 30,
464 },
465 }
466 }
467}
468
469#[cfg(test)]
474mod tests {
475 use super::*;
476 use std::io::Write;
477 use std::sync::Mutex;
478
479 static ENV_LOCK: Mutex<()> = Mutex::new(());
482
483 fn clear_env_vars() {
485 let vars = [
486 "AGENT_MODE",
487 "AGENT_INTERVAL_SECS",
488 "EXCHANGE_IS_MAINNET",
489 "EXCHANGE_VAULT_ADDRESS",
490 "RISK_MAX_POSITION_USDC",
491 "RISK_MAX_LEVERAGE",
492 "RISK_MAX_OPEN_POSITIONS",
493 "RISK_MAX_DAILY_LOSS_USDC",
494 "STRATEGY_NAME",
495 "STRATEGY_COMPOSER_PROFILE",
496 "NOTIFIER_DISCORD_WEBHOOK",
497 "NOTIFIER_ENABLED",
498 "ANTHROPIC_API_KEY",
499 "HYPERLIQUID_PRIVATE_KEY",
500 ];
501 for var in vars {
502 env::remove_var(var);
503 }
504 }
505
506 #[test]
509 fn test_defaults_all_sections() {
510 let _lock = ENV_LOCK.lock().unwrap();
511 clear_env_vars();
512 let cfg = AppConfig::default();
513 assert_eq!(cfg.agent.mode, "dry-run");
514 assert_eq!(cfg.agent.interval_secs, 60);
515 assert!(cfg.exchange.is_mainnet);
516 assert!(cfg.exchange.vault_address.is_none());
517 assert_eq!(cfg.risk.max_position_usdc, 10_000.0);
518 assert_eq!(cfg.risk.max_leverage, 3.0);
519 assert_eq!(cfg.risk.max_open_positions, 5);
520 assert_eq!(cfg.risk.max_daily_loss_usdc, 1_000.0);
521 assert_eq!(cfg.strategy.name, "conservative");
522 assert!(cfg.strategy.composer_profile.is_none());
523 assert!(!cfg.notifier.enabled);
524 assert!(cfg.notifier.discord_webhook.is_none());
525 assert!(cfg.credentials.anthropic_api_key.is_none());
526 assert!(cfg.credentials.hyperliquid_private_key.is_none());
527 clear_env_vars();
528 }
529
530 #[test]
533 fn test_load_from_toml_string() {
534 let _lock = ENV_LOCK.lock().unwrap();
535 clear_env_vars();
536 let toml = r#"
537[agent]
538mode = "live"
539interval_secs = 30
540
541[exchange]
542is_mainnet = false
543vault_address = "0xABC"
544
545[risk]
546max_position_usdc = 50000.0
547max_leverage = 5.0
548max_open_positions = 10
549max_daily_loss_usdc = 2500.0
550
551[strategy]
552name = "trend_following"
553composer_profile = "all_weather"
554
555[notifier]
556enabled = true
557discord_webhook = "https://discord.com/api/webhooks/xxx"
558
559[credentials]
560anthropic_api_key = "sk-ant-test"
561hyperliquid_private_key = "0xHL"
562"#;
563 let cfg = AppConfig::from_toml_str(toml).unwrap();
564 assert_eq!(cfg.agent.mode, "live");
565 assert_eq!(cfg.agent.interval_secs, 30);
566 assert!(!cfg.exchange.is_mainnet);
567 assert_eq!(cfg.exchange.vault_address.as_deref(), Some("0xABC"));
568 assert_eq!(cfg.risk.max_position_usdc, 50_000.0);
569 assert_eq!(cfg.risk.max_leverage, 5.0);
570 assert_eq!(cfg.risk.max_open_positions, 10);
571 assert_eq!(cfg.risk.max_daily_loss_usdc, 2_500.0);
572 assert_eq!(cfg.strategy.name, "trend_following");
573 assert_eq!(
574 cfg.strategy.composer_profile.as_deref(),
575 Some("all_weather")
576 );
577 assert!(cfg.notifier.enabled);
578 assert_eq!(
579 cfg.notifier.discord_webhook.as_deref(),
580 Some("https://discord.com/api/webhooks/xxx")
581 );
582 assert_eq!(
583 cfg.credentials.anthropic_api_key.as_deref(),
584 Some("sk-ant-test")
585 );
586 assert_eq!(
587 cfg.credentials.hyperliquid_private_key.as_deref(),
588 Some("0xHL")
589 );
590 clear_env_vars();
591 }
592
593 #[test]
594 fn test_partial_toml_uses_defaults_for_missing() {
595 let _lock = ENV_LOCK.lock().unwrap();
596 clear_env_vars();
597 let toml = r#"
598[agent]
599mode = "paper"
600"#;
601 let cfg = AppConfig::from_toml_str(toml).unwrap();
602 assert_eq!(cfg.agent.mode, "paper");
603 assert_eq!(cfg.agent.interval_secs, 60);
604 assert!(cfg.exchange.is_mainnet);
605 assert_eq!(cfg.risk.max_position_usdc, 10_000.0);
606 clear_env_vars();
607 }
608
609 #[test]
610 fn test_empty_toml_gives_all_defaults() {
611 let _lock = ENV_LOCK.lock().unwrap();
612 clear_env_vars();
613 let cfg = AppConfig::from_toml_str("").unwrap();
614 assert_eq!(cfg, AppConfig::default());
615 clear_env_vars();
616 }
617
618 #[test]
621 fn test_env_override_agent_mode() {
622 let _lock = ENV_LOCK.lock().unwrap();
623 clear_env_vars();
624 env::set_var("AGENT_MODE", "live");
625 let cfg = AppConfig::from_toml_str("").unwrap();
626 assert_eq!(cfg.agent.mode, "live");
627 clear_env_vars();
628 }
629
630 #[test]
631 fn test_env_override_agent_interval() {
632 let _lock = ENV_LOCK.lock().unwrap();
633 clear_env_vars();
634 env::set_var("AGENT_INTERVAL_SECS", "120");
635 let cfg = AppConfig::from_toml_str("").unwrap();
636 assert_eq!(cfg.agent.interval_secs, 120);
637 clear_env_vars();
638 }
639
640 #[test]
641 fn test_env_override_exchange_is_mainnet_false() {
642 let _lock = ENV_LOCK.lock().unwrap();
643 clear_env_vars();
644 env::set_var("EXCHANGE_IS_MAINNET", "false");
645 let cfg = AppConfig::from_toml_str("").unwrap();
646 assert!(!cfg.exchange.is_mainnet);
647 clear_env_vars();
648 }
649
650 #[test]
651 fn test_env_override_exchange_is_mainnet_numeric() {
652 let _lock = ENV_LOCK.lock().unwrap();
653 clear_env_vars();
654 env::set_var("EXCHANGE_IS_MAINNET", "0");
655 let cfg = AppConfig::from_toml_str("").unwrap();
656 assert!(!cfg.exchange.is_mainnet);
657 clear_env_vars();
658 }
659
660 #[test]
661 fn test_env_override_risk_fields() {
662 let _lock = ENV_LOCK.lock().unwrap();
663 clear_env_vars();
664 env::set_var("RISK_MAX_POSITION_USDC", "25000");
665 env::set_var("RISK_MAX_LEVERAGE", "10.0");
666 env::set_var("RISK_MAX_OPEN_POSITIONS", "20");
667 env::set_var("RISK_MAX_DAILY_LOSS_USDC", "5000");
668 let cfg = AppConfig::from_toml_str("").unwrap();
669 assert_eq!(cfg.risk.max_position_usdc, 25_000.0);
670 assert_eq!(cfg.risk.max_leverage, 10.0);
671 assert_eq!(cfg.risk.max_open_positions, 20);
672 assert_eq!(cfg.risk.max_daily_loss_usdc, 5_000.0);
673 clear_env_vars();
674 }
675
676 #[test]
677 fn test_env_override_credentials() {
678 let _lock = ENV_LOCK.lock().unwrap();
679 clear_env_vars();
680 env::set_var("ANTHROPIC_API_KEY", "sk-ant-xxx");
681 env::set_var("HYPERLIQUID_PRIVATE_KEY", "0xhl");
682 let cfg = AppConfig::from_toml_str("").unwrap();
683 assert_eq!(
684 cfg.credentials.anthropic_api_key.as_deref(),
685 Some("sk-ant-xxx")
686 );
687 assert_eq!(
688 cfg.credentials.hyperliquid_private_key.as_deref(),
689 Some("0xhl")
690 );
691 clear_env_vars();
692 }
693
694 #[test]
695 fn test_env_override_notifier() {
696 let _lock = ENV_LOCK.lock().unwrap();
697 clear_env_vars();
698 env::set_var("NOTIFIER_ENABLED", "true");
699 env::set_var("NOTIFIER_DISCORD_WEBHOOK", "https://hooks.example.com/abc");
700 let cfg = AppConfig::from_toml_str("").unwrap();
701 assert!(cfg.notifier.enabled);
702 assert_eq!(
703 cfg.notifier.discord_webhook.as_deref(),
704 Some("https://hooks.example.com/abc")
705 );
706 clear_env_vars();
707 }
708
709 #[test]
710 fn test_env_overrides_toml_values() {
711 let _lock = ENV_LOCK.lock().unwrap();
712 clear_env_vars();
713 let toml = r#"
714[agent]
715mode = "paper"
716interval_secs = 30
717"#;
718 env::set_var("AGENT_MODE", "live");
719 let cfg = AppConfig::from_toml_str(toml).unwrap();
720 assert_eq!(cfg.agent.mode, "live");
721 assert_eq!(cfg.agent.interval_secs, 30);
722 clear_env_vars();
723 }
724
725 #[test]
726 fn test_env_invalid_number_ignored() {
727 let _lock = ENV_LOCK.lock().unwrap();
728 clear_env_vars();
729 env::set_var("AGENT_INTERVAL_SECS", "not_a_number");
730 let cfg = AppConfig::from_toml_str("").unwrap();
731 assert_eq!(cfg.agent.interval_secs, 60);
732 clear_env_vars();
733 }
734
735 #[test]
736 fn test_env_invalid_bool_ignored() {
737 let _lock = ENV_LOCK.lock().unwrap();
738 clear_env_vars();
739 env::set_var("EXCHANGE_IS_MAINNET", "maybe");
740 let cfg = AppConfig::from_toml_str("").unwrap();
741 assert!(cfg.exchange.is_mainnet);
742 clear_env_vars();
743 }
744
745 #[test]
748 fn test_load_from_file() {
749 let _lock = ENV_LOCK.lock().unwrap();
750 clear_env_vars();
751 let dir = tempfile::tempdir().unwrap();
752 let path = dir.path().join("config.toml");
753 let mut f = std::fs::File::create(&path).unwrap();
754 writeln!(
755 f,
756 r#"
757[agent]
758mode = "paper"
759interval_secs = 15
760"#
761 )
762 .unwrap();
763
764 let cfg = AppConfig::load(Some(path.to_str().unwrap())).unwrap();
765 assert_eq!(cfg.agent.mode, "paper");
766 assert_eq!(cfg.agent.interval_secs, 15);
767 clear_env_vars();
768 }
769
770 #[test]
771 fn test_load_missing_file_gives_defaults() {
772 let _lock = ENV_LOCK.lock().unwrap();
773 clear_env_vars();
774 let cfg = AppConfig::load(Some("/tmp/nonexistent_hyper_agent_config.toml")).unwrap();
775 assert_eq!(cfg, AppConfig::default());
776 clear_env_vars();
777 }
778
779 #[test]
780 fn test_load_none_path_gives_defaults() {
781 let _lock = ENV_LOCK.lock().unwrap();
782 clear_env_vars();
783 let cfg = AppConfig::load(None).unwrap();
784 assert_eq!(cfg, AppConfig::default());
785 clear_env_vars();
786 }
787
788 #[test]
789 fn test_load_invalid_toml_returns_error() {
790 let _lock = ENV_LOCK.lock().unwrap();
791 clear_env_vars();
792 let dir = tempfile::tempdir().unwrap();
793 let path = dir.path().join("bad.toml");
794 std::fs::write(&path, "this is not [valid toml =").unwrap();
795 let result = AppConfig::load(Some(path.to_str().unwrap()));
796 assert!(result.is_err());
797 clear_env_vars();
798 }
799
800 #[test]
803 fn test_toml_serialization_roundtrip() {
804 let cfg = AppConfig {
805 agent: AgentSection {
806 mode: "live".to_string(),
807 interval_secs: 45,
808 ..Default::default()
809 },
810 exchange: ExchangeSection {
811 is_mainnet: false,
812 vault_address: Some("0xDEAD".to_string()),
813 },
814 risk: RiskSection {
815 max_position_usdc: 99_999.0,
816 max_leverage: 7.5,
817 max_open_positions: 12,
818 max_daily_loss_usdc: 3_333.0,
819 },
820 strategy: StrategySection {
821 name: "turtle_system".to_string(),
822 composer_profile: Some("aggressive".to_string()),
823 },
824 notifier: NotifierSection {
825 enabled: true,
826 discord_webhook: Some("https://example.com".to_string()),
827 log_dir: "logs".to_string(),
828 quiet_start: "23:00".to_string(),
829 quiet_end: "08:00".to_string(),
830 },
831 credentials: CredentialsSection {
832 anthropic_api_key: Some("key".to_string()),
833 hyperliquid_private_key: Some("0xABC".to_string()),
834 },
835 };
836
837 let toml_str = toml::to_string(&cfg).unwrap();
838 let deserialized: AppConfig = toml::from_str(&toml_str).unwrap();
839 assert_eq!(cfg, deserialized);
840 }
841
842 #[test]
845 fn test_env_override_strategy() {
846 let _lock = ENV_LOCK.lock().unwrap();
847 clear_env_vars();
848 env::set_var("STRATEGY_NAME", "turtle_system");
849 env::set_var("STRATEGY_COMPOSER_PROFILE", "aggressive");
850 let cfg = AppConfig::from_toml_str("").unwrap();
851 assert_eq!(cfg.strategy.name, "turtle_system");
852 assert_eq!(cfg.strategy.composer_profile.as_deref(), Some("aggressive"));
853 clear_env_vars();
854 }
855
856 #[test]
859 fn test_env_empty_string_clears_option() {
860 let _lock = ENV_LOCK.lock().unwrap();
861 clear_env_vars();
862 let toml = r#"
863[credentials]
864anthropic_api_key = "original-key"
865"#;
866 env::set_var("ANTHROPIC_API_KEY", "");
867 let cfg = AppConfig::from_toml_str(toml).unwrap();
868 assert!(cfg.credentials.anthropic_api_key.is_none());
869 clear_env_vars();
870 }
871
872 #[test]
875 fn test_config_error_display() {
876 let io_err =
877 ConfigError::IoError(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
878 assert!(io_err.to_string().contains("I/O"));
879
880 let parse_err = toml::from_str::<AppConfig>("bad[toml").unwrap_err();
881 let cfg_err = ConfigError::ParseError(parse_err);
882 assert!(cfg_err.to_string().contains("parse"));
883 }
884
885 #[test]
888 fn test_to_agent_config_live_mode() {
889 let cfg = AppConfig {
890 agent: AgentSection {
891 mode: "live".to_string(),
892 interval_secs: 120,
893 ..Default::default()
894 },
895 risk: RiskSection {
896 max_position_usdc: 25_000.0,
897 ..Default::default()
898 },
899 strategy: StrategySection {
900 name: "trend_following".to_string(),
901 composer_profile: Some("all_weather".to_string()),
902 },
903 ..Default::default()
904 };
905 let ac = cfg.to_agent_config("test-agent");
906 assert_eq!(ac.agent_id, "test-agent");
907 assert_eq!(ac.trading_mode, crate::agent_config::TradingMode::Live);
908 assert_eq!(
909 ac.prompt_template,
910 crate::agent_config::PromptTemplate::TrendFollowing
911 );
912 assert!(!ac.system_prompt.is_empty());
913 assert_eq!(ac.analysis_frequency_minutes, 2); assert_eq!(ac.max_position_size_usd, 25_000.0);
915 assert_eq!(ac.composer_profile.as_deref(), Some("all_weather"));
916 assert!(ac.enabled);
917 }
918
919 #[test]
920 fn test_to_agent_config_paper_mode() {
921 let cfg = AppConfig::default(); let ac = cfg.to_agent_config("paper-agent");
923 assert_eq!(ac.trading_mode, crate::agent_config::TradingMode::Paper);
924 assert_eq!(
925 ac.prompt_template,
926 crate::agent_config::PromptTemplate::Conservative
927 );
928 }
929
930 #[test]
931 fn test_to_agent_config_mean_reversion() {
932 let cfg = AppConfig {
933 strategy: StrategySection {
934 name: "mean_reversion".to_string(),
935 composer_profile: None,
936 },
937 ..Default::default()
938 };
939 let ac = cfg.to_agent_config("mr-agent");
940 assert_eq!(
941 ac.prompt_template,
942 crate::agent_config::PromptTemplate::MeanReversion
943 );
944 }
945
946 #[test]
947 fn test_to_agent_config_interval_minimum_1() {
948 let cfg = AppConfig {
949 agent: AgentSection {
950 mode: "paper".to_string(),
951 interval_secs: 30,
952 ..Default::default()
953 },
954 ..Default::default()
955 };
956 let ac = cfg.to_agent_config("fast");
957 assert_eq!(ac.analysis_frequency_minutes, 1);
959 }
960
961 #[test]
964 fn test_build_risk_config_defaults() {
965 let cfg = AppConfig::default();
966 let rc = cfg.build_risk_config();
967 assert!(rc.position_limits.enabled);
968 assert_eq!(rc.position_limits.max_per_symbol, 10_000.0);
969 assert_eq!(rc.position_limits.max_total_position, 10_000.0 * 3.0); assert!(rc.daily_loss_limits.enabled);
971 assert_eq!(rc.daily_loss_limits.max_daily_loss, 1_000.0);
972 assert!(!rc.circuit_breaker.enabled);
973 }
974
975 #[test]
976 fn test_build_risk_config_custom_values() {
977 let cfg = AppConfig {
978 risk: RiskSection {
979 max_position_usdc: 50_000.0,
980 max_leverage: 5.0,
981 max_open_positions: 10,
982 max_daily_loss_usdc: 5_000.0,
983 },
984 ..Default::default()
985 };
986 let rc = cfg.build_risk_config();
987 assert_eq!(rc.position_limits.max_per_symbol, 50_000.0);
988 assert_eq!(rc.position_limits.max_total_position, 250_000.0);
989 assert_eq!(rc.daily_loss_limits.max_daily_loss, 5_000.0);
990 assert_eq!(rc.anomaly_detection.max_order_size, 100_000.0);
991 assert_eq!(rc.circuit_breaker.trigger_loss, 10_000.0);
992 }
993}