use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PromptTemplate {
TrendFollowing,
MeanReversion,
Conservative,
Custom,
}
impl PromptTemplate {
pub fn default_prompt(&self) -> String {
match self {
PromptTemplate::TrendFollowing => concat!(
"You are a trend-following trading agent. ",
"Your goal is to identify and ride sustained price movements. ",
"Look for breakouts above resistance, increasing volume, and strong momentum indicators. ",
"Use trailing stops to protect profits. ",
"Enter positions when trend confirmation signals align across multiple timeframes. ",
"Cut losses quickly when the trend reverses."
).to_string(),
PromptTemplate::MeanReversion => concat!(
"You are a mean-reversion trading agent. ",
"Your goal is to identify overextended price moves and trade the reversion to the mean. ",
"Look for extreme RSI readings, Bollinger Band violations, and significant deviations from moving averages. ",
"Enter positions when price is stretched far from its average with signs of exhaustion. ",
"Take profits as price returns toward the mean. ",
"Use tight stops in case the trend continues."
).to_string(),
PromptTemplate::Conservative => concat!(
"You are a conservative analysis-only agent operating in paper-trading mode. ",
"Your goal is to analyze market conditions and provide trading recommendations WITHOUT placing any real orders. ",
"Provide detailed analysis of market structure, key levels, and potential trade setups. ",
"Include entry points, stop losses, and take profit targets in your analysis. ",
"Flag any high-risk conditions or unusual market behavior. ",
"Never recommend executing trades — only analyze and report."
).to_string(),
PromptTemplate::Custom => String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PromptTemplateSummary {
pub id: PromptTemplate,
pub name: String,
pub description: String,
pub default_prompt: String,
}
pub fn list_prompt_templates() -> Vec<PromptTemplateSummary> {
vec![
PromptTemplateSummary {
id: PromptTemplate::TrendFollowing,
name: "Trend Following".to_string(),
description: "Ride momentum, buy breakouts, use trailing stops".to_string(),
default_prompt: PromptTemplate::TrendFollowing.default_prompt(),
},
PromptTemplateSummary {
id: PromptTemplate::MeanReversion,
name: "Mean Reversion".to_string(),
description: "Fade extremes, buy dips, sell rallies toward the mean".to_string(),
default_prompt: PromptTemplate::MeanReversion.default_prompt(),
},
PromptTemplateSummary {
id: PromptTemplate::Conservative,
name: "Conservative (Paper Trading)".to_string(),
description: "Analyze only, no real orders — paper trading mode".to_string(),
default_prompt: PromptTemplate::Conservative.default_prompt(),
},
PromptTemplateSummary {
id: PromptTemplate::Custom,
name: "Custom".to_string(),
description: "Define your own system prompt".to_string(),
default_prompt: PromptTemplate::Custom.default_prompt(),
},
]
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TradingMode {
Live,
Paper,
}
impl Default for TradingMode {
fn default() -> Self {
TradingMode::Paper
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentConfig {
pub agent_id: String,
pub prompt_template: PromptTemplate,
pub system_prompt: String,
pub analysis_frequency_minutes: u64,
pub trading_pairs: Vec<String>,
pub max_position_size_usd: f64,
pub enabled: bool,
#[serde(default)]
pub trading_mode: TradingMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub composer_profile: Option<String>,
#[serde(default = "default_max_retries")]
pub max_retries: u32,
#[serde(default = "default_max_tool_turns")]
pub max_tool_turns: u32,
}
fn default_max_retries() -> u32 {
3
}
fn default_max_tool_turns() -> u32 {
5
}
impl AgentConfig {
pub fn with_template(agent_id: &str, template: PromptTemplate) -> Self {
let system_prompt = template.default_prompt();
Self {
agent_id: agent_id.to_string(),
prompt_template: template,
system_prompt,
analysis_frequency_minutes: 60,
trading_pairs: vec!["BTC-PERP".to_string(), "ETH-PERP".to_string()],
max_position_size_usd: 10_000.0,
enabled: false,
trading_mode: TradingMode::default(),
composer_profile: None,
max_retries: default_max_retries(),
max_tool_turns: default_max_tool_turns(),
}
}
}
impl Default for AgentConfig {
fn default() -> Self {
Self::with_template("default", PromptTemplate::Conservative)
}
}
fn agent_config_dir() -> PathBuf {
let mut path = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("hyper-agent");
path.push("agent-configs");
let _ = fs::create_dir_all(&path);
path
}
fn agent_config_path(agent_id: &str) -> PathBuf {
agent_config_dir().join(format!("{}.json", agent_id))
}
pub fn load_agent_config(agent_id: &str) -> Option<AgentConfig> {
let path = agent_config_path(agent_id);
fs::read_to_string(&path)
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
}
pub fn save_agent_config_to_disk(config: &AgentConfig) -> Result<(), String> {
let path = agent_config_path(&config.agent_id);
let json = serde_json::to_string_pretty(config).map_err(|e| e.to_string())?;
fs::write(&path, json).map_err(|e| e.to_string())?;
Ok(())
}
#[allow(dead_code)]
pub fn delete_agent_config_from_disk(agent_id: &str) -> Result<(), String> {
let path = agent_config_path(agent_id);
if path.exists() {
fs::remove_file(&path).map_err(|e| e.to_string())?;
}
Ok(())
}
pub fn validate_agent_config(config: &AgentConfig) -> Result<(), String> {
if config.agent_id.is_empty() {
return Err("agent_id must not be empty".to_string());
}
if config.analysis_frequency_minutes == 0 {
return Err("analysis_frequency_minutes must be at least 1".to_string());
}
if config.max_position_size_usd < 0.0 {
return Err("max_position_size_usd must not be negative".to_string());
}
if config.system_prompt.is_empty() && config.prompt_template == PromptTemplate::Custom {
return Err("system_prompt must not be empty when using Custom template".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_trading_mode_serialization() {
assert_eq!(
serde_json::to_string(&TradingMode::Live).unwrap(),
"\"live\""
);
assert_eq!(
serde_json::to_string(&TradingMode::Paper).unwrap(),
"\"paper\""
);
}
#[test]
fn test_trading_mode_deserialization() {
let live: TradingMode = serde_json::from_str("\"live\"").unwrap();
assert_eq!(live, TradingMode::Live);
let paper: TradingMode = serde_json::from_str("\"paper\"").unwrap();
assert_eq!(paper, TradingMode::Paper);
}
#[test]
fn test_trading_mode_default_is_paper() {
assert_eq!(TradingMode::default(), TradingMode::Paper);
}
#[test]
fn test_prompt_template_serialization() {
let template = PromptTemplate::TrendFollowing;
let json = serde_json::to_string(&template).unwrap();
assert_eq!(json, "\"trendFollowing\"");
let template = PromptTemplate::MeanReversion;
let json = serde_json::to_string(&template).unwrap();
assert_eq!(json, "\"meanReversion\"");
let template = PromptTemplate::Conservative;
let json = serde_json::to_string(&template).unwrap();
assert_eq!(json, "\"conservative\"");
let template = PromptTemplate::Custom;
let json = serde_json::to_string(&template).unwrap();
assert_eq!(json, "\"custom\"");
}
#[test]
fn test_prompt_template_deserialization() {
let t: PromptTemplate = serde_json::from_str("\"trendFollowing\"").unwrap();
assert_eq!(t, PromptTemplate::TrendFollowing);
let t: PromptTemplate = serde_json::from_str("\"meanReversion\"").unwrap();
assert_eq!(t, PromptTemplate::MeanReversion);
let t: PromptTemplate = serde_json::from_str("\"conservative\"").unwrap();
assert_eq!(t, PromptTemplate::Conservative);
let t: PromptTemplate = serde_json::from_str("\"custom\"").unwrap();
assert_eq!(t, PromptTemplate::Custom);
}
#[test]
fn test_prompt_template_default_prompts_non_empty() {
assert!(!PromptTemplate::TrendFollowing.default_prompt().is_empty());
assert!(!PromptTemplate::MeanReversion.default_prompt().is_empty());
assert!(!PromptTemplate::Conservative.default_prompt().is_empty());
assert!(PromptTemplate::Custom.default_prompt().is_empty());
}
#[test]
fn test_prompt_template_default_prompts_contain_key_terms() {
let tf = PromptTemplate::TrendFollowing.default_prompt();
assert!(tf.contains("trend"));
assert!(tf.contains("breakout"));
let mr = PromptTemplate::MeanReversion.default_prompt();
assert!(mr.contains("mean"));
assert!(mr.contains("reversion") || mr.contains("mean"));
let c = PromptTemplate::Conservative.default_prompt();
assert!(c.contains("paper-trading") || c.contains("analysis-only"));
assert!(c.contains("Never"));
}
#[test]
fn test_list_prompt_templates_returns_all() {
let templates = list_prompt_templates();
assert_eq!(templates.len(), 4);
let ids: Vec<_> = templates.iter().map(|t| &t.id).collect();
assert!(ids.contains(&&PromptTemplate::TrendFollowing));
assert!(ids.contains(&&PromptTemplate::MeanReversion));
assert!(ids.contains(&&PromptTemplate::Conservative));
assert!(ids.contains(&&PromptTemplate::Custom));
}
#[test]
fn test_list_prompt_templates_fields_populated() {
let templates = list_prompt_templates();
for t in &templates {
assert!(!t.name.is_empty());
assert!(!t.description.is_empty());
if t.id != PromptTemplate::Custom {
assert!(!t.default_prompt.is_empty());
}
}
}
#[test]
fn test_agent_config_with_template_trend_following() {
let config = AgentConfig::with_template("agent-1", PromptTemplate::TrendFollowing);
assert_eq!(config.agent_id, "agent-1");
assert_eq!(config.prompt_template, PromptTemplate::TrendFollowing);
assert!(!config.system_prompt.is_empty());
assert!(config.system_prompt.contains("trend"));
assert_eq!(config.analysis_frequency_minutes, 60);
assert!(!config.trading_pairs.is_empty());
assert!(config.max_position_size_usd > 0.0);
assert!(!config.enabled); }
#[test]
fn test_agent_config_with_template_conservative() {
let config = AgentConfig::with_template("agent-2", PromptTemplate::Conservative);
assert_eq!(config.prompt_template, PromptTemplate::Conservative);
assert!(
config.system_prompt.contains("paper-trading")
|| config.system_prompt.contains("analysis-only")
);
}
#[test]
fn test_agent_config_default() {
let config = AgentConfig::default();
assert_eq!(config.agent_id, "default");
assert_eq!(config.prompt_template, PromptTemplate::Conservative);
assert!(!config.enabled);
}
#[test]
fn test_agent_config_serialization() {
let config = AgentConfig {
agent_id: "test-agent".to_string(),
prompt_template: PromptTemplate::MeanReversion,
system_prompt: "Custom prompt text".to_string(),
analysis_frequency_minutes: 240,
trading_pairs: vec!["BTC-PERP".to_string(), "SOL-PERP".to_string()],
max_position_size_usd: 5_000.0,
enabled: true,
trading_mode: TradingMode::Live,
composer_profile: Some("all_weather".to_string()),
max_retries: 5,
max_tool_turns: 3,
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["agentId"], "test-agent");
assert_eq!(json["promptTemplate"], "meanReversion");
assert_eq!(json["systemPrompt"], "Custom prompt text");
assert_eq!(json["analysisFrequencyMinutes"], 240);
assert_eq!(
json["tradingPairs"],
serde_json::json!(["BTC-PERP", "SOL-PERP"])
);
assert_eq!(json["maxPositionSizeUsd"], 5_000.0);
assert_eq!(json["enabled"], true);
assert_eq!(json["tradingMode"], "live");
assert_eq!(json["composerProfile"], "all_weather");
assert_eq!(json["maxRetries"], 5);
}
#[test]
fn test_agent_config_deserialization() {
let json = serde_json::json!({
"agentId": "deser-agent",
"promptTemplate": "trendFollowing",
"systemPrompt": "Go long on everything",
"analysisFrequencyMinutes": 15,
"tradingPairs": ["ETH-PERP"],
"maxPositionSizeUsd": 25000.0,
"enabled": false,
"tradingMode": "live"
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.agent_id, "deser-agent");
assert_eq!(config.prompt_template, PromptTemplate::TrendFollowing);
assert_eq!(config.system_prompt, "Go long on everything");
assert_eq!(config.analysis_frequency_minutes, 15);
assert_eq!(config.trading_pairs, vec!["ETH-PERP"]);
assert_eq!(config.max_position_size_usd, 25_000.0);
assert!(!config.enabled);
assert_eq!(config.trading_mode, TradingMode::Live);
}
#[test]
fn test_agent_config_deserialization_missing_trading_mode_defaults_to_paper() {
let json = serde_json::json!({
"agentId": "old-agent",
"promptTemplate": "conservative",
"systemPrompt": "Analyze only",
"analysisFrequencyMinutes": 60,
"tradingPairs": ["BTC-PERP"],
"maxPositionSizeUsd": 10000.0,
"enabled": false
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.trading_mode, TradingMode::Paper);
}
#[test]
fn test_agent_config_roundtrip() {
let config = AgentConfig::with_template("roundtrip-agent", PromptTemplate::TrendFollowing);
let json = serde_json::to_string(&config).unwrap();
let deserialized: AgentConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config.agent_id, deserialized.agent_id);
assert_eq!(config.prompt_template, deserialized.prompt_template);
assert_eq!(config.system_prompt, deserialized.system_prompt);
assert_eq!(
config.analysis_frequency_minutes,
deserialized.analysis_frequency_minutes
);
assert_eq!(config.trading_pairs, deserialized.trading_pairs);
assert_eq!(
config.max_position_size_usd,
deserialized.max_position_size_usd
);
assert_eq!(config.enabled, deserialized.enabled);
}
#[test]
fn test_save_and_load_agent_config() {
let config = AgentConfig {
agent_id: "persist-test-agent-cfg".to_string(),
prompt_template: PromptTemplate::MeanReversion,
system_prompt: "Test prompt for persistence".to_string(),
analysis_frequency_minutes: 30,
trading_pairs: vec!["BTC-PERP".to_string()],
max_position_size_usd: 7_500.0,
enabled: true,
trading_mode: TradingMode::Live,
composer_profile: None,
max_retries: 3,
max_tool_turns: 5,
};
save_agent_config_to_disk(&config).unwrap();
let loaded = load_agent_config("persist-test-agent-cfg");
assert!(loaded.is_some());
let loaded = loaded.unwrap();
assert_eq!(loaded.agent_id, config.agent_id);
assert_eq!(loaded.prompt_template, config.prompt_template);
assert_eq!(loaded.system_prompt, config.system_prompt);
assert_eq!(
loaded.analysis_frequency_minutes,
config.analysis_frequency_minutes
);
assert_eq!(loaded.trading_pairs, config.trading_pairs);
assert_eq!(loaded.max_position_size_usd, config.max_position_size_usd);
assert_eq!(loaded.enabled, config.enabled);
let _ = fs::remove_file(agent_config_path("persist-test-agent-cfg"));
}
#[test]
fn test_load_nonexistent_config_returns_none() {
let loaded = load_agent_config("nonexistent-agent-xyz-12345");
assert!(loaded.is_none());
}
#[test]
fn test_delete_agent_config() {
let config =
AgentConfig::with_template("delete-test-agent-cfg", PromptTemplate::Conservative);
save_agent_config_to_disk(&config).unwrap();
assert!(load_agent_config("delete-test-agent-cfg").is_some());
delete_agent_config_from_disk("delete-test-agent-cfg").unwrap();
assert!(load_agent_config("delete-test-agent-cfg").is_none());
}
#[test]
fn test_delete_nonexistent_config_is_ok() {
let result = delete_agent_config_from_disk("never-existed-agent-xyz");
assert!(result.is_ok());
}
#[test]
fn test_overwrite_config() {
let agent_id = "overwrite-test-agent-cfg";
let config1 = AgentConfig {
agent_id: agent_id.to_string(),
prompt_template: PromptTemplate::TrendFollowing,
system_prompt: "First prompt".to_string(),
analysis_frequency_minutes: 15,
trading_pairs: vec!["BTC-PERP".to_string()],
max_position_size_usd: 1_000.0,
enabled: false,
trading_mode: TradingMode::Paper,
composer_profile: None,
max_retries: 3,
max_tool_turns: 5,
};
save_agent_config_to_disk(&config1).unwrap();
let config2 = AgentConfig {
agent_id: agent_id.to_string(),
prompt_template: PromptTemplate::MeanReversion,
system_prompt: "Updated prompt".to_string(),
analysis_frequency_minutes: 240,
trading_pairs: vec!["ETH-PERP".to_string(), "SOL-PERP".to_string()],
max_position_size_usd: 50_000.0,
enabled: true,
trading_mode: TradingMode::Live,
composer_profile: None,
max_retries: 3,
max_tool_turns: 5,
};
save_agent_config_to_disk(&config2).unwrap();
let loaded = load_agent_config(agent_id).unwrap();
assert_eq!(loaded.prompt_template, PromptTemplate::MeanReversion);
assert_eq!(loaded.system_prompt, "Updated prompt");
assert_eq!(loaded.analysis_frequency_minutes, 240);
assert_eq!(loaded.trading_pairs.len(), 2);
assert_eq!(loaded.max_position_size_usd, 50_000.0);
assert!(loaded.enabled);
let _ = fs::remove_file(agent_config_path(agent_id));
}
#[test]
fn test_validate_config_valid() {
let config = AgentConfig::default();
assert!(validate_agent_config(&config).is_ok());
}
#[test]
fn test_validate_config_empty_agent_id() {
let config = AgentConfig {
agent_id: String::new(),
..AgentConfig::default()
};
let result = validate_agent_config(&config);
assert!(result.is_err());
assert!(result.unwrap_err().contains("agent_id"));
}
#[test]
fn test_validate_config_zero_frequency() {
let config = AgentConfig {
agent_id: "valid-id".to_string(),
analysis_frequency_minutes: 0,
..AgentConfig::default()
};
let result = validate_agent_config(&config);
assert!(result.is_err());
assert!(result.unwrap_err().contains("frequency"));
}
#[test]
fn test_validate_config_negative_position_size() {
let config = AgentConfig {
agent_id: "valid-id".to_string(),
max_position_size_usd: -100.0,
..AgentConfig::default()
};
let result = validate_agent_config(&config);
assert!(result.is_err());
assert!(result.unwrap_err().contains("negative"));
}
#[test]
fn test_validate_config_custom_empty_prompt() {
let config = AgentConfig {
agent_id: "valid-id".to_string(),
prompt_template: PromptTemplate::Custom,
system_prompt: String::new(),
analysis_frequency_minutes: 60,
trading_pairs: vec![],
max_position_size_usd: 1_000.0,
enabled: false,
trading_mode: TradingMode::Paper,
composer_profile: None,
max_retries: 3,
max_tool_turns: 5,
};
let result = validate_agent_config(&config);
assert!(result.is_err());
assert!(result.unwrap_err().contains("system_prompt"));
}
#[test]
fn test_agent_config_default_max_retries() {
let config = AgentConfig::default();
assert_eq!(config.max_retries, 3);
}
#[test]
fn test_agent_config_max_retries_serialization() {
let config = AgentConfig {
max_retries: 5,
..AgentConfig::default()
};
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["maxRetries"], 5);
}
#[test]
fn test_agent_config_max_retries_deserialization_present() {
let json = serde_json::json!({
"agentId": "retry-agent",
"promptTemplate": "conservative",
"systemPrompt": "test",
"analysisFrequencyMinutes": 60,
"tradingPairs": ["BTC-PERP"],
"maxPositionSizeUsd": 10000.0,
"enabled": false,
"maxRetries": 7
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.max_retries, 7);
}
#[test]
fn test_agent_config_max_retries_deserialization_missing_defaults_to_3() {
let json = serde_json::json!({
"agentId": "no-retry-field",
"promptTemplate": "conservative",
"systemPrompt": "test",
"analysisFrequencyMinutes": 60,
"tradingPairs": ["BTC-PERP"],
"maxPositionSizeUsd": 10000.0,
"enabled": false
});
let config: AgentConfig = serde_json::from_value(json).unwrap();
assert_eq!(config.max_retries, 3);
}
#[test]
fn test_agent_config_max_retries_zero_disables_retry() {
let config = AgentConfig {
max_retries: 0,
..AgentConfig::default()
};
assert_eq!(config.max_retries, 0);
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["maxRetries"], 0);
}
}