Skip to main content

hyper_agent_core/
agent_config.rs

1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::PathBuf;
4
5// ---------------------------------------------------------------------------
6// Prompt Template
7// ---------------------------------------------------------------------------
8
9/// Preset system prompt templates for different trading styles.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub enum PromptTemplate {
13    /// Trend following strategy: ride momentum, buy breakouts, trail stops.
14    TrendFollowing,
15    /// Mean reversion strategy: fade extremes, buy dips, sell rallies.
16    MeanReversion,
17    /// Conservative / paper-trading mode: analyze only, never place real orders.
18    Conservative,
19    /// User-defined system prompt.
20    Custom,
21}
22
23impl PromptTemplate {
24    /// Return the default system prompt text for each preset template.
25    pub fn default_prompt(&self) -> String {
26        match self {
27            PromptTemplate::TrendFollowing => concat!(
28                "You are a trend-following trading agent. ",
29                "Your goal is to identify and ride sustained price movements. ",
30                "Look for breakouts above resistance, increasing volume, and strong momentum indicators. ",
31                "Use trailing stops to protect profits. ",
32                "Enter positions when trend confirmation signals align across multiple timeframes. ",
33                "Cut losses quickly when the trend reverses."
34            ).to_string(),
35            PromptTemplate::MeanReversion => concat!(
36                "You are a mean-reversion trading agent. ",
37                "Your goal is to identify overextended price moves and trade the reversion to the mean. ",
38                "Look for extreme RSI readings, Bollinger Band violations, and significant deviations from moving averages. ",
39                "Enter positions when price is stretched far from its average with signs of exhaustion. ",
40                "Take profits as price returns toward the mean. ",
41                "Use tight stops in case the trend continues."
42            ).to_string(),
43            PromptTemplate::Conservative => concat!(
44                "You are a conservative analysis-only agent operating in paper-trading mode. ",
45                "Your goal is to analyze market conditions and provide trading recommendations WITHOUT placing any real orders. ",
46                "Provide detailed analysis of market structure, key levels, and potential trade setups. ",
47                "Include entry points, stop losses, and take profit targets in your analysis. ",
48                "Flag any high-risk conditions or unusual market behavior. ",
49                "Never recommend executing trades — only analyze and report."
50            ).to_string(),
51            PromptTemplate::Custom => String::new(),
52        }
53    }
54}
55
56/// Summary of a prompt template returned to the frontend.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct PromptTemplateSummary {
60    pub id: PromptTemplate,
61    pub name: String,
62    pub description: String,
63    pub default_prompt: String,
64}
65
66/// Return descriptions of all available prompt templates.
67pub fn list_prompt_templates() -> Vec<PromptTemplateSummary> {
68    vec![
69        PromptTemplateSummary {
70            id: PromptTemplate::TrendFollowing,
71            name: "Trend Following".to_string(),
72            description: "Ride momentum, buy breakouts, use trailing stops".to_string(),
73            default_prompt: PromptTemplate::TrendFollowing.default_prompt(),
74        },
75        PromptTemplateSummary {
76            id: PromptTemplate::MeanReversion,
77            name: "Mean Reversion".to_string(),
78            description: "Fade extremes, buy dips, sell rallies toward the mean".to_string(),
79            default_prompt: PromptTemplate::MeanReversion.default_prompt(),
80        },
81        PromptTemplateSummary {
82            id: PromptTemplate::Conservative,
83            name: "Conservative (Paper Trading)".to_string(),
84            description: "Analyze only, no real orders — paper trading mode".to_string(),
85            default_prompt: PromptTemplate::Conservative.default_prompt(),
86        },
87        PromptTemplateSummary {
88            id: PromptTemplate::Custom,
89            name: "Custom".to_string(),
90            description: "Define your own system prompt".to_string(),
91            default_prompt: PromptTemplate::Custom.default_prompt(),
92        },
93    ]
94}
95
96// ---------------------------------------------------------------------------
97// Trading Mode
98// ---------------------------------------------------------------------------
99
100/// Whether the agent operates in live or paper-trading mode.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase")]
103pub enum TradingMode {
104    /// Orders are sent to Hyperliquid for real execution.
105    Live,
106    /// Orders are simulated locally; no real fills.
107    Paper,
108}
109
110impl Default for TradingMode {
111    fn default() -> Self {
112        TradingMode::Paper
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Agent Config
118// ---------------------------------------------------------------------------
119
120/// Full configuration for an AI trading agent.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct AgentConfig {
124    /// The agent this config belongs to.
125    pub agent_id: String,
126
127    /// Which prompt template is active.
128    pub prompt_template: PromptTemplate,
129
130    /// The actual system prompt text sent to the AI model.
131    /// When `prompt_template` is a preset, this is populated from `PromptTemplate::default_prompt()`.
132    /// When `prompt_template` is `Custom`, the user supplies this.
133    pub system_prompt: String,
134
135    /// How often the agent loop runs, in minutes.
136    /// Common values: 15, 60, 240 (4 hours).
137    pub analysis_frequency_minutes: u64,
138
139    /// Which trading pairs / markets the agent should analyze.
140    /// e.g. ["BTC-PERP", "ETH-PERP"]
141    pub trading_pairs: Vec<String>,
142
143    /// Maximum position size in USD for any single trade.
144    pub max_position_size_usd: f64,
145
146    /// Whether the agent is enabled (on/off toggle).
147    pub enabled: bool,
148
149    /// Paper or live trading mode.
150    #[serde(default)]
151    pub trading_mode: TradingMode,
152
153    /// Optional StrategyComposer profile for signal composition.
154    /// When set, the agent loop uses the composer to detect market state,
155    /// compose strategy signals, and format analysis for Claude.
156    /// Valid values: "conservative", "all_weather", "turtle_system".
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub composer_profile: Option<String>,
159
160    /// Maximum number of retries for Claude API calls (rate limit / transient errors).
161    /// Uses exponential backoff via motosan-ai RetryPolicy.
162    /// Default: 3.
163    #[serde(default = "default_max_retries")]
164    pub max_retries: u32,
165
166    /// Maximum number of tool turns in a multi-turn conversation loop.
167    /// Each tool turn = Claude requests tool use -> we execute -> send result back.
168    /// Prevents infinite loops. Default: 5.
169    #[serde(default = "default_max_tool_turns")]
170    pub max_tool_turns: u32,
171}
172
173fn default_max_retries() -> u32 {
174    3
175}
176
177fn default_max_tool_turns() -> u32 {
178    5
179}
180
181impl AgentConfig {
182    /// Create a default config for an agent using a given template.
183    pub fn with_template(agent_id: &str, template: PromptTemplate) -> Self {
184        let system_prompt = template.default_prompt();
185        Self {
186            agent_id: agent_id.to_string(),
187            prompt_template: template,
188            system_prompt,
189            analysis_frequency_minutes: 60,
190            trading_pairs: vec!["BTC-PERP".to_string(), "ETH-PERP".to_string()],
191            max_position_size_usd: 10_000.0,
192            enabled: false,
193            trading_mode: TradingMode::default(),
194            composer_profile: None,
195            max_retries: default_max_retries(),
196            max_tool_turns: default_max_tool_turns(),
197        }
198    }
199}
200
201impl Default for AgentConfig {
202    fn default() -> Self {
203        Self::with_template("default", PromptTemplate::Conservative)
204    }
205}
206
207// ---------------------------------------------------------------------------
208// Persistence
209// ---------------------------------------------------------------------------
210
211fn agent_config_dir() -> PathBuf {
212    let mut path = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
213    path.push("hyper-agent");
214    path.push("agent-configs");
215    let _ = fs::create_dir_all(&path);
216    path
217}
218
219fn agent_config_path(agent_id: &str) -> PathBuf {
220    agent_config_dir().join(format!("{}.json", agent_id))
221}
222
223/// Load an agent config from disk. Returns `None` if no config file exists.
224pub fn load_agent_config(agent_id: &str) -> Option<AgentConfig> {
225    let path = agent_config_path(agent_id);
226    fs::read_to_string(&path)
227        .ok()
228        .and_then(|data| serde_json::from_str(&data).ok())
229}
230
231/// Save an agent config to disk.
232pub fn save_agent_config_to_disk(config: &AgentConfig) -> Result<(), String> {
233    let path = agent_config_path(&config.agent_id);
234    let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
235    fs::write(&path, json).map_err(|e| e.to_string())?;
236    Ok(())
237}
238
239/// Delete an agent config from disk.
240#[allow(dead_code)]
241pub fn delete_agent_config_from_disk(agent_id: &str) -> Result<(), String> {
242    let path = agent_config_path(agent_id);
243    if path.exists() {
244        fs::remove_file(&path).map_err(|e| e.to_string())?;
245    }
246    Ok(())
247}
248
249// ---------------------------------------------------------------------------
250// Validation
251// ---------------------------------------------------------------------------
252
253/// Validate an agent config. Returns `Ok(())` or an error message.
254pub fn validate_agent_config(config: &AgentConfig) -> Result<(), String> {
255    if config.agent_id.is_empty() {
256        return Err("agent_id must not be empty".to_string());
257    }
258    if config.analysis_frequency_minutes == 0 {
259        return Err("analysis_frequency_minutes must be at least 1".to_string());
260    }
261    if config.max_position_size_usd < 0.0 {
262        return Err("max_position_size_usd must not be negative".to_string());
263    }
264    if config.system_prompt.is_empty() && config.prompt_template == PromptTemplate::Custom {
265        return Err("system_prompt must not be empty when using Custom template".to_string());
266    }
267    Ok(())
268}
269
270// ---------------------------------------------------------------------------
271// Tests
272// ---------------------------------------------------------------------------
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use std::fs;
278
279    // ---- TradingMode ----
280
281    #[test]
282    fn test_trading_mode_serialization() {
283        assert_eq!(
284            serde_json::to_string(&TradingMode::Live).unwrap(),
285            "\"live\""
286        );
287        assert_eq!(
288            serde_json::to_string(&TradingMode::Paper).unwrap(),
289            "\"paper\""
290        );
291    }
292
293    #[test]
294    fn test_trading_mode_deserialization() {
295        let live: TradingMode = serde_json::from_str("\"live\"").unwrap();
296        assert_eq!(live, TradingMode::Live);
297        let paper: TradingMode = serde_json::from_str("\"paper\"").unwrap();
298        assert_eq!(paper, TradingMode::Paper);
299    }
300
301    #[test]
302    fn test_trading_mode_default_is_paper() {
303        assert_eq!(TradingMode::default(), TradingMode::Paper);
304    }
305
306    // ---- PromptTemplate ----
307
308    #[test]
309    fn test_prompt_template_serialization() {
310        let template = PromptTemplate::TrendFollowing;
311        let json = serde_json::to_string(&template).unwrap();
312        assert_eq!(json, "\"trendFollowing\"");
313
314        let template = PromptTemplate::MeanReversion;
315        let json = serde_json::to_string(&template).unwrap();
316        assert_eq!(json, "\"meanReversion\"");
317
318        let template = PromptTemplate::Conservative;
319        let json = serde_json::to_string(&template).unwrap();
320        assert_eq!(json, "\"conservative\"");
321
322        let template = PromptTemplate::Custom;
323        let json = serde_json::to_string(&template).unwrap();
324        assert_eq!(json, "\"custom\"");
325    }
326
327    #[test]
328    fn test_prompt_template_deserialization() {
329        let t: PromptTemplate = serde_json::from_str("\"trendFollowing\"").unwrap();
330        assert_eq!(t, PromptTemplate::TrendFollowing);
331
332        let t: PromptTemplate = serde_json::from_str("\"meanReversion\"").unwrap();
333        assert_eq!(t, PromptTemplate::MeanReversion);
334
335        let t: PromptTemplate = serde_json::from_str("\"conservative\"").unwrap();
336        assert_eq!(t, PromptTemplate::Conservative);
337
338        let t: PromptTemplate = serde_json::from_str("\"custom\"").unwrap();
339        assert_eq!(t, PromptTemplate::Custom);
340    }
341
342    #[test]
343    fn test_prompt_template_default_prompts_non_empty() {
344        assert!(!PromptTemplate::TrendFollowing.default_prompt().is_empty());
345        assert!(!PromptTemplate::MeanReversion.default_prompt().is_empty());
346        assert!(!PromptTemplate::Conservative.default_prompt().is_empty());
347        // Custom template has an empty default prompt
348        assert!(PromptTemplate::Custom.default_prompt().is_empty());
349    }
350
351    #[test]
352    fn test_prompt_template_default_prompts_contain_key_terms() {
353        let tf = PromptTemplate::TrendFollowing.default_prompt();
354        assert!(tf.contains("trend"));
355        assert!(tf.contains("breakout"));
356
357        let mr = PromptTemplate::MeanReversion.default_prompt();
358        assert!(mr.contains("mean"));
359        assert!(mr.contains("reversion") || mr.contains("mean"));
360
361        let c = PromptTemplate::Conservative.default_prompt();
362        assert!(c.contains("paper-trading") || c.contains("analysis-only"));
363        assert!(c.contains("Never"));
364    }
365
366    // ---- list_prompt_templates ----
367
368    #[test]
369    fn test_list_prompt_templates_returns_all() {
370        let templates = list_prompt_templates();
371        assert_eq!(templates.len(), 4);
372
373        let ids: Vec<_> = templates.iter().map(|t| &t.id).collect();
374        assert!(ids.contains(&&PromptTemplate::TrendFollowing));
375        assert!(ids.contains(&&PromptTemplate::MeanReversion));
376        assert!(ids.contains(&&PromptTemplate::Conservative));
377        assert!(ids.contains(&&PromptTemplate::Custom));
378    }
379
380    #[test]
381    fn test_list_prompt_templates_fields_populated() {
382        let templates = list_prompt_templates();
383        for t in &templates {
384            assert!(!t.name.is_empty());
385            assert!(!t.description.is_empty());
386            // default_prompt can be empty for Custom
387            if t.id != PromptTemplate::Custom {
388                assert!(!t.default_prompt.is_empty());
389            }
390        }
391    }
392
393    // ---- AgentConfig ----
394
395    #[test]
396    fn test_agent_config_with_template_trend_following() {
397        let config = AgentConfig::with_template("agent-1", PromptTemplate::TrendFollowing);
398        assert_eq!(config.agent_id, "agent-1");
399        assert_eq!(config.prompt_template, PromptTemplate::TrendFollowing);
400        assert!(!config.system_prompt.is_empty());
401        assert!(config.system_prompt.contains("trend"));
402        assert_eq!(config.analysis_frequency_minutes, 60);
403        assert!(!config.trading_pairs.is_empty());
404        assert!(config.max_position_size_usd > 0.0);
405        assert!(!config.enabled); // agents start disabled
406    }
407
408    #[test]
409    fn test_agent_config_with_template_conservative() {
410        let config = AgentConfig::with_template("agent-2", PromptTemplate::Conservative);
411        assert_eq!(config.prompt_template, PromptTemplate::Conservative);
412        assert!(
413            config.system_prompt.contains("paper-trading")
414                || config.system_prompt.contains("analysis-only")
415        );
416    }
417
418    #[test]
419    fn test_agent_config_default() {
420        let config = AgentConfig::default();
421        assert_eq!(config.agent_id, "default");
422        assert_eq!(config.prompt_template, PromptTemplate::Conservative);
423        assert!(!config.enabled);
424    }
425
426    #[test]
427    fn test_agent_config_serialization() {
428        let config = AgentConfig {
429            agent_id: "test-agent".to_string(),
430            prompt_template: PromptTemplate::MeanReversion,
431            system_prompt: "Custom prompt text".to_string(),
432            analysis_frequency_minutes: 240,
433            trading_pairs: vec!["BTC-PERP".to_string(), "SOL-PERP".to_string()],
434            max_position_size_usd: 5_000.0,
435            enabled: true,
436            trading_mode: TradingMode::Live,
437            composer_profile: Some("all_weather".to_string()),
438            max_retries: 5,
439            max_tool_turns: 3,
440        };
441
442        let json = serde_json::to_value(&config).unwrap();
443        assert_eq!(json["agentId"], "test-agent");
444        assert_eq!(json["promptTemplate"], "meanReversion");
445        assert_eq!(json["systemPrompt"], "Custom prompt text");
446        assert_eq!(json["analysisFrequencyMinutes"], 240);
447        assert_eq!(
448            json["tradingPairs"],
449            serde_json::json!(["BTC-PERP", "SOL-PERP"])
450        );
451        assert_eq!(json["maxPositionSizeUsd"], 5_000.0);
452        assert_eq!(json["enabled"], true);
453        assert_eq!(json["tradingMode"], "live");
454        assert_eq!(json["composerProfile"], "all_weather");
455        assert_eq!(json["maxRetries"], 5);
456    }
457
458    #[test]
459    fn test_agent_config_deserialization() {
460        let json = serde_json::json!({
461            "agentId": "deser-agent",
462            "promptTemplate": "trendFollowing",
463            "systemPrompt": "Go long on everything",
464            "analysisFrequencyMinutes": 15,
465            "tradingPairs": ["ETH-PERP"],
466            "maxPositionSizeUsd": 25000.0,
467            "enabled": false,
468            "tradingMode": "live"
469        });
470
471        let config: AgentConfig = serde_json::from_value(json).unwrap();
472        assert_eq!(config.agent_id, "deser-agent");
473        assert_eq!(config.prompt_template, PromptTemplate::TrendFollowing);
474        assert_eq!(config.system_prompt, "Go long on everything");
475        assert_eq!(config.analysis_frequency_minutes, 15);
476        assert_eq!(config.trading_pairs, vec!["ETH-PERP"]);
477        assert_eq!(config.max_position_size_usd, 25_000.0);
478        assert!(!config.enabled);
479        assert_eq!(config.trading_mode, TradingMode::Live);
480    }
481
482    #[test]
483    fn test_agent_config_deserialization_missing_trading_mode_defaults_to_paper() {
484        let json = serde_json::json!({
485            "agentId": "old-agent",
486            "promptTemplate": "conservative",
487            "systemPrompt": "Analyze only",
488            "analysisFrequencyMinutes": 60,
489            "tradingPairs": ["BTC-PERP"],
490            "maxPositionSizeUsd": 10000.0,
491            "enabled": false
492        });
493
494        let config: AgentConfig = serde_json::from_value(json).unwrap();
495        assert_eq!(config.trading_mode, TradingMode::Paper);
496    }
497
498    #[test]
499    fn test_agent_config_roundtrip() {
500        let config = AgentConfig::with_template("roundtrip-agent", PromptTemplate::TrendFollowing);
501        let json = serde_json::to_string(&config).unwrap();
502        let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
503
504        assert_eq!(config.agent_id, deserialized.agent_id);
505        assert_eq!(config.prompt_template, deserialized.prompt_template);
506        assert_eq!(config.system_prompt, deserialized.system_prompt);
507        assert_eq!(
508            config.analysis_frequency_minutes,
509            deserialized.analysis_frequency_minutes
510        );
511        assert_eq!(config.trading_pairs, deserialized.trading_pairs);
512        assert_eq!(
513            config.max_position_size_usd,
514            deserialized.max_position_size_usd
515        );
516        assert_eq!(config.enabled, deserialized.enabled);
517    }
518
519    // ---- Persistence ----
520
521    #[test]
522    fn test_save_and_load_agent_config() {
523        let config = AgentConfig {
524            agent_id: "persist-test-agent-cfg".to_string(),
525            prompt_template: PromptTemplate::MeanReversion,
526            system_prompt: "Test prompt for persistence".to_string(),
527            analysis_frequency_minutes: 30,
528            trading_pairs: vec!["BTC-PERP".to_string()],
529            max_position_size_usd: 7_500.0,
530            enabled: true,
531            trading_mode: TradingMode::Live,
532            composer_profile: None,
533            max_retries: 3,
534            max_tool_turns: 5,
535        };
536
537        // Save
538        save_agent_config_to_disk(&config).unwrap();
539
540        // Load
541        let loaded = load_agent_config("persist-test-agent-cfg");
542        assert!(loaded.is_some());
543        let loaded = loaded.unwrap();
544        assert_eq!(loaded.agent_id, config.agent_id);
545        assert_eq!(loaded.prompt_template, config.prompt_template);
546        assert_eq!(loaded.system_prompt, config.system_prompt);
547        assert_eq!(
548            loaded.analysis_frequency_minutes,
549            config.analysis_frequency_minutes
550        );
551        assert_eq!(loaded.trading_pairs, config.trading_pairs);
552        assert_eq!(loaded.max_position_size_usd, config.max_position_size_usd);
553        assert_eq!(loaded.enabled, config.enabled);
554
555        // Cleanup
556        let _ = fs::remove_file(agent_config_path("persist-test-agent-cfg"));
557    }
558
559    #[test]
560    fn test_load_nonexistent_config_returns_none() {
561        let loaded = load_agent_config("nonexistent-agent-xyz-12345");
562        assert!(loaded.is_none());
563    }
564
565    #[test]
566    fn test_delete_agent_config() {
567        let config =
568            AgentConfig::with_template("delete-test-agent-cfg", PromptTemplate::Conservative);
569        save_agent_config_to_disk(&config).unwrap();
570
571        // Verify it exists
572        assert!(load_agent_config("delete-test-agent-cfg").is_some());
573
574        // Delete
575        delete_agent_config_from_disk("delete-test-agent-cfg").unwrap();
576
577        // Verify it's gone
578        assert!(load_agent_config("delete-test-agent-cfg").is_none());
579    }
580
581    #[test]
582    fn test_delete_nonexistent_config_is_ok() {
583        let result = delete_agent_config_from_disk("never-existed-agent-xyz");
584        assert!(result.is_ok());
585    }
586
587    #[test]
588    fn test_overwrite_config() {
589        let agent_id = "overwrite-test-agent-cfg";
590
591        let config1 = AgentConfig {
592            agent_id: agent_id.to_string(),
593            prompt_template: PromptTemplate::TrendFollowing,
594            system_prompt: "First prompt".to_string(),
595            analysis_frequency_minutes: 15,
596            trading_pairs: vec!["BTC-PERP".to_string()],
597            max_position_size_usd: 1_000.0,
598            enabled: false,
599            trading_mode: TradingMode::Paper,
600            composer_profile: None,
601            max_retries: 3,
602            max_tool_turns: 5,
603        };
604        save_agent_config_to_disk(&config1).unwrap();
605
606        // Overwrite with different values
607        let config2 = AgentConfig {
608            agent_id: agent_id.to_string(),
609            prompt_template: PromptTemplate::MeanReversion,
610            system_prompt: "Updated prompt".to_string(),
611            analysis_frequency_minutes: 240,
612            trading_pairs: vec!["ETH-PERP".to_string(), "SOL-PERP".to_string()],
613            max_position_size_usd: 50_000.0,
614            enabled: true,
615            trading_mode: TradingMode::Live,
616            composer_profile: None,
617            max_retries: 3,
618            max_tool_turns: 5,
619        };
620        save_agent_config_to_disk(&config2).unwrap();
621
622        let loaded = load_agent_config(agent_id).unwrap();
623        assert_eq!(loaded.prompt_template, PromptTemplate::MeanReversion);
624        assert_eq!(loaded.system_prompt, "Updated prompt");
625        assert_eq!(loaded.analysis_frequency_minutes, 240);
626        assert_eq!(loaded.trading_pairs.len(), 2);
627        assert_eq!(loaded.max_position_size_usd, 50_000.0);
628        assert!(loaded.enabled);
629
630        // Cleanup
631        let _ = fs::remove_file(agent_config_path(agent_id));
632    }
633
634    // ---- Validation tests ----
635
636    #[test]
637    fn test_validate_config_valid() {
638        let config = AgentConfig::default();
639        // Default has agent_id="default", non-zero frequency, non-negative size
640        // But agent_id is "default" which is not empty, so should pass
641        assert!(validate_agent_config(&config).is_ok());
642    }
643
644    #[test]
645    fn test_validate_config_empty_agent_id() {
646        let config = AgentConfig {
647            agent_id: String::new(),
648            ..AgentConfig::default()
649        };
650        let result = validate_agent_config(&config);
651        assert!(result.is_err());
652        assert!(result.unwrap_err().contains("agent_id"));
653    }
654
655    #[test]
656    fn test_validate_config_zero_frequency() {
657        let config = AgentConfig {
658            agent_id: "valid-id".to_string(),
659            analysis_frequency_minutes: 0,
660            ..AgentConfig::default()
661        };
662        let result = validate_agent_config(&config);
663        assert!(result.is_err());
664        assert!(result.unwrap_err().contains("frequency"));
665    }
666
667    #[test]
668    fn test_validate_config_negative_position_size() {
669        let config = AgentConfig {
670            agent_id: "valid-id".to_string(),
671            max_position_size_usd: -100.0,
672            ..AgentConfig::default()
673        };
674        let result = validate_agent_config(&config);
675        assert!(result.is_err());
676        assert!(result.unwrap_err().contains("negative"));
677    }
678
679    #[test]
680    fn test_validate_config_custom_empty_prompt() {
681        let config = AgentConfig {
682            agent_id: "valid-id".to_string(),
683            prompt_template: PromptTemplate::Custom,
684            system_prompt: String::new(),
685            analysis_frequency_minutes: 60,
686            trading_pairs: vec![],
687            max_position_size_usd: 1_000.0,
688            enabled: false,
689            trading_mode: TradingMode::Paper,
690            composer_profile: None,
691            max_retries: 3,
692            max_tool_turns: 5,
693        };
694        let result = validate_agent_config(&config);
695        assert!(result.is_err());
696        assert!(result.unwrap_err().contains("system_prompt"));
697    }
698
699    // ---- max_retries ----
700
701    #[test]
702    fn test_agent_config_default_max_retries() {
703        let config = AgentConfig::default();
704        assert_eq!(config.max_retries, 3);
705    }
706
707    #[test]
708    fn test_agent_config_max_retries_serialization() {
709        let config = AgentConfig {
710            max_retries: 5,
711            ..AgentConfig::default()
712        };
713        let json = serde_json::to_value(&config).unwrap();
714        assert_eq!(json["maxRetries"], 5);
715    }
716
717    #[test]
718    fn test_agent_config_max_retries_deserialization_present() {
719        let json = serde_json::json!({
720            "agentId": "retry-agent",
721            "promptTemplate": "conservative",
722            "systemPrompt": "test",
723            "analysisFrequencyMinutes": 60,
724            "tradingPairs": ["BTC-PERP"],
725            "maxPositionSizeUsd": 10000.0,
726            "enabled": false,
727            "maxRetries": 7
728        });
729        let config: AgentConfig = serde_json::from_value(json).unwrap();
730        assert_eq!(config.max_retries, 7);
731    }
732
733    #[test]
734    fn test_agent_config_max_retries_deserialization_missing_defaults_to_3() {
735        let json = serde_json::json!({
736            "agentId": "no-retry-field",
737            "promptTemplate": "conservative",
738            "systemPrompt": "test",
739            "analysisFrequencyMinutes": 60,
740            "tradingPairs": ["BTC-PERP"],
741            "maxPositionSizeUsd": 10000.0,
742            "enabled": false
743        });
744        let config: AgentConfig = serde_json::from_value(json).unwrap();
745        assert_eq!(config.max_retries, 3);
746    }
747
748    #[test]
749    fn test_agent_config_max_retries_zero_disables_retry() {
750        let config = AgentConfig {
751            max_retries: 0,
752            ..AgentConfig::default()
753        };
754        assert_eq!(config.max_retries, 0);
755        let json = serde_json::to_value(&config).unwrap();
756        assert_eq!(json["maxRetries"], 0);
757    }
758}