1use chrono::{DateTime, Utc};
2use console::Style;
3
4use super::context::DashboardContext;
5use super::{
6 ago_secs, bar, clock_dot, format_duration_secs, panel, panel_fit, rule, status_dot,
7 terminal_width, two_column,
8};
9
10pub fn render_dashboard(ctx: &DashboardContext) -> String {
11 let width = terminal_width().min(120);
12 let use_side_by_side = width >= 100;
13 let col_inner = if use_side_by_side {
14 (width / 2).saturating_sub(4)
15 } else {
16 width.saturating_sub(4)
17 };
18 let full_max = width;
19
20 let now = chrono::Local::now().format("%a %d %b %Y %H:%M");
21 let mut out = String::new();
22 out.push('\n');
23 out.push_str(&rule(&format!("✦ Schwab Agent · {now}")));
24 out.push_str("\n\n");
25
26 let agent_panel = render_agent_panel(ctx, col_inner);
27 let rules_panel = render_rules_panel(ctx, col_inner);
28 if use_side_by_side {
29 out.push_str(&two_column(agent_panel, rules_panel, width));
30 } else {
31 out.push_str(&agent_panel);
32 out.push('\n');
33 out.push_str(&rules_panel);
34 }
35 out.push_str("\n\n");
36
37 let positions = render_positions_panel(ctx, full_max);
38 if !positions.is_empty() {
39 out.push_str(&positions);
40 out.push_str("\n\n");
41 }
42
43 let activity = render_activity_panel(ctx, full_max);
44 if !activity.is_empty() {
45 out.push_str(&activity);
46 out.push_str("\n\n");
47 }
48
49 let log_panel = render_log_panel(ctx, full_max);
50 if !log_panel.is_empty() {
51 out.push_str(&log_panel);
52 out.push_str("\n\n");
53 }
54
55 if !ctx.daemon.running {
56 let rules_display = ctx
57 .rules_path
58 .file_name()
59 .map(|n| format!("rules/{}", n.to_string_lossy()))
60 .unwrap_or_else(|| ctx.rules.agent_id.clone());
61 out.push_str(&format!(
62 " {} Agent daemon is not running — {} {}\n\n",
63 Style::new().yellow().apply_to("!"),
64 Style::new().dim().apply_to("start with"),
65 Style::new().cyan().apply_to(format!(
66 "schwab agent run {rules_display} --background --trust --yes"
67 ))
68 ));
69 }
70
71 out.push_str(&render_footer_hint());
72 out.push('\n');
73 out
74}
75
76fn render_agent_panel(ctx: &DashboardContext, inner: usize) -> String {
77 let dim = Style::new().dim();
78 let green = Style::new().green();
79 let mut lines = Vec::new();
80
81 if ctx.daemon.running {
82 let pid = ctx
83 .daemon
84 .pid
85 .map(|p| p.to_string())
86 .unwrap_or_else(|| "?".into());
87 lines.push(format!(
88 " {} {:<12} {} pid {}",
89 status_dot(true),
90 "daemon",
91 green.apply_to("running"),
92 dim.apply_to(pid)
93 ));
94 } else {
95 lines.push(format!(
96 " {} {:<12} {}",
97 status_dot(false),
98 "daemon",
99 Style::new().red().apply_to("stopped")
100 ));
101 }
102
103 if let Some(at) = ctx.state.last_tick {
104 let secs = (Utc::now() - at).num_seconds();
105 let session = ctx.state.last_session.as_deref().unwrap_or("?");
106 lines.push(format!(
107 " {} {:<12} last tick {} {}",
108 clock_dot(),
109 "agent",
110 dim.apply_to(ago_secs(secs)),
111 session_style(session).apply_to(session)
112 ));
113 } else {
114 lines.push(format!(
115 " {} {:<12} {}",
116 clock_dot(),
117 "agent",
118 dim.apply_to("no ticks yet")
119 ));
120 }
121
122 let spreads = ctx.state.open_positions.len();
123 let contracts = ctx.state.total_contracts();
124 let pending = ctx.state.pending_count();
125 let trades = ctx.state.trades_today;
126 let max_trades = ctx.rules.risk.max_trades_per_day;
127 let pos_label = if contracts > spreads as u32 {
128 format!("{spreads} spread · {contracts} ct")
129 } else if spreads > 0 {
130 format!("{spreads} spread")
131 } else {
132 "flat".into()
133 };
134 lines.push(format!(
135 " {pos_label} · {pending} pending · {trades}/{max_trades} trades today",
136 ));
137
138 if ctx.rules.llm.enabled {
139 let phase = ctx
140 .state
141 .last_llm_summary
142 .as_ref()
143 .and_then(|v| v.get("phase"))
144 .and_then(|v| v.as_str())
145 .unwrap_or("—");
146 lines.push(format!(
147 " LLM {} · {} reviews",
148 dim.apply_to(phase),
149 ctx.state.llm_review_count
150 ));
151 }
152
153 panel("Agent", &lines, inner)
154}
155
156fn render_rules_panel(ctx: &DashboardContext, inner: usize) -> String {
157 let dim = Style::new().dim();
158 let rules = &ctx.rules;
159 let mut lines = Vec::new();
160
161 lines.push(format!(
162 " {}",
163 Style::new().cyan().bold().apply_to(&rules.agent_id)
164 ));
165 lines.push(format!(
166 " tick {} · monitor every {}m",
167 format_duration_secs(rules.schedule.tick_interval_seconds),
168 ctx.monitor_interval_minutes()
169 ));
170
171 let watch = rules.watchlist.join(", ");
172 lines.push(format!(" watchlist {}", dim.apply_to(&watch)));
173
174 let mut strategies = Vec::new();
175 if rules.strategies.vertical.enabled {
176 let v = &rules.entry_rules.vertical;
177 strategies.push(format!(
178 "vertical {} {}–{} DTE",
179 v.r#type, v.dte_min, v.dte_max
180 ));
181 }
182 if rules.strategies.iron_condor.enabled {
183 let ic = &rules.entry_rules.iron_condor;
184 strategies.push(format!("iron condor {}–{} DTE", ic.dte_min, ic.dte_max));
185 }
186 if strategies.is_empty() {
187 lines.push(format!(" strategies {}", dim.apply_to("(none enabled)")));
188 } else {
189 for s in strategies {
190 lines.push(format!(" {s}"));
191 }
192 }
193
194 let risk_used = ctx.portfolio_risk_usd();
195 let risk_max = rules.risk.max_portfolio_risk_usd.max(1.0);
196 let risk_ratio = risk_used / risk_max;
197 lines.push(format!(
198 " risk ${:.0}/${:.0} {}",
199 risk_used,
200 risk_max,
201 bar(risk_ratio, 8)
202 ));
203
204 if rules.llm.enabled {
205 lines.push(format!(
206 " LLM {} / {}",
207 short_model(rules.llm.effective_selection_model()),
208 short_model(rules.llm.effective_monitor_model())
209 ));
210 } else {
211 lines.push(format!(" LLM {}", dim.apply_to("disabled")));
212 }
213
214 if rules.schedule.overnight.enabled {
215 lines.push(format!(
216 " overnight every {} {}",
217 format_duration_secs(rules.schedule.overnight.tick_interval_seconds),
218 if rules.schedule.overnight.web_digest {
219 "web digest"
220 } else {
221 "no digest"
222 }
223 ));
224 }
225
226 panel("Rules", &lines, inner)
227}
228
229fn render_positions_panel(ctx: &DashboardContext, max_width: usize) -> String {
230 if ctx.state.open_positions.is_empty() {
231 return String::new();
232 }
233
234 let hold = Style::new().green();
235 let mut lines = Vec::new();
236 for pos in ctx.state.open_positions.values() {
237 let contracts = if pos.contracts > 1 {
238 format!(" ×{}", pos.contracts)
239 } else {
240 String::new()
241 };
242 let credit = pos
243 .entry_credit
244 .map(|c| format!(" cr ${c:.2}"))
245 .unwrap_or_default();
246 let opened = ago_from_dt(pos.opened_at);
247 lines.push(format!(
248 " {}{} {} exp {} max loss ${:.0}{} — {} {}",
249 pos.underlying,
250 contracts,
251 pos.strategy,
252 pos.expiry,
253 pos.max_loss_usd,
254 credit,
255 hold.apply_to("holding"),
256 Style::new().dim().apply_to(opened)
257 ));
258 }
259 let contracts = ctx.state.total_contracts();
260 let title = if contracts > ctx.state.open_positions.len() as u32 {
261 format!(
262 "Positions ({} spread · {} ct)",
263 ctx.state.open_positions.len(),
264 contracts
265 )
266 } else {
267 format!("Positions ({})", ctx.state.open_positions.len())
268 };
269 panel_fit(&title, &lines, max_width)
270}
271
272fn render_activity_panel(ctx: &DashboardContext, max_width: usize) -> String {
273 let actions: Vec<_> = ctx.state.last_actions.iter().rev().take(8).collect();
274 if actions.is_empty() {
275 return String::new();
276 }
277
278 let dim = Style::new().dim();
279 let mut lines = Vec::new();
280 for act in actions {
281 let time = act.at.format("%H:%M").to_string();
282 let detail = format_action_detail(&act.action, &act.detail);
283 lines.push(format!(
284 " {} {:<16} {}",
285 dim.apply_to(time),
286 Style::new().bold().apply_to(&act.action),
287 detail
288 ));
289 }
290 panel_fit("Recent Activity", &lines, max_width)
291}
292
293fn render_log_panel(ctx: &DashboardContext, max_width: usize) -> String {
294 if ctx.log_tail.is_empty() {
295 return String::new();
296 }
297 let dim = Style::new().dim();
298 let lines: Vec<String> = ctx
299 .log_tail
300 .iter()
301 .map(|l| {
302 let max_content = max_width.saturating_sub(6);
303 let trimmed = if strip_log_len(l) > max_content {
304 format!("{}…", truncate_visible(l, max_content.saturating_sub(1)))
305 } else {
306 l.clone()
307 };
308 format!(" {}", dim.apply_to(trimmed))
309 })
310 .collect();
311 panel_fit("Agent Log (tail)", &lines, max_width)
312}
313
314fn strip_log_len(s: &str) -> usize {
315 console::strip_ansi_codes(s).len()
316}
317
318fn truncate_visible(s: &str, max: usize) -> String {
319 s.chars().take(max).collect()
320}
321
322fn render_footer_hint() -> String {
323 Style::new()
324 .dim()
325 .apply_to(
326 " schwab dashboard · watch · rules show · agent status · agent run · agent stop · help",
327 )
328 .to_string()
329}
330
331fn format_action_detail(action: &str, detail: &serde_json::Value) -> String {
332 match action {
333 "llm_review" => detail
334 .get("phase")
335 .and_then(|v| v.as_str())
336 .map(|p| {
337 let rec = detail
338 .pointer("/new_entries/recommendation")
339 .and_then(|v| v.as_str())
340 .unwrap_or("—");
341 format!("{p} → entries {rec}")
342 })
343 .unwrap_or_else(|| "review".into()),
344 "overnight_digest" => detail
345 .get("market_commentary")
346 .and_then(|v| v.as_str())
347 .map(|s| {
348 let t = s.chars().take(60).collect::<String>();
349 if s.len() > 60 {
350 format!("{t}…")
351 } else {
352 t
353 }
354 })
355 .unwrap_or_else(|| "digest".into()),
356 other => {
357 let s = detail.to_string();
358 if s.len() > 50 {
359 format!("{other} …")
360 } else {
361 s
362 }
363 }
364 }
365}
366
367fn session_style(session: &str) -> Style {
368 match session {
369 "regular" => Style::new().green(),
370 "overnight" => Style::new().magenta(),
371 "idle" => Style::new().dim(),
372 _ => Style::new().dim(),
373 }
374}
375
376fn short_model(model: &str) -> String {
377 model
378 .rsplit('/')
379 .next()
380 .unwrap_or(model)
381 .chars()
382 .take(20)
383 .collect()
384}
385
386fn ago_from_dt(dt: DateTime<Utc>) -> String {
387 ago_secs((Utc::now() - dt).num_seconds())
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use std::path::PathBuf;
394
395 #[test]
396 fn dashboard_renders_for_project_rules() {
397 let path =
398 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rules/options-rules.example.yaml");
399 if !path.exists() {
400 return;
401 }
402 let ctx = DashboardContext::load(&path).unwrap();
403 let out = render_dashboard(&ctx);
404 assert!(out.contains("Schwab Agent"));
405 assert!(out.contains(&ctx.rules.agent_id));
406 }
407}