1use std::fs;
2use std::io::{stdout, Write};
3use std::path::PathBuf;
4
5use anyhow::{Context, Result};
6use serde_json::json;
7
8use crate::agent::{
9 analysis_report, compute_stats, load_agent_state, load_sim_agent_state, load_state, log_path,
10 pid_path, reset_sim, run_agent_loop, save_state, sim_journal_path, sim_state_path,
11 spawn_background, state_summary, stop_daemon,
12};
13use crate::cli::{AgentBacktestCommands, AgentCommands, AgentSimCommands};
14use crate::config::RuntimeConfig;
15use crate::flatten::{build_agent_options_flatten_plan, execute_agent_options_flatten};
16use crate::output::{OutputFormat, ResponseEnvelope};
17use crate::rules::{rules_json_schema, RulesConfig};
18use crate::ui::context::DashboardContext;
19use crate::ui::dashboard::render_dashboard;
20use crate::ui::discover::resolve_rules_file;
21
22use crate::agent::backtest::{
23 build_backtest_report, prefetch_daily_bars, run_backtest, BacktestRunOptions,
24};
25use crate::agent::backtest::prefetch::{default_from_to, parse_ymd};
26use crate::agent::paths::backtest_cache_path;
27
28pub async fn run(runtime: &RuntimeConfig, command: AgentCommands) -> Result<()> {
29 match command {
30 AgentCommands::Schema => {
31 runtime.emit(ResponseEnvelope::ok("agent schema", rules_json_schema()));
32 }
33 AgentCommands::Validate { file } => {
34 let rules = RulesConfig::load(&file)?;
35 runtime.emit(ResponseEnvelope::ok(
36 "agent validate",
37 json!({
38 "valid": true,
39 "agent_id": rules.agent_id,
40 "accounts": rules.accounts.len(),
41 "watchlist": rules.watchlist_items(),
42 "llm_enabled": rules.llm.enabled,
43 "telegram_enabled": rules.notify.telegram.enabled,
44 "simulation": rules.simulation,
45 }),
46 ));
47 }
48 AgentCommands::Status { rules_file } => {
49 if runtime.output == OutputFormat::Json {
50 if let Some(rules_path) = rules_file {
51 let ctx = DashboardContext::load(&rules_path)?;
52 runtime.emit(ResponseEnvelope::ok("agent status", ctx.to_json()));
53 } else {
54 emit_legacy_status(runtime)?;
55 }
56 } else if let Some(rules_path) = rules_file {
57 let ctx = DashboardContext::load(&rules_path)?;
58 print!("{}", render_dashboard(&ctx));
59 stdout().flush().ok();
60 } else if let Ok(rules_path) = resolve_rules_file(None, runtime.is_interactive()) {
61 let ctx = DashboardContext::load(&rules_path)?;
62 print!("{}", render_dashboard(&ctx));
63 stdout().flush().ok();
64 } else {
65 emit_legacy_status(runtime)?;
66 }
67 }
68 AgentCommands::Run {
69 file,
70 once,
71 background,
72 } => {
73 if background {
74 if once {
75 anyhow::bail!("--background cannot be combined with --once");
76 }
77 let mut extra = Vec::new();
78 if runtime.dry_run {
79 extra.push("--dry-run".into());
80 }
81 if runtime.simulate {
82 extra.push("--simulate".into());
83 }
84 if runtime.trust {
85 extra.push("--trust".into());
86 }
87 if runtime.yes {
88 extra.push("--yes".into());
89 }
90 if runtime.output == OutputFormat::Json {
91 extra.push("--json".into());
92 }
93 let pid = spawn_background(&file, &extra)?;
94 runtime.emit(ResponseEnvelope::ok(
95 "agent background",
96 json!({
97 "pid": pid,
98 "pid_file": pid_path(&file),
99 "log_file": log_path(&file),
100 "rules": file,
101 "simulate": runtime.simulate,
102 }),
103 ));
104 return Ok(());
105 }
106 run_agent_loop(runtime, &file, once, None).await?;
107 }
108 AgentCommands::Stop { file } => {
109 stop_daemon(&file)?;
110 runtime.emit(ResponseEnvelope::ok(
111 "agent stop",
112 json!({ "stopped": true, "rules": file }),
113 ));
114 }
115 AgentCommands::CloseAll { file } => {
116 let rules = RulesConfig::load(&file)?;
117 let state = if runtime.simulate {
118 load_sim_agent_state(&file, &rules.agent_id)
119 } else {
120 load_agent_state(&file, &rules.agent_id)
121 };
122 let api = runtime.build_api()?;
123 let plan = build_agent_options_flatten_plan(&api, &rules, &state).await?;
124 let data = execute_agent_options_flatten(runtime, &api, &file, &rules, &plan).await?;
125 runtime.emit(ResponseEnvelope::ok("agent close-all", data));
126 }
127 AgentCommands::Sim { command } => run_sim(runtime, command).await?,
128 AgentCommands::Backtest { command } => run_backtest_cmd(runtime, command).await?,
129 }
130 Ok(())
131}
132
133async fn run_backtest_cmd(runtime: &RuntimeConfig, command: AgentBacktestCommands) -> Result<()> {
134 match command {
135 AgentBacktestCommands::Prefetch {
136 rules_file,
137 from,
138 to,
139 force,
140 } => {
141 let rules = RulesConfig::load(&rules_file)?;
142 let from_d = from.as_deref().map(parse_ymd).transpose()?;
143 let to_d = to.as_deref().map(parse_ymd).transpose()?;
144 let (from, to) = default_from_to(from_d, to_d)?;
145 let market = runtime.build_market_api()?;
146 let cache =
147 prefetch_daily_bars(&market, &rules, &rules_file, from, to, force).await?;
148 runtime.emit(ResponseEnvelope::ok(
149 "agent backtest prefetch",
150 json!({
151 "cache_path": backtest_cache_path(&rules_file),
152 "from": from.to_string(),
153 "to": to.to_string(),
154 "symbols": cache.symbols.keys().cloned().collect::<Vec<_>>(),
155 "bars": cache.symbols.iter().map(|(k,v)| json!({k: v.len()})).collect::<Vec<_>>(),
156 }),
157 ));
158 }
159 AgentBacktestCommands::Run {
160 rules_file,
161 from,
162 to,
163 fresh,
164 } => {
165 let rules = RulesConfig::load(&rules_file)?;
166 let from_d = from.as_deref().map(parse_ymd).transpose()?;
167 let to_d = to.as_deref().map(parse_ymd).transpose()?;
168 let (from, to) = default_from_to(from_d, to_d)?;
169 let result = run_backtest(
170 &rules_file,
171 &rules,
172 BacktestRunOptions { from, to, fresh },
173 )?;
174 runtime.emit(ResponseEnvelope::ok("agent backtest run", result));
175 }
176 AgentBacktestCommands::Report { rules_file, output } => {
177 let rules = RulesConfig::load(&rules_file)?;
178 let report = build_backtest_report(&rules_file, &rules)?;
179 if let Some(path) = output {
180 if let Some(parent) = path.parent() {
181 fs::create_dir_all(parent)?;
182 }
183 fs::write(&path, serde_json::to_string_pretty(&report)?)?;
184 }
185 runtime.emit(ResponseEnvelope::ok("agent backtest report", report));
186 }
187 }
188 Ok(())
189}
190
191async fn run_sim(runtime: &RuntimeConfig, command: AgentSimCommands) -> Result<()> {
192 match command {
193 AgentSimCommands::Stats { file } => {
194 let rules = RulesConfig::load(&file)?;
195 let mut state = load_sim_agent_state(&file, &rules.agent_id);
196 state.agent_id = rules.agent_id.clone();
197 let stats = compute_stats(&state, &rules);
198 runtime.emit(ResponseEnvelope::ok(
199 "agent sim stats",
200 json!({
201 "rules": file,
202 "state_path": sim_state_path(&file),
203 "stats": stats,
204 }),
205 ));
206 }
207 AgentSimCommands::Report { file, output } => {
208 let rules = RulesConfig::load(&file)?;
209 let mut state = load_sim_agent_state(&file, &rules.agent_id);
210 state.agent_id = rules.agent_id.clone();
211 let report = analysis_report(&state, &rules);
212 if let Some(path) = output {
213 let text = serde_json::to_string_pretty(&report)?;
214 fs::write(&path, text).with_context(|| format!("write report {}", path.display()))?;
215 runtime.emit(ResponseEnvelope::ok(
216 "agent sim report",
217 json!({ "rules": file, "output": path }),
218 ));
219 } else {
220 runtime.emit(ResponseEnvelope::ok("agent sim report", report));
221 }
222 }
223 AgentSimCommands::Reset { file } => {
224 if !runtime.yes {
225 anyhow::bail!("sim reset requires --yes (live agent-state is not modified)");
226 }
227 let rules = RulesConfig::load(&file)?;
228 let path = sim_state_path(&file);
229 let mut state = load_sim_agent_state(&file, &rules.agent_id);
230 state.agent_id = rules.agent_id.clone();
231 reset_sim(&mut state, &rules);
232 save_state(&path, &state)?;
233 let journal = sim_journal_path(&file);
234 if journal.exists() {
235 fs::remove_file(&journal)
236 .with_context(|| format!("remove journal {}", journal.display()))?;
237 }
238 runtime.emit(ResponseEnvelope::ok(
239 "agent sim reset",
240 json!({
241 "rules": file,
242 "state_path": path,
243 "starting_budget_usd": state
244 .sim
245 .as_ref()
246 .map(|s| s.starting_budget_usd),
247 }),
248 ));
249 }
250 }
251 Ok(())
252}
253
254fn emit_legacy_status(runtime: &RuntimeConfig) -> Result<()> {
255 let path = directories::ProjectDirs::from("com", "schwabinvestbot", "schwab")
256 .map(|d| d.data_local_dir().join("agent-state.json"))
257 .unwrap_or_else(|| PathBuf::from("agent-state.json"));
258 let state = load_state(&path)?;
259 runtime.emit(ResponseEnvelope::ok(
260 "agent status",
261 json!({ "state_path": path, "state": state_summary(&state) }),
262 ));
263 Ok(())
264}