Skip to main content

schwab_cli/ui/
rules_view.rs

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}