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