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 let pop = v
85 .min_pop_pct
86 .map(|p| format!("POP≥{p:.0}%"))
87 .unwrap_or_else(|| "POP—".into());
88 let be = v
89 .min_distance_to_be_pct
90 .map(|p| format!("BE cushion≥{p:.0}%"))
91 .unwrap_or_else(|| "BE—".into());
92 let ctw = v
93 .min_credit_to_width_pct
94 .map(|p| format!("cr/width≥{p:.0}%"))
95 .unwrap_or_else(|| "cr/width≥12.5%".into());
96 entry_lines.push(format!(" filters: {pop} · {be} · {ctw}"));
97 }
98 if rules.strategies.iron_condor.enabled {
99 let ic = &rules.entry_rules.iron_condor;
100 entry_lines.push(" iron condor".into());
101 entry_lines.push(format!(
102 " DTE {}–{} · credit ≥ ${:.2} · wing ${:.0} · δ {:.2}",
103 ic.dte_min, ic.dte_max, ic.min_credit, ic.wing_width, ic.short_delta
104 ));
105 }
106 if entry_lines.is_empty() {
107 entry_lines.push(" (no strategies enabled)".into());
108 }
109 out.push_str(&panel_fit("Entry", &entry_lines, max_w));
110 out.push_str("\n\n");
111
112 let ex = &rules.exit_rules;
113 let exit_lines = vec![
114 kv_line("profit target", &format!("{}%", ex.profit_target_pct), 16),
115 kv_line("stop loss", &format!("{}% of credit", ex.stop_loss_pct), 16),
116 kv_line("close at DTE", &format!("≤{}", ex.dte_close), 16),
117 ];
118 out.push_str(&panel_fit("Exit (mechanical)", &exit_lines, max_w));
119 out.push_str("\n\n");
120
121 let risk = &rules.risk;
122 let risk_used = ctx.portfolio_risk_usd();
123 let risk_ratio = risk_used / risk.max_portfolio_risk_usd.max(1.0);
124 let risk_lines = vec![
125 format!(
126 " {:16} ${:.0} / ${:.0} {}",
127 "portfolio risk",
128 risk_used,
129 risk.max_portfolio_risk_usd,
130 bar(risk_ratio, 8)
131 ),
132 kv_line(
133 "per trade max",
134 &format!("${:.0}", risk.max_risk_per_trade_usd),
135 16,
136 ),
137 kv_line("trades / day", &risk.max_trades_per_day.to_string(), 16),
138 kv_line(
139 "allowed",
140 if risk.allowed_underlyings.is_empty() {
141 "(watchlist)".into()
142 } else {
143 risk.allowed_underlyings.join(", ")
144 }
145 .as_str(),
146 16,
147 ),
148 ];
149 out.push_str(&panel_fit("Risk", &risk_lines, max_w));
150 out.push_str("\n\n");
151
152 let llm = &rules.llm;
153 let llm_lines = if llm.enabled {
154 vec![
155 kv_line("selection", llm.effective_selection_model(), 16),
156 kv_line("monitor", llm.effective_monitor_model(), 16),
157 kv_line("web", &llm.web_model, 16),
158 kv_line(
159 "monitor every",
160 &format!(
161 "{} ticks (~{}m){}",
162 llm.effective_monitor_review_ticks(
163 None,
164 rules.exit_rules.dte_close,
165 ),
166 ctx.monitor_interval_minutes(),
167 llm.monitor_review_every_ticks
168 .map(|n| format!(" (slow cadence {n}t above {} DTE)", rules.exit_rules.dte_close))
169 .unwrap_or_default()
170 ),
171 16,
172 ),
173 kv_line(
174 "web research",
175 &format!("every {} reviews", llm.web_research_every_reviews),
176 16,
177 ),
178 kv_line(
179 "veto / exits",
180 &format!("{} / {}", llm.veto_entries, llm.allow_llm_exits),
181 16,
182 ),
183 ]
184 } else {
185 vec![" disabled".into()]
186 };
187 out.push_str(&panel_fit("LLM", &llm_lines, max_w));
188 out.push_str("\n\n");
189
190 let exec = &rules.execution;
191 let exec_lines = vec![
192 kv_line("order type", &exec.order_type, 16),
193 kv_line("require preview", &exec.require_preview.to_string(), 16),
194 kv_line("wait for fill", &exec.wait_for_fill.to_string(), 16),
195 kv_line(
196 "fill timeout",
197 &format!("{}s", exec.fill_timeout_seconds),
198 16,
199 ),
200 ];
201 out.push_str(&panel_fit("Execution", &exec_lines, max_w));
202 out.push('\n');
203 out
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use std::path::PathBuf;
210
211 #[test]
212 fn rules_detail_renders_sections() {
213 let path =
214 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rules/options-pilot-8709.yaml");
215 if !path.exists() {
216 return;
217 }
218 let ctx = DashboardContext::load(&path).unwrap();
219 let out = render_rules_detail(&ctx);
220 assert!(out.contains("Schedule"));
221 assert!(out.contains("Risk"));
222 assert!(out.contains("LLM"));
223 }
224
225 #[test]
226 fn rules_panel_borders_align() {
227 let path =
228 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../rules/options-pilot-8709.yaml");
229 if !path.exists() {
230 return;
231 }
232 let ctx = DashboardContext::load(&path).unwrap();
233 let out = render_rules_detail(&ctx);
234 for block in out.split("\n\n") {
235 if !block.contains('╭') {
236 continue;
237 }
238 let mut border_width = None;
239 for line in block.lines() {
240 let plain = console::strip_ansi_codes(line);
241 if plain.starts_with('╭') || plain.starts_with('╰') {
242 border_width = Some(plain.chars().count());
243 }
244 if plain.starts_with('│') {
245 let w = border_width.expect("border width");
246 assert_eq!(
247 plain.chars().count(),
248 w,
249 "panel line width mismatch: {plain}"
250 );
251 }
252 }
253 }
254 }
255}