1use console::Style;
2
3use super::context::DashboardContext;
4use super::{bar, kv_line, panel_fit, terminal_width};
5
6pub fn render_rules_detail(ctx: &DashboardContext) -> String {
7 let max_w = terminal_width().min(88);
8 let rules = &ctx.rules;
9 let dim = Style::new().dim();
10 let bold = Style::new().bold();
11
12 let mut out = String::new();
13 out.push('\n');
14 out.push_str(&format!(
15 "{}\n\n",
16 bold.apply_to(format!("Rules — {}", rules.agent_id))
17 ));
18 out.push_str(&format!(
19 " {} {}\n\n",
20 dim.apply_to("file:"),
21 ctx.rules_path.display()
22 ));
23
24 let acct_lines: Vec<String> = rules
25 .accounts
26 .iter()
27 .filter(|a| a.enabled)
28 .map(|a| {
29 let label = a.label.as_deref().unwrap_or("account");
30 let hash = &a.hash[..a.hash.len().min(8)];
31 format!(" {label} {hash}… {:?}", a.r#type)
32 })
33 .collect();
34 out.push_str(&panel_fit("Accounts", &acct_lines, max_w));
35 out.push_str("\n\n");
36
37 let sched = &rules.schedule;
38 let overnight_val = if sched.overnight.enabled {
39 format!(
40 "every {} · web digest {}",
41 super::format_duration_secs(sched.overnight.tick_interval_seconds),
42 if sched.overnight.web_digest {
43 "on"
44 } else {
45 "off"
46 }
47 )
48 } else {
49 "disabled".into()
50 };
51 let sched_lines = vec![
52 kv_line(
53 "tick interval",
54 &format!(
55 "{} ({})",
56 super::format_duration_secs(sched.tick_interval_seconds),
57 sched.tick_interval_seconds
58 ),
59 16,
60 ),
61 kv_line("timezone", &sched.timezone, 16),
62 kv_line(
63 "market hours",
64 if sched.market_hours_only { "yes" } else { "no" },
65 16,
66 ),
67 kv_line("overnight", &overnight_val, 16),
68 ];
69 out.push_str(&panel_fit("Schedule", &sched_lines, max_w));
70 out.push_str("\n\n");
71
72 let mut entry_lines = Vec::new();
73 if rules.strategies.vertical.enabled {
74 let v = &rules.entry_rules.vertical;
75 entry_lines.push(format!(" vertical ({})", v.r#type));
76 entry_lines.push(format!(
77 " DTE {}–{} · credit ≥ ${:.2} · width ${:.0}",
78 v.dte_min, v.dte_max, v.min_credit, v.max_width
79 ));
80 entry_lines.push(format!(
81 " delta {:.2}–{:.2} · max {} pos · {} contracts",
82 v.short_delta_min, v.short_delta_max, v.max_open_positions, v.max_contracts_per_trade
83 ));
84 }
85 if rules.strategies.iron_condor.enabled {
86 let ic = &rules.entry_rules.iron_condor;
87 entry_lines.push(" iron condor".into());
88 entry_lines.push(format!(
89 " DTE {}–{} · credit ≥ ${:.2} · wing ${:.0} · δ {:.2}",
90 ic.dte_min, ic.dte_max, ic.min_credit, ic.wing_width, ic.short_delta
91 ));
92 }
93 if entry_lines.is_empty() {
94 entry_lines.push(" (no strategies enabled)".into());
95 }
96 out.push_str(&panel_fit("Entry", &entry_lines, max_w));
97 out.push_str("\n\n");
98
99 let ex = &rules.exit_rules;
100 let exit_lines = vec![
101 kv_line("profit target", &format!("{}%", ex.profit_target_pct), 16),
102 kv_line("stop loss", &format!("{}% of credit", ex.stop_loss_pct), 16),
103 kv_line("close at DTE", &format!("≤{}", ex.dte_close), 16),
104 ];
105 out.push_str(&panel_fit("Exit (mechanical)", &exit_lines, max_w));
106 out.push_str("\n\n");
107
108 let risk = &rules.risk;
109 let risk_used = ctx.portfolio_risk_usd();
110 let risk_ratio = risk_used / risk.max_portfolio_risk_usd.max(1.0);
111 let risk_lines = vec![
112 format!(
113 " {:16} ${:.0} / ${:.0} {}",
114 "portfolio risk",
115 risk_used,
116 risk.max_portfolio_risk_usd,
117 bar(risk_ratio, 8)
118 ),
119 kv_line(
120 "per trade max",
121 &format!("${:.0}", risk.max_risk_per_trade_usd),
122 16,
123 ),
124 kv_line("trades / day", &risk.max_trades_per_day.to_string(), 16),
125 kv_line(
126 "allowed",
127 if risk.allowed_underlyings.is_empty() {
128 "(watchlist)".into()
129 } else {
130 risk.allowed_underlyings.join(", ")
131 }
132 .as_str(),
133 16,
134 ),
135 ];
136 out.push_str(&panel_fit("Risk", &risk_lines, max_w));
137 out.push_str("\n\n");
138
139 let llm = &rules.llm;
140 let llm_lines = if llm.enabled {
141 vec![
142 kv_line("selection", llm.effective_selection_model(), 16),
143 kv_line("monitor", llm.effective_monitor_model(), 16),
144 kv_line("web", &llm.web_model, 16),
145 kv_line(
146 "monitor every",
147 &format!(
148 "{} ticks (~{}m)",
149 llm.review_every_ticks,
150 ctx.monitor_interval_minutes()
151 ),
152 16,
153 ),
154 kv_line(
155 "web research",
156 &format!("every {} reviews", llm.web_research_every_reviews),
157 16,
158 ),
159 kv_line(
160 "veto / exits",
161 &format!("{} / {}", llm.veto_entries, llm.allow_llm_exits),
162 16,
163 ),
164 ]
165 } else {
166 vec![" disabled".into()]
167 };
168 out.push_str(&panel_fit("LLM", &llm_lines, max_w));
169 out.push_str("\n\n");
170
171 let exec = &rules.execution;
172 let exec_lines = vec![
173 kv_line("order type", &exec.order_type, 16),
174 kv_line("require preview", &exec.require_preview.to_string(), 16),
175 kv_line("wait for fill", &exec.wait_for_fill.to_string(), 16),
176 kv_line(
177 "fill timeout",
178 &format!("{}s", exec.fill_timeout_seconds),
179 16,
180 ),
181 ];
182 out.push_str(&panel_fit("Execution", &exec_lines, max_w));
183 out.push('\n');
184 out
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use std::path::PathBuf;
191
192 #[test]
193 fn rules_detail_renders_sections() {
194 let path =
195 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rules/options-pilot-8709.yaml");
196 if !path.exists() {
197 return;
198 }
199 let ctx = DashboardContext::load(&path).unwrap();
200 let out = render_rules_detail(&ctx);
201 assert!(out.contains("Schedule"));
202 assert!(out.contains("Risk"));
203 assert!(out.contains("LLM"));
204 }
205
206 #[test]
207 fn rules_panel_borders_align() {
208 let path =
209 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rules/options-pilot-8709.yaml");
210 if !path.exists() {
211 return;
212 }
213 let ctx = DashboardContext::load(&path).unwrap();
214 let out = render_rules_detail(&ctx);
215 for block in out.split("\n\n") {
216 if !block.contains('╭') {
217 continue;
218 }
219 let mut border_width = None;
220 for line in block.lines() {
221 let plain = console::strip_ansi_codes(line);
222 if plain.starts_with('╭') || plain.starts_with('╰') {
223 border_width = Some(plain.chars().count());
224 }
225 if plain.starts_with('│') {
226 let w = border_width.expect("border width");
227 assert_eq!(
228 plain.chars().count(),
229 w,
230 "panel line width mismatch: {plain}"
231 );
232 }
233 }
234 }
235 }
236}