Skip to main content

schwab_cli/commands/
agent.rs

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}