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