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
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}