use serde::{Deserialize, Serialize};
use std::env;
use std::fmt;
use std::path::Path;
use crate::adjuster_guardrails::AdjusterGuardrails;
#[derive(Debug)]
pub enum ConfigError {
IoError(std::io::Error),
ParseError(toml::de::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::IoError(e) => write!(f, "config I/O error: {}", e),
ConfigError::ParseError(e) => write!(f, "config parse error: {}", e),
}
}
}
impl std::error::Error for ConfigError {}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self {
ConfigError::IoError(e)
}
}
impl From<toml::de::Error> for ConfigError {
fn from(e: toml::de::Error) -> Self {
ConfigError::ParseError(e)
}
}
fn default_mode() -> String {
"dry-run".to_string()
}
fn default_interval_secs() -> u64 {
60
}
fn default_true() -> bool {
true
}
fn default_max_position_usdc() -> f64 {
10_000.0
}
fn default_max_leverage() -> f64 {
3.0
}
fn default_max_open_positions() -> u32 {
5
}
fn default_max_daily_loss_usdc() -> f64 {
1_000.0
}
fn default_strategy_name() -> String {
"conservative".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentSection {
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default = "default_interval_secs")]
pub interval_secs: u64,
#[serde(default)]
pub guardrails: AdjusterGuardrails,
}
impl Default for AgentSection {
fn default() -> Self {
Self {
mode: default_mode(),
interval_secs: default_interval_secs(),
guardrails: AdjusterGuardrails::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExchangeSection {
#[serde(default = "default_true")]
pub is_mainnet: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vault_address: Option<String>,
}
impl Default for ExchangeSection {
fn default() -> Self {
Self {
is_mainnet: default_true(),
vault_address: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RiskSection {
#[serde(default = "default_max_position_usdc")]
pub max_position_usdc: f64,
#[serde(default = "default_max_leverage")]
pub max_leverage: f64,
#[serde(default = "default_max_open_positions")]
pub max_open_positions: u32,
#[serde(default = "default_max_daily_loss_usdc")]
pub max_daily_loss_usdc: f64,
}
impl Default for RiskSection {
fn default() -> Self {
Self {
max_position_usdc: default_max_position_usdc(),
max_leverage: default_max_leverage(),
max_open_positions: default_max_open_positions(),
max_daily_loss_usdc: default_max_daily_loss_usdc(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StrategySection {
#[serde(default = "default_strategy_name")]
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub composer_profile: Option<String>,
}
impl Default for StrategySection {
fn default() -> Self {
Self {
name: default_strategy_name(),
composer_profile: None,
}
}
}
pub use hyper_agent_notify::config::NotifierSection;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CredentialsSection {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anthropic_api_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hyperliquid_private_key: Option<String>,
}
impl Default for CredentialsSection {
fn default() -> Self {
Self {
anthropic_api_key: None,
hyperliquid_private_key: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AppConfig {
#[serde(default)]
pub agent: AgentSection,
#[serde(default)]
pub exchange: ExchangeSection,
#[serde(default)]
pub risk: RiskSection,
#[serde(default)]
pub strategy: StrategySection,
#[serde(default)]
pub notifier: NotifierSection,
#[serde(default)]
pub credentials: CredentialsSection,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
agent: AgentSection::default(),
exchange: ExchangeSection::default(),
risk: RiskSection::default(),
strategy: StrategySection::default(),
notifier: NotifierSection::default(),
credentials: CredentialsSection::default(),
}
}
}
impl AppConfig {
pub fn load(path: Option<&str>) -> Result<Self, ConfigError> {
let mut config = match path {
Some(p) if Path::new(p).exists() => {
let content = std::fs::read_to_string(p)?;
toml::from_str::<AppConfig>(&content)?
}
_ => AppConfig::default(),
};
config.apply_env_overrides();
Ok(config)
}
pub fn from_toml_str(toml_str: &str) -> Result<Self, ConfigError> {
let mut config = toml::from_str::<AppConfig>(toml_str)?;
config.apply_env_overrides();
Ok(config)
}
pub fn apply_env_overrides(&mut self) {
if let Ok(v) = env::var("AGENT_MODE") {
self.agent.mode = v;
}
if let Ok(v) = env::var("AGENT_INTERVAL_SECS") {
if let Ok(n) = v.parse::<u64>() {
self.agent.interval_secs = n;
}
}
if let Ok(v) = env::var("EXCHANGE_IS_MAINNET") {
match v.to_lowercase().as_str() {
"true" | "1" | "yes" => self.exchange.is_mainnet = true,
"false" | "0" | "no" => self.exchange.is_mainnet = false,
_ => {}
}
}
if let Ok(v) = env::var("EXCHANGE_VAULT_ADDRESS") {
self.exchange.vault_address = if v.is_empty() { None } else { Some(v) };
}
if let Ok(v) = env::var("RISK_MAX_POSITION_USDC") {
if let Ok(n) = v.parse::<f64>() {
self.risk.max_position_usdc = n;
}
}
if let Ok(v) = env::var("RISK_MAX_LEVERAGE") {
if let Ok(n) = v.parse::<f64>() {
self.risk.max_leverage = n;
}
}
if let Ok(v) = env::var("RISK_MAX_OPEN_POSITIONS") {
if let Ok(n) = v.parse::<u32>() {
self.risk.max_open_positions = n;
}
}
if let Ok(v) = env::var("RISK_MAX_DAILY_LOSS_USDC") {
if let Ok(n) = v.parse::<f64>() {
self.risk.max_daily_loss_usdc = n;
}
}
if let Ok(v) = env::var("STRATEGY_NAME") {
self.strategy.name = v;
}
if let Ok(v) = env::var("STRATEGY_COMPOSER_PROFILE") {
self.strategy.composer_profile = if v.is_empty() { None } else { Some(v) };
}
if let Ok(v) = env::var("NOTIFIER_DISCORD_WEBHOOK") {
self.notifier.discord_webhook = if v.is_empty() { None } else { Some(v) };
}
if let Ok(v) = env::var("NOTIFIER_ENABLED") {
match v.to_lowercase().as_str() {
"true" | "1" | "yes" => self.notifier.enabled = true,
"false" | "0" | "no" => self.notifier.enabled = false,
_ => {}
}
}
if let Ok(v) = env::var("ANTHROPIC_API_KEY") {
self.credentials.anthropic_api_key = if v.is_empty() { None } else { Some(v) };
}
if let Ok(v) = env::var("HYPERLIQUID_PRIVATE_KEY") {
self.credentials.hyperliquid_private_key = if v.is_empty() { None } else { Some(v) };
}
}
pub fn to_agent_config(&self, agent_id: &str) -> crate::agent_config::AgentConfig {
use crate::agent_config::{AgentConfig, PromptTemplate, TradingMode};
let trading_mode = match self.agent.mode.as_str() {
"live" => TradingMode::Live,
_ => TradingMode::Paper,
};
let (prompt_template, system_prompt) = match self.strategy.name.as_str() {
"trend_following" => {
let t = PromptTemplate::TrendFollowing;
let p = t.default_prompt();
(t, p)
}
"mean_reversion" => {
let t = PromptTemplate::MeanReversion;
let p = t.default_prompt();
(t, p)
}
_ => {
let t = PromptTemplate::Conservative;
let p = t.default_prompt();
(t, p)
}
};
let analysis_frequency_minutes = (self.agent.interval_secs / 60).max(1);
AgentConfig {
agent_id: agent_id.to_string(),
prompt_template,
system_prompt,
analysis_frequency_minutes,
trading_pairs: vec!["BTC-PERP".to_string(), "ETH-PERP".to_string()],
max_position_size_usd: self.risk.max_position_usdc,
enabled: true,
trading_mode,
composer_profile: self.strategy.composer_profile.clone(),
max_retries: 3,
max_tool_turns: 5,
}
}
pub fn build_risk_config(&self) -> hyper_risk::risk::RiskConfig {
use hyper_risk::risk::*;
RiskConfig {
position_limits: PositionLimits {
enabled: true,
max_total_position: self.risk.max_position_usdc * self.risk.max_leverage,
max_per_symbol: self.risk.max_position_usdc,
},
daily_loss_limits: DailyLossLimits {
enabled: true,
max_daily_loss: self.risk.max_daily_loss_usdc,
max_daily_loss_percent: 5.0,
},
anomaly_detection: AnomalyDetection {
enabled: true,
max_order_size: self.risk.max_position_usdc * 2.0,
max_orders_per_minute: 10,
block_duplicate_orders: true,
},
circuit_breaker: CircuitBreaker {
enabled: false,
trigger_loss: self.risk.max_daily_loss_usdc * 2.0,
trigger_window_minutes: 60,
action: "pause_all".to_string(),
cooldown_minutes: 30,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn clear_env_vars() {
let vars = [
"AGENT_MODE",
"AGENT_INTERVAL_SECS",
"EXCHANGE_IS_MAINNET",
"EXCHANGE_VAULT_ADDRESS",
"RISK_MAX_POSITION_USDC",
"RISK_MAX_LEVERAGE",
"RISK_MAX_OPEN_POSITIONS",
"RISK_MAX_DAILY_LOSS_USDC",
"STRATEGY_NAME",
"STRATEGY_COMPOSER_PROFILE",
"NOTIFIER_DISCORD_WEBHOOK",
"NOTIFIER_ENABLED",
"ANTHROPIC_API_KEY",
"HYPERLIQUID_PRIVATE_KEY",
];
for var in vars {
env::remove_var(var);
}
}
#[test]
fn test_defaults_all_sections() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let cfg = AppConfig::default();
assert_eq!(cfg.agent.mode, "dry-run");
assert_eq!(cfg.agent.interval_secs, 60);
assert!(cfg.exchange.is_mainnet);
assert!(cfg.exchange.vault_address.is_none());
assert_eq!(cfg.risk.max_position_usdc, 10_000.0);
assert_eq!(cfg.risk.max_leverage, 3.0);
assert_eq!(cfg.risk.max_open_positions, 5);
assert_eq!(cfg.risk.max_daily_loss_usdc, 1_000.0);
assert_eq!(cfg.strategy.name, "conservative");
assert!(cfg.strategy.composer_profile.is_none());
assert!(!cfg.notifier.enabled);
assert!(cfg.notifier.discord_webhook.is_none());
assert!(cfg.credentials.anthropic_api_key.is_none());
assert!(cfg.credentials.hyperliquid_private_key.is_none());
clear_env_vars();
}
#[test]
fn test_load_from_toml_string() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let toml = r#"
[agent]
mode = "live"
interval_secs = 30
[exchange]
is_mainnet = false
vault_address = "0xABC"
[risk]
max_position_usdc = 50000.0
max_leverage = 5.0
max_open_positions = 10
max_daily_loss_usdc = 2500.0
[strategy]
name = "trend_following"
composer_profile = "all_weather"
[notifier]
enabled = true
discord_webhook = "https://discord.com/api/webhooks/xxx"
[credentials]
anthropic_api_key = "sk-ant-test"
hyperliquid_private_key = "0xHL"
"#;
let cfg = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(cfg.agent.mode, "live");
assert_eq!(cfg.agent.interval_secs, 30);
assert!(!cfg.exchange.is_mainnet);
assert_eq!(cfg.exchange.vault_address.as_deref(), Some("0xABC"));
assert_eq!(cfg.risk.max_position_usdc, 50_000.0);
assert_eq!(cfg.risk.max_leverage, 5.0);
assert_eq!(cfg.risk.max_open_positions, 10);
assert_eq!(cfg.risk.max_daily_loss_usdc, 2_500.0);
assert_eq!(cfg.strategy.name, "trend_following");
assert_eq!(
cfg.strategy.composer_profile.as_deref(),
Some("all_weather")
);
assert!(cfg.notifier.enabled);
assert_eq!(
cfg.notifier.discord_webhook.as_deref(),
Some("https://discord.com/api/webhooks/xxx")
);
assert_eq!(
cfg.credentials.anthropic_api_key.as_deref(),
Some("sk-ant-test")
);
assert_eq!(
cfg.credentials.hyperliquid_private_key.as_deref(),
Some("0xHL")
);
clear_env_vars();
}
#[test]
fn test_partial_toml_uses_defaults_for_missing() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let toml = r#"
[agent]
mode = "paper"
"#;
let cfg = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(cfg.agent.mode, "paper");
assert_eq!(cfg.agent.interval_secs, 60);
assert!(cfg.exchange.is_mainnet);
assert_eq!(cfg.risk.max_position_usdc, 10_000.0);
clear_env_vars();
}
#[test]
fn test_empty_toml_gives_all_defaults() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let cfg = AppConfig::from_toml_str("").unwrap();
assert_eq!(cfg, AppConfig::default());
clear_env_vars();
}
#[test]
fn test_env_override_agent_mode() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("AGENT_MODE", "live");
let cfg = AppConfig::from_toml_str("").unwrap();
assert_eq!(cfg.agent.mode, "live");
clear_env_vars();
}
#[test]
fn test_env_override_agent_interval() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("AGENT_INTERVAL_SECS", "120");
let cfg = AppConfig::from_toml_str("").unwrap();
assert_eq!(cfg.agent.interval_secs, 120);
clear_env_vars();
}
#[test]
fn test_env_override_exchange_is_mainnet_false() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("EXCHANGE_IS_MAINNET", "false");
let cfg = AppConfig::from_toml_str("").unwrap();
assert!(!cfg.exchange.is_mainnet);
clear_env_vars();
}
#[test]
fn test_env_override_exchange_is_mainnet_numeric() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("EXCHANGE_IS_MAINNET", "0");
let cfg = AppConfig::from_toml_str("").unwrap();
assert!(!cfg.exchange.is_mainnet);
clear_env_vars();
}
#[test]
fn test_env_override_risk_fields() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("RISK_MAX_POSITION_USDC", "25000");
env::set_var("RISK_MAX_LEVERAGE", "10.0");
env::set_var("RISK_MAX_OPEN_POSITIONS", "20");
env::set_var("RISK_MAX_DAILY_LOSS_USDC", "5000");
let cfg = AppConfig::from_toml_str("").unwrap();
assert_eq!(cfg.risk.max_position_usdc, 25_000.0);
assert_eq!(cfg.risk.max_leverage, 10.0);
assert_eq!(cfg.risk.max_open_positions, 20);
assert_eq!(cfg.risk.max_daily_loss_usdc, 5_000.0);
clear_env_vars();
}
#[test]
fn test_env_override_credentials() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("ANTHROPIC_API_KEY", "sk-ant-xxx");
env::set_var("HYPERLIQUID_PRIVATE_KEY", "0xhl");
let cfg = AppConfig::from_toml_str("").unwrap();
assert_eq!(
cfg.credentials.anthropic_api_key.as_deref(),
Some("sk-ant-xxx")
);
assert_eq!(
cfg.credentials.hyperliquid_private_key.as_deref(),
Some("0xhl")
);
clear_env_vars();
}
#[test]
fn test_env_override_notifier() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("NOTIFIER_ENABLED", "true");
env::set_var("NOTIFIER_DISCORD_WEBHOOK", "https://hooks.example.com/abc");
let cfg = AppConfig::from_toml_str("").unwrap();
assert!(cfg.notifier.enabled);
assert_eq!(
cfg.notifier.discord_webhook.as_deref(),
Some("https://hooks.example.com/abc")
);
clear_env_vars();
}
#[test]
fn test_env_overrides_toml_values() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let toml = r#"
[agent]
mode = "paper"
interval_secs = 30
"#;
env::set_var("AGENT_MODE", "live");
let cfg = AppConfig::from_toml_str(toml).unwrap();
assert_eq!(cfg.agent.mode, "live");
assert_eq!(cfg.agent.interval_secs, 30);
clear_env_vars();
}
#[test]
fn test_env_invalid_number_ignored() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("AGENT_INTERVAL_SECS", "not_a_number");
let cfg = AppConfig::from_toml_str("").unwrap();
assert_eq!(cfg.agent.interval_secs, 60);
clear_env_vars();
}
#[test]
fn test_env_invalid_bool_ignored() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("EXCHANGE_IS_MAINNET", "maybe");
let cfg = AppConfig::from_toml_str("").unwrap();
assert!(cfg.exchange.is_mainnet);
clear_env_vars();
}
#[test]
fn test_load_from_file() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[agent]
mode = "paper"
interval_secs = 15
"#
)
.unwrap();
let cfg = AppConfig::load(Some(path.to_str().unwrap())).unwrap();
assert_eq!(cfg.agent.mode, "paper");
assert_eq!(cfg.agent.interval_secs, 15);
clear_env_vars();
}
#[test]
fn test_load_missing_file_gives_defaults() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let cfg = AppConfig::load(Some("/tmp/nonexistent_hyper_agent_config.toml")).unwrap();
assert_eq!(cfg, AppConfig::default());
clear_env_vars();
}
#[test]
fn test_load_none_path_gives_defaults() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let cfg = AppConfig::load(None).unwrap();
assert_eq!(cfg, AppConfig::default());
clear_env_vars();
}
#[test]
fn test_load_invalid_toml_returns_error() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.toml");
std::fs::write(&path, "this is not [valid toml =").unwrap();
let result = AppConfig::load(Some(path.to_str().unwrap()));
assert!(result.is_err());
clear_env_vars();
}
#[test]
fn test_toml_serialization_roundtrip() {
let cfg = AppConfig {
agent: AgentSection {
mode: "live".to_string(),
interval_secs: 45,
..Default::default()
},
exchange: ExchangeSection {
is_mainnet: false,
vault_address: Some("0xDEAD".to_string()),
},
risk: RiskSection {
max_position_usdc: 99_999.0,
max_leverage: 7.5,
max_open_positions: 12,
max_daily_loss_usdc: 3_333.0,
},
strategy: StrategySection {
name: "turtle_system".to_string(),
composer_profile: Some("aggressive".to_string()),
},
notifier: NotifierSection {
enabled: true,
discord_webhook: Some("https://example.com".to_string()),
log_dir: "logs".to_string(),
quiet_start: "23:00".to_string(),
quiet_end: "08:00".to_string(),
},
credentials: CredentialsSection {
anthropic_api_key: Some("key".to_string()),
hyperliquid_private_key: Some("0xABC".to_string()),
},
};
let toml_str = toml::to_string(&cfg).unwrap();
let deserialized: AppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(cfg, deserialized);
}
#[test]
fn test_env_override_strategy() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
env::set_var("STRATEGY_NAME", "turtle_system");
env::set_var("STRATEGY_COMPOSER_PROFILE", "aggressive");
let cfg = AppConfig::from_toml_str("").unwrap();
assert_eq!(cfg.strategy.name, "turtle_system");
assert_eq!(cfg.strategy.composer_profile.as_deref(), Some("aggressive"));
clear_env_vars();
}
#[test]
fn test_env_empty_string_clears_option() {
let _lock = ENV_LOCK.lock().unwrap();
clear_env_vars();
let toml = r#"
[credentials]
anthropic_api_key = "original-key"
"#;
env::set_var("ANTHROPIC_API_KEY", "");
let cfg = AppConfig::from_toml_str(toml).unwrap();
assert!(cfg.credentials.anthropic_api_key.is_none());
clear_env_vars();
}
#[test]
fn test_config_error_display() {
let io_err =
ConfigError::IoError(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
assert!(io_err.to_string().contains("I/O"));
let parse_err = toml::from_str::<AppConfig>("bad[toml").unwrap_err();
let cfg_err = ConfigError::ParseError(parse_err);
assert!(cfg_err.to_string().contains("parse"));
}
#[test]
fn test_to_agent_config_live_mode() {
let cfg = AppConfig {
agent: AgentSection {
mode: "live".to_string(),
interval_secs: 120,
..Default::default()
},
risk: RiskSection {
max_position_usdc: 25_000.0,
..Default::default()
},
strategy: StrategySection {
name: "trend_following".to_string(),
composer_profile: Some("all_weather".to_string()),
},
..Default::default()
};
let ac = cfg.to_agent_config("test-agent");
assert_eq!(ac.agent_id, "test-agent");
assert_eq!(ac.trading_mode, crate::agent_config::TradingMode::Live);
assert_eq!(
ac.prompt_template,
crate::agent_config::PromptTemplate::TrendFollowing
);
assert!(!ac.system_prompt.is_empty());
assert_eq!(ac.analysis_frequency_minutes, 2); assert_eq!(ac.max_position_size_usd, 25_000.0);
assert_eq!(ac.composer_profile.as_deref(), Some("all_weather"));
assert!(ac.enabled);
}
#[test]
fn test_to_agent_config_paper_mode() {
let cfg = AppConfig::default(); let ac = cfg.to_agent_config("paper-agent");
assert_eq!(ac.trading_mode, crate::agent_config::TradingMode::Paper);
assert_eq!(
ac.prompt_template,
crate::agent_config::PromptTemplate::Conservative
);
}
#[test]
fn test_to_agent_config_mean_reversion() {
let cfg = AppConfig {
strategy: StrategySection {
name: "mean_reversion".to_string(),
composer_profile: None,
},
..Default::default()
};
let ac = cfg.to_agent_config("mr-agent");
assert_eq!(
ac.prompt_template,
crate::agent_config::PromptTemplate::MeanReversion
);
}
#[test]
fn test_to_agent_config_interval_minimum_1() {
let cfg = AppConfig {
agent: AgentSection {
mode: "paper".to_string(),
interval_secs: 30,
..Default::default()
},
..Default::default()
};
let ac = cfg.to_agent_config("fast");
assert_eq!(ac.analysis_frequency_minutes, 1);
}
#[test]
fn test_build_risk_config_defaults() {
let cfg = AppConfig::default();
let rc = cfg.build_risk_config();
assert!(rc.position_limits.enabled);
assert_eq!(rc.position_limits.max_per_symbol, 10_000.0);
assert_eq!(rc.position_limits.max_total_position, 10_000.0 * 3.0); assert!(rc.daily_loss_limits.enabled);
assert_eq!(rc.daily_loss_limits.max_daily_loss, 1_000.0);
assert!(!rc.circuit_breaker.enabled);
}
#[test]
fn test_build_risk_config_custom_values() {
let cfg = AppConfig {
risk: RiskSection {
max_position_usdc: 50_000.0,
max_leverage: 5.0,
max_open_positions: 10,
max_daily_loss_usdc: 5_000.0,
},
..Default::default()
};
let rc = cfg.build_risk_config();
assert_eq!(rc.position_limits.max_per_symbol, 50_000.0);
assert_eq!(rc.position_limits.max_total_position, 250_000.0);
assert_eq!(rc.daily_loss_limits.max_daily_loss, 5_000.0);
assert_eq!(rc.anomaly_detection.max_order_size, 100_000.0);
assert_eq!(rc.circuit_breaker.trigger_loss, 10_000.0);
}
}