Skip to main content

hyper_agent_core/
config.rs

1//! Unified TOML configuration with environment variable overrides.
2//!
3//! # Loading order
4//!
5//! 1. Parse TOML file (or use defaults if the file is missing)
6//! 2. Apply environment variable overrides on top
7//!
8//! # Environment variable mapping
9//!
10//! | Env var                     | Config field                       |
11//! |-----------------------------|------------------------------------|
12//! | `AGENT_MODE`                | `agent.mode`                       |
13//! | `AGENT_INTERVAL_SECS`       | `agent.interval_secs`              |
14//! | `EXCHANGE_IS_MAINNET`       | `exchange.is_mainnet`              |
15//! | `EXCHANGE_VAULT_ADDRESS`    | `exchange.vault_address`           |
16//! | `RISK_MAX_POSITION_USDC`    | `risk.max_position_usdc`           |
17//! | `RISK_MAX_LEVERAGE`         | `risk.max_leverage`                |
18//! | `RISK_MAX_OPEN_POSITIONS`   | `risk.max_open_positions`          |
19//! | `RISK_MAX_DAILY_LOSS_USDC`  | `risk.max_daily_loss_usdc`         |
20//! | `STRATEGY_NAME`             | `strategy.name`                    |
21//! | `STRATEGY_COMPOSER_PROFILE` | `strategy.composer_profile`        |
22//! | `NOTIFIER_DISCORD_WEBHOOK`  | `notifier.discord_webhook`         |
23//! | `NOTIFIER_ENABLED`          | `notifier.enabled`                 |
24//! | `ANTHROPIC_API_KEY`         | `credentials.anthropic_api_key`    |
25//! | `HYPERLIQUID_PRIVATE_KEY`   | `credentials.hyperliquid_private_key` |
26
27use serde::{Deserialize, Serialize};
28use std::env;
29use std::fmt;
30use std::path::Path;
31
32use crate::adjuster_guardrails::AdjusterGuardrails;
33
34// ---------------------------------------------------------------------------
35// Error
36// ---------------------------------------------------------------------------
37
38/// Errors that can occur when loading configuration.
39#[derive(Debug)]
40pub enum ConfigError {
41    /// Failed to read the TOML file from disk.
42    IoError(std::io::Error),
43    /// Failed to parse the TOML content.
44    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
70// ---------------------------------------------------------------------------
71// Default helpers
72// ---------------------------------------------------------------------------
73
74fn 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// ---------------------------------------------------------------------------
107// Sections
108// ---------------------------------------------------------------------------
109
110/// Top-level agent behaviour settings.
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112pub struct AgentSection {
113    /// Operating mode: `"live"`, `"paper"`, or `"dry-run"`.
114    #[serde(default = "default_mode")]
115    pub mode: String,
116
117    /// How often the agent loop runs (seconds).
118    #[serde(default = "default_interval_secs")]
119    pub interval_secs: u64,
120
121    /// Guardrails for adjustment frequency and magnitude.
122    #[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/// Exchange connection settings.
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
138pub struct ExchangeSection {
139    /// Whether to connect to the mainnet (`true`) or testnet (`false`).
140    #[serde(default = "default_true")]
141    pub is_mainnet: bool,
142
143    /// Optional vault address for delegated trading.
144    #[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/// Risk management limits.
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159pub struct RiskSection {
160    /// Maximum position size in USDC for any single trade.
161    #[serde(default = "default_max_position_usdc")]
162    pub max_position_usdc: f64,
163
164    /// Maximum leverage multiplier.
165    #[serde(default = "default_max_leverage")]
166    pub max_leverage: f64,
167
168    /// Maximum number of concurrent open positions.
169    #[serde(default = "default_max_open_positions")]
170    pub max_open_positions: u32,
171
172    /// Maximum aggregate daily loss in USDC before the agent stops trading.
173    #[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/// Strategy selection.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct StrategySection {
191    /// Strategy name (e.g. `"conservative"`, `"trend_following"`).
192    #[serde(default = "default_strategy_name")]
193    pub name: String,
194
195    /// Optional composer profile for signal composition.
196    #[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
209// NotifierSection has moved to hyper-agent-notify; re-export for backward compatibility.
210pub use hyper_agent_notify::config::NotifierSection;
211
212/// Secret credentials (API keys, private keys).
213///
214/// **Warning**: This section should never be committed to version control.
215/// Prefer environment variables for production deployments.
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
217pub struct CredentialsSection {
218    /// Anthropic API key for Claude.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub anthropic_api_key: Option<String>,
221
222    /// Hyperliquid private key.
223    #[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// ---------------------------------------------------------------------------
237// Top-level config
238// ---------------------------------------------------------------------------
239
240/// Unified application configuration loaded from TOML with env var overrides.
241#[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    /// Load configuration from an optional TOML file path, then apply
277    /// environment variable overrides.
278    ///
279    /// If `path` is `None` or the file does not exist, all defaults are used.
280    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    /// Load configuration from a TOML string, then apply env overrides.
293    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    /// Apply environment variable overrides to every field that has a
300    /// corresponding env var set.
301    pub fn apply_env_overrides(&mut self) {
302        // agent
303        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        // exchange
313        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        // risk
325        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        // strategy
347        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        // notifier
355        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        // credentials
367        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    // -----------------------------------------------------------------------
376    // #344 — Config unification: bridge TOML AppConfig -> AgentConfig
377    // -----------------------------------------------------------------------
378
379    /// Convert TOML-based `AppConfig` into the legacy `AgentConfig` used by
380    /// the agent loop.
381    ///
382    /// This bridges CLI/TOML settings so they actually reach
383    /// `execute_single_iteration()`. Fields that have no TOML equivalent
384    /// (e.g. `agent_id`, `prompt_template`) receive sensible defaults.
385    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        // Map strategy.name to a PromptTemplate where possible.
394        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        // Convert interval_secs to minutes (minimum 1).
413        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    // -----------------------------------------------------------------------
431    // #347 — Risk config bridge: AppConfig.risk -> hyper_risk RiskConfig
432    // -----------------------------------------------------------------------
433
434    /// Build a `hyper_risk::risk::RiskConfig` from the TOML `[risk]` section.
435    ///
436    /// This ensures CLI / TOML risk limits are respected by `RiskGuard`
437    /// instead of relying on the separate JSON file on disk.
438    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// ---------------------------------------------------------------------------
470// Tests
471// ---------------------------------------------------------------------------
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use std::io::Write;
477    use std::sync::Mutex;
478
479    /// Global mutex to serialize tests that manipulate environment variables.
480    /// Env vars are process-global, so concurrent tests would interfere.
481    static ENV_LOCK: Mutex<()> = Mutex::new(());
482
483    /// Helper: clear all config-related env vars.
484    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    // ---- Defaults (no env interaction needed) ----
507
508    #[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    // ---- TOML loading ----
531
532    #[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    // ---- Env var overrides ----
619
620    #[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    // ---- File loading ----
746
747    #[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    // ---- Serialization round-trip ----
801
802    #[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    // ---- Strategy env overrides ----
843
844    #[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    // ---- Empty env var for Option fields sets None ----
857
858    #[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    // ---- ConfigError display ----
873
874    #[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    // ---- #344 to_agent_config bridge ----
886
887    #[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); // 120s / 60
914        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(); // mode = "dry-run"
922        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        // 30 / 60 = 0, clamped to 1
958        assert_eq!(ac.analysis_frequency_minutes, 1);
959    }
960
961    // ---- #347 build_risk_config ----
962
963    #[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); // max_pos * max_leverage
970        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}