Skip to main content

schwab_cli/agent/
format.rs

1use console::Style;
2use serde_json::Value;
3
4/// Human-readable body for agent tick / run-once envelopes (pretty output mode).
5pub fn format_tick_data(data: &Value) -> String {
6    let mut out = String::new();
7
8    let agent_id = str_field(data, "agent_id").unwrap_or("agent");
9    let dry_run = data
10        .get("dry_run")
11        .and_then(|v| v.as_bool())
12        .unwrap_or(false);
13
14    let header = Style::new().cyan().bold();
15    let _label = Style::new().dim();
16    let accent = Style::new().yellow();
17
18    out.push_str(&format!("{}", header.apply_to(agent_id)));
19    if dry_run {
20        out.push_str(&format!("  {}", accent.apply_to("[dry-run]")));
21    }
22    if let Some(session) = str_field(data, "session") {
23        let session_style = match session {
24            "regular" => Style::new().green(),
25            "overnight" => Style::new().magenta(),
26            "idle" => Style::new().dim(),
27            _ => Style::new().dim(),
28        };
29        out.push_str(&format!("  {}", session_style.apply_to(session)));
30    }
31    if data
32        .get("at_open")
33        .and_then(|v| v.as_bool())
34        .unwrap_or(false)
35    {
36        out.push_str(&format!("  {}", accent.apply_to("[at open]")));
37    }
38    out.push('\n');
39
40    let signals = data.get("signals").and_then(|v| v.as_array());
41    let actions = data.get("actions").and_then(|v| v.as_array());
42    let skipped = data.get("skipped").and_then(|v| v.as_array());
43    let monitored = data.get("monitored_positions").and_then(|v| v.as_array());
44
45    out.push_str(&format_monitored_positions(monitored));
46
47    out.push_str(&format_section("Signals", signals, format_signal));
48    out.push_str(&format_section("Actions", actions, format_action));
49    out.push_str(&format_skipped(skipped));
50
51    if let Some(llm) = data.get("llm_review") {
52        if !llm.is_null() {
53            out.push('\n');
54            out.push_str(&format_llm_review(llm));
55        }
56    }
57
58    out.trim_end().to_string()
59}
60
61fn format_monitored_positions(monitored: Option<&Vec<Value>>) -> String {
62    let Some(arr) = monitored else {
63        return String::new();
64    };
65    if arr.is_empty() {
66        return String::new();
67    }
68
69    let header = Style::new().bold();
70    let hold = Style::new().green();
71    let exit = Style::new().yellow();
72    let mut out = format!(
73        "\n{}\n",
74        header.apply_to(format!(
75            "Monitoring {} open position{}",
76            arr.len(),
77            if arr.len() == 1 { "" } else { "s" }
78        ))
79    );
80
81    for pos in arr {
82        out.push_str(&format!(
83            "  • {}\n",
84            format_monitored_position(pos, &hold, &exit)
85        ));
86    }
87    out
88}
89
90fn format_monitored_position(pos: &Value, hold: &Style, exit: &Style) -> String {
91    let underlying = str_field(pos, "underlying").unwrap_or("?");
92    let strategy = str_field(pos, "strategy").unwrap_or("spread");
93    let expiry = str_field(pos, "expiry").unwrap_or("?");
94    let status = str_field(pos, "status").unwrap_or("holding");
95
96    let credit = pos.get("entry_credit").and_then(|v| v.as_f64());
97    let credit_s = credit.map(|c| format!("  cr ${c:.2}")).unwrap_or_default();
98
99    let profit = pos.get("profit_pct").and_then(|v| v.as_f64());
100    let profit_s = profit
101        .map(|p| format!("  P/L {p:+.0}%"))
102        .unwrap_or_default();
103
104    let dte = pos
105        .get("dte")
106        .and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|u| u as i64)));
107    let dte_s = dte.map(|d| format!("  {d} DTE")).unwrap_or_default();
108
109    let greeks_s = pos
110        .get("market_context")
111        .map(format_monitor_greeks_suffix)
112        .or_else(|| {
113            pos.get("market_context_error")
114                .and_then(|v| v.as_str())
115                .map(|e| format!("  [no chain: {e}]"))
116        })
117        .unwrap_or_default();
118
119    let status_s = if status.starts_with("exit:") {
120        exit.apply_to(status).to_string()
121    } else {
122        hold.apply_to(status).to_string()
123    };
124
125    format!(
126        "{underlying} {strategy}  exp {expiry}{credit_s}{profit_s}{dte_s}{greeks_s}  — {status_s}"
127    )
128}
129
130fn format_monitor_greeks_suffix(ctx: &Value) -> String {
131    let mut parts = Vec::new();
132    if let Some(px) = ctx.get("underlying_price").and_then(|v| v.as_f64()) {
133        parts.push(format!("@${px:.2}"));
134    }
135    if let (Some(short), Some(long)) = (
136        ctx.get("short_strike").and_then(|v| v.as_f64()),
137        ctx.get("long_strike").and_then(|v| v.as_f64()),
138    ) {
139        parts.push(format!("${short:.0}/${long:.0}"));
140    }
141    if let Some(delta) = ctx.get("short_delta").and_then(|v| v.as_f64()) {
142        parts.push(format!("δ {delta:.2}"));
143    }
144    if let Some(otm) = ctx.get("short_otm_pct").and_then(|v| v.as_f64()) {
145        parts.push(format!("{otm:.1}% OTM"));
146    }
147    if let Some(pop) = ctx
148        .get("approx_short_otm_probability_pct")
149        .and_then(|v| v.as_f64())
150    {
151        parts.push(format!("~{pop:.0}% OTM prob"));
152    }
153    if parts.is_empty() {
154        String::new()
155    } else {
156        format!("  ({})", parts.join(", "))
157    }
158}
159
160pub fn format_status_data(data: &Value) -> String {
161    let mut out = String::new();
162    let label = Style::new().dim();
163
164    if let Some(path) = str_field(data, "state_path") {
165        out.push_str(&format!("  {} {}\n", label.apply_to("state file:"), path));
166    }
167
168    if let Some(state) = data.get("state") {
169        for (key, fmt) in [
170            ("agent_id", "agent"),
171            ("tick_count", "ticks"),
172            ("trades_today", "trades today"),
173            ("open_positions", "open positions"),
174            ("pending_orders", "pending orders"),
175            ("last_tick", "last tick"),
176            ("last_llm_summary", "last LLM"),
177        ] {
178            if let Some(v) = state.get(key) {
179                if key == "last_llm_summary" {
180                    if let Some(summary) = format_llm_summary_line(v) {
181                        out.push_str(&format!("  {} {}\n", label.apply_to(fmt), summary));
182                    }
183                } else {
184                    out.push_str(&format!("  {} {}\n", label.apply_to(fmt), json_scalar(v)));
185                }
186            }
187        }
188    }
189
190    out.trim_end().to_string()
191}
192
193pub fn format_validate_data(data: &Value) -> String {
194    let label = Style::new().dim();
195    let ok = Style::new().green();
196    let mut out = String::new();
197
198    out.push_str(&format!(
199        "  {} {}\n",
200        label.apply_to("valid:"),
201        ok.apply_to("yes")
202    ));
203    for (key, label_text) in [
204        ("agent_id", "agent id"),
205        ("accounts", "accounts"),
206        ("watchlist", "watchlist"),
207        ("llm_enabled", "LLM"),
208        ("telegram_enabled", "telegram"),
209    ] {
210        if let Some(v) = data.get(key) {
211            out.push_str(&format!(
212                "  {} {}\n",
213                label.apply_to(label_text),
214                json_scalar(v)
215            ));
216        }
217    }
218    out.trim_end().to_string()
219}
220
221pub fn format_background_data(data: &Value) -> String {
222    let label = Style::new().dim();
223    let mut out = String::new();
224    for (key, label_text) in [
225        ("pid", "pid"),
226        ("rules", "rules"),
227        ("pid_file", "pid file"),
228        ("log_file", "log file"),
229    ] {
230        if let Some(v) = data.get(key) {
231            out.push_str(&format!(
232                "  {} {}\n",
233                label.apply_to(label_text),
234                json_scalar(v)
235            ));
236        }
237    }
238    out.trim_end().to_string()
239}
240
241fn format_section<F>(title: &str, items: Option<&Vec<Value>>, formatter: F) -> String
242where
243    F: Fn(&Value) -> String,
244{
245    let Some(arr) = items else {
246        return String::new();
247    };
248    if arr.is_empty() {
249        return String::new();
250    }
251    let header = Style::new().bold();
252    let mut out = format!("\n{}\n", header.apply_to(title));
253    for item in arr {
254        out.push_str(&format!("  • {}\n", formatter(item)));
255    }
256    out
257}
258
259fn format_skipped(skipped: Option<&Vec<Value>>) -> String {
260    let Some(arr) = skipped else {
261        return String::new();
262    };
263    if arr.is_empty() {
264        return String::new();
265    }
266    let header = Style::new().bold();
267    let mut out = format!("\n{}\n", header.apply_to("Skipped"));
268    for item in arr {
269        if let Some(s) = item.as_str() {
270            out.push_str(&format!("  • {s}\n"));
271        }
272    }
273    out
274}
275
276fn format_signal(signal: &Value) -> String {
277    let kind = str_field(signal, "type").unwrap_or("signal");
278    match kind {
279        "entry" => format_entry_signal(signal),
280        "exit" => {
281            let underlying = str_field(signal, "underlying").unwrap_or("?");
282            let reason = str_field(signal, "reason").unwrap_or("exit");
283            let pos = str_field(signal, "position_id").unwrap_or("");
284            let mark = signal.get("mark").and_then(|v| v.as_f64());
285            let mark_s = mark.map(|m| format!("  mark ${m:.2}")).unwrap_or_default();
286            format!("EXIT  {underlying}  {reason}  {pos}{mark_s}")
287        }
288        _ => compact_json(signal),
289    }
290}
291
292fn format_entry_signal(signal: &Value) -> String {
293    let strategy = str_field(signal, "strategy").unwrap_or("spread");
294    let params = signal.get("params");
295    let underlying = params
296        .and_then(|p| str_field(p, "underlying"))
297        .or_else(|| str_field(signal, "underlying"))
298        .unwrap_or("?");
299    let expiry = params.and_then(|p| str_field(p, "expiry")).unwrap_or("");
300
301    let strikes = match strategy {
302        "vertical" => {
303            let spread_type = params
304                .and_then(|p| str_field(p, "type"))
305                .unwrap_or("spread");
306            let short = params
307                .and_then(|p| p.get("short_strike"))
308                .and_then(|v| v.as_f64());
309            let long = params
310                .and_then(|p| p.get("long_strike"))
311                .and_then(|v| v.as_f64());
312            match (short, long) {
313                (Some(s), Some(l)) => format!("{spread_type}  ${s:.0}/${l:.0}"),
314                _ => spread_type.to_string(),
315            }
316        }
317        "iron_condor" => {
318            let ps = params
319                .and_then(|p| p.get("put_short"))
320                .and_then(|v| v.as_f64());
321            let pl = params
322                .and_then(|p| p.get("put_long"))
323                .and_then(|v| v.as_f64());
324            let cs = params
325                .and_then(|p| p.get("call_short"))
326                .and_then(|v| v.as_f64());
327            let cl = params
328                .and_then(|p| p.get("call_long"))
329                .and_then(|v| v.as_f64());
330            match (ps, pl, cs, cl) {
331                (Some(ps), Some(pl), Some(cs), Some(cl)) => {
332                    format!("iron condor  puts ${ps:.0}/${pl:.0}  calls ${cs:.0}/${cl:.0}")
333                }
334                _ => "iron condor".to_string(),
335            }
336        }
337        _ => strategy.to_string(),
338    };
339
340    let credit = signal
341        .get("estimated_credit")
342        .or_else(|| params.and_then(|p| p.get("limit_credit")))
343        .and_then(|v| v.as_f64());
344    let credit_s = credit.map(|c| format!("  ~${c:.2} cr")).unwrap_or_default();
345
346    let ctx = signal.get("market_context");
347    let ctx_s = ctx.map(format_market_context_suffix).unwrap_or_default();
348
349    format!("ENTRY  {underlying}  {strikes}  exp {expiry}{credit_s}{ctx_s}")
350}
351
352fn format_market_context_suffix(ctx: &Value) -> String {
353    let mut parts = Vec::new();
354    if let Some(dte) = ctx.get("dte").and_then(|v| v.as_u64()) {
355        parts.push(format!("{dte} DTE"));
356    }
357    if let Some(delta) = ctx.get("short_delta").and_then(|v| v.as_f64()) {
358        parts.push(format!("δ {delta:.2}"));
359    }
360    if let Some(pct) = ctx.get("credit_to_width_pct").and_then(|v| v.as_f64()) {
361        parts.push(format!("{pct:.0}% cr/width"));
362    }
363    if let Some(loss) = ctx.get("max_loss_total_usd").and_then(|v| v.as_f64()) {
364        parts.push(format!("max loss ${loss:.0}"));
365    }
366    if parts.is_empty() {
367        String::new()
368    } else {
369        format!("  ({})", parts.join(", "))
370    }
371}
372
373fn format_action(action: &Value) -> String {
374    if let Some(fill) = str_field(action, "fill_status") {
375        return format_order_action(action, fill);
376    }
377    if action.get("exit").is_some() {
378        let underlying = action
379            .pointer("/signal/underlying")
380            .and_then(|v| v.as_str())
381            .unwrap_or("?");
382        let reason = action
383            .pointer("/signal/reason")
384            .and_then(|v| v.as_str())
385            .unwrap_or("exit");
386        return format!("EXIT filled  {underlying}  ({reason})");
387    }
388    if let Some(kind) = str_field(action, "kind") {
389        return format!("{kind}  {}", compact_json(action));
390    }
391    compact_json(action)
392}
393
394fn format_order_action(action: &Value, fill_status: &str) -> String {
395    let underlying = action
396        .pointer("/signal/params/underlying")
397        .or_else(|| action.pointer("/signal/underlying"))
398        .and_then(|v| v.as_str())
399        .unwrap_or("?");
400    let strategy = action
401        .pointer("/signal/strategy")
402        .and_then(|v| v.as_str())
403        .unwrap_or("spread");
404
405    let order_id = action
406        .pointer("/entry/orderId")
407        .or_else(|| action.pointer("/place/orderId"))
408        .and_then(|v| v.as_u64())
409        .map(|id| format!("  order #{id}"))
410        .unwrap_or_default();
411
412    let status = match fill_status {
413        "FILLED" => Style::new().green().apply_to("FILLED").to_string(),
414        "REJECTED" | "CANCELED" | "EXPIRED" => Style::new().red().apply_to(fill_status).to_string(),
415        _ => Style::new().yellow().apply_to(fill_status).to_string(),
416    };
417
418    let note = action
419        .get("note")
420        .and_then(|v| v.as_str())
421        .map(|n| format!("  — {n}"))
422        .unwrap_or_default();
423
424    format!("ENTRY {status}  {underlying} {strategy}{order_id}{note}")
425}
426
427fn format_llm_review(llm: &Value) -> String {
428    let header = Style::new().bold();
429    let label = Style::new().dim();
430    let phase = str_field(llm, "phase").unwrap_or("review");
431    let phase_label = match phase {
432        "overnight_digest" => "overnight digest",
433        other => other,
434    };
435    let model = str_field(llm, "model").unwrap_or("model");
436    let web = llm
437        .get("used_web")
438        .and_then(|v| v.as_bool())
439        .unwrap_or(false);
440    let web_tag = if web { " + web" } else { "" };
441
442    let mut out = format!(
443        "{}\n",
444        header.apply_to(format!("LLM {phase_label} ({model}{web_tag})"))
445    );
446
447    if let Some(rec) = llm
448        .pointer("/new_entries/recommendation")
449        .and_then(|v| v.as_str())
450    {
451        let rec_style = match rec {
452            "proceed" => Style::new().green(),
453            "defer" | "skip" | "hold" => Style::new().yellow(),
454            _ => Style::new().cyan(),
455        };
456        out.push_str(&format!(
457            "  {} {}\n",
458            label.apply_to("entries:"),
459            rec_style.apply_to(rec)
460        ));
461    }
462
463    if let Some(reason) = llm
464        .pointer("/new_entries/reasoning")
465        .and_then(|v| v.as_str())
466    {
467        if !reason.is_empty() {
468            out.push_str(&format!("  {}\n", wrap_text(reason, 76, "  ")));
469        }
470    }
471
472    if let Some(commentary) = llm.get("market_commentary").and_then(|v| v.as_str()) {
473        if !commentary.is_empty() {
474            out.push_str(&format!("  {}\n", label.apply_to("market:")));
475            out.push_str(&format!("  {}\n", wrap_text(commentary, 76, "  ")));
476        }
477    }
478
479    if let Some(alerts) = llm.get("risk_alerts").and_then(|v| v.as_array()) {
480        if !alerts.is_empty() {
481            out.push_str(&format!("  {}\n", label.apply_to("alerts:")));
482            for alert in alerts {
483                if let Some(s) = alert.as_str() {
484                    out.push_str(&format!("    • {s}\n"));
485                }
486            }
487        }
488    }
489
490    if let Some(positions) = llm.get("positions").and_then(|v| v.as_array()) {
491        if !positions.is_empty() {
492            out.push_str(&format!("  {}\n", label.apply_to("positions:")));
493            for pos in positions {
494                let id = pos
495                    .get("position_id")
496                    .and_then(|v| v.as_str())
497                    .unwrap_or("?");
498                let rec = pos
499                    .get("recommendation")
500                    .and_then(|v| v.as_str())
501                    .unwrap_or("hold");
502                out.push_str(&format!("    • {id}: {rec}\n"));
503            }
504        }
505    }
506
507    out.trim_end().to_string()
508}
509
510fn format_llm_summary_line(v: &Value) -> Option<String> {
511    if v.is_null() {
512        return None;
513    }
514    let phase = str_field(v, "phase").unwrap_or("?");
515    let rec = v
516        .pointer("/new_entries/recommendation")
517        .and_then(|r| r.as_str())
518        .unwrap_or("—");
519    Some(format!("{phase} → entries {rec}"))
520}
521
522fn wrap_text(text: &str, width: usize, indent: &str) -> String {
523    let mut lines = Vec::new();
524    let mut current = String::new();
525    for word in text.split_whitespace() {
526        if current.is_empty() {
527            current = word.to_string();
528        } else if current.len() + 1 + word.len() <= width {
529            current.push(' ');
530            current.push_str(word);
531        } else {
532            lines.push(current);
533            current = word.to_string();
534        }
535    }
536    if !current.is_empty() {
537        lines.push(current);
538    }
539    lines
540        .into_iter()
541        .map(|l| format!("{indent}{l}"))
542        .collect::<Vec<_>>()
543        .join("\n")
544}
545
546fn str_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
547    value.get(key).and_then(|v| v.as_str())
548}
549
550fn json_scalar(v: &Value) -> String {
551    match v {
552        Value::String(s) => s.clone(),
553        Value::Number(n) => n.to_string(),
554        Value::Bool(b) => b.to_string(),
555        Value::Array(a) => a
556            .iter()
557            .filter_map(|i| i.as_str())
558            .collect::<Vec<_>>()
559            .join(", "),
560        _ => v.to_string(),
561    }
562}
563
564fn compact_json(v: &Value) -> String {
565    serde_json::to_string(v).unwrap_or_else(|_| "{}".into())
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use serde_json::json;
572
573    #[test]
574    fn format_skipped_only_tick() {
575        let data = json!({
576            "agent_id": "options-example",
577            "dry_run": true,
578            "signals": [],
579            "actions": [],
580            "monitored_positions": [{
581                "underlying": "IWM",
582                "strategy": "vertical",
583                "expiry": "2026-07-31",
584                "entry_credit": 0.25,
585                "profit_pct": 8.0,
586                "dte": 36,
587                "status": "holding"
588            }],
589            "skipped": ["new entries paused — max_trades_per_day (1) reached"],
590            "llm_review": null
591        });
592        let out = format_tick_data(&data);
593        assert!(out.contains("options-example"));
594        assert!(out.contains("Monitoring 1 open position"));
595        assert!(out.contains("IWM"));
596        assert!(out.contains("max_trades_per_day"));
597        assert!(!out.contains("\"signals\""));
598    }
599
600    #[test]
601    fn format_entry_signal_with_context() {
602        let data = json!({
603            "agent_id": "options-example",
604            "dry_run": false,
605            "monitored_positions": [],
606            "signals": [{
607                "type": "entry",
608                "strategy": "vertical",
609                "params": {
610                    "underlying": "IWM",
611                    "expiry": "2025-07-18",
612                    "type": "put_credit",
613                    "short_strike": 282.0,
614                    "long_strike": 280.0
615                },
616                "estimated_credit": 0.26,
617                "market_context": {
618                    "dte": 36,
619                    "short_delta": -0.18,
620                    "credit_to_width_pct": 13.0,
621                    "max_loss_total_usd": 174.0
622                }
623            }],
624            "actions": [],
625            "skipped": [],
626            "llm_review": {
627                "phase": "selection",
628                "model": "anthropic/claude-sonnet-4",
629                "used_web": false,
630                "new_entries": { "recommendation": "proceed", "reasoning": "Premium acceptable." },
631                "market_commentary": "",
632                "risk_alerts": []
633            }
634        });
635        let out = format_tick_data(&data);
636        assert!(out.contains("ENTRY  IWM"));
637        assert!(out.contains("put_credit"));
638        assert!(out.contains("LLM selection"));
639        assert!(out.contains("proceed"));
640    }
641}