1use console::Style;
2use serde_json::Value;
3
4pub 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}