Skip to main content

schwab_cli/
rules.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7
8pub const RULES_VERSION: u32 = 1;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RulesConfig {
12    pub version: u32,
13    pub agent_id: String,
14    #[serde(default)]
15    pub accounts: Vec<RulesAccount>,
16    #[serde(default)]
17    pub schedule: ScheduleConfig,
18    #[serde(default)]
19    pub strategies: StrategiesToggle,
20    #[serde(default)]
21    pub watchlist: Vec<String>,
22    #[serde(default)]
23    pub entry_rules: EntryRules,
24    #[serde(default)]
25    pub exit_rules: ExitRules,
26    #[serde(default)]
27    pub risk: RiskConfig,
28    #[serde(default)]
29    pub execution: ExecutionConfig,
30    #[serde(default)]
31    pub llm: LlmConfig,
32    #[serde(default)]
33    pub notify: NotifyConfig,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RulesAccount {
38    pub hash: String,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub label: Option<String>,
41    #[serde(default)]
42    pub r#type: AccountType,
43    #[serde(default = "default_true")]
44    pub enabled: bool,
45}
46
47fn default_true() -> bool {
48    true
49}
50
51#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "lowercase")]
53pub enum AccountType {
54    #[default]
55    Margin,
56    Ira,
57    Cash,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct ScheduleConfig {
63    pub tick_interval_seconds: u64,
64    pub market_hours_only: bool,
65    pub timezone: String,
66    #[serde(default)]
67    pub overnight: OvernightConfig,
68}
69
70/// Low-frequency overnight / pre-market behavior when the option market is closed.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(default)]
73pub struct OvernightConfig {
74    /// When true, the agent keeps running after the close with a slower tick.
75    pub enabled: bool,
76    /// Seconds between overnight wakes (default 1 hour). LLM digest respects this interval.
77    pub tick_interval_seconds: u64,
78    /// Run web-model digest to build an open playbook (no chain calls, no entries).
79    pub web_digest: bool,
80    /// Skip overnight LLM when flat (no open positions).
81    pub skip_llm_when_flat: bool,
82    /// Telegram only when risk_alerts is non-empty (digest still saved to state).
83    pub alert_on_risk_only: bool,
84}
85
86impl Default for OvernightConfig {
87    fn default() -> Self {
88        Self {
89            enabled: false,
90            tick_interval_seconds: 3600,
91            web_digest: true,
92            skip_llm_when_flat: true,
93            alert_on_risk_only: true,
94        }
95    }
96}
97
98impl Default for ScheduleConfig {
99    fn default() -> Self {
100        Self {
101            tick_interval_seconds: 60,
102            market_hours_only: true,
103            timezone: "America/New_York".into(),
104            overnight: OvernightConfig::default(),
105        }
106    }
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct StrategiesToggle {
111    #[serde(default)]
112    pub vertical: StrategyEnabled,
113    #[serde(default)]
114    pub iron_condor: StrategyEnabled,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct StrategyEnabled {
119    #[serde(default)]
120    pub enabled: bool,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct EntryRules {
125    #[serde(default)]
126    pub vertical: VerticalEntryRules,
127    #[serde(default)]
128    pub iron_condor: IronCondorEntryRules,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct VerticalEntryRules {
134    pub r#type: String,
135    pub dte_min: u32,
136    pub dte_max: u32,
137    pub min_credit: f64,
138    pub max_width: f64,
139    pub short_delta_min: f64,
140    pub short_delta_max: f64,
141    pub max_open_positions: u32,
142    pub max_contracts_per_trade: u32,
143}
144
145impl Default for VerticalEntryRules {
146    fn default() -> Self {
147        Self {
148            r#type: "put_credit".into(),
149            dte_min: 30,
150            dte_max: 45,
151            min_credit: 0.50,
152            max_width: 5.0,
153            short_delta_min: 0.15,
154            short_delta_max: 0.30,
155            max_open_positions: 3,
156            max_contracts_per_trade: 2,
157        }
158    }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(default)]
163pub struct IronCondorEntryRules {
164    pub dte_min: u32,
165    pub dte_max: u32,
166    pub min_credit: f64,
167    pub wing_width: f64,
168    pub short_delta: f64,
169    pub max_open_positions: u32,
170    pub max_contracts_per_trade: u32,
171}
172
173impl Default for IronCondorEntryRules {
174    fn default() -> Self {
175        Self {
176            dte_min: 30,
177            dte_max: 45,
178            min_credit: 1.00,
179            wing_width: 5.0,
180            short_delta: 0.16,
181            max_open_positions: 2,
182            max_contracts_per_trade: 1,
183        }
184    }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(default)]
189pub struct ExitRules {
190    pub profit_target_pct: f64,
191    pub stop_loss_pct: f64,
192    pub dte_close: u32,
193}
194
195impl Default for ExitRules {
196    fn default() -> Self {
197        Self {
198            profit_target_pct: 50.0,
199            stop_loss_pct: 200.0,
200            dte_close: 21,
201        }
202    }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(default)]
207pub struct RiskConfig {
208    pub max_portfolio_risk_usd: f64,
209    pub max_risk_per_trade_usd: f64,
210    pub max_trades_per_day: u32,
211    pub allowed_underlyings: Vec<String>,
212    pub blocked_events: Vec<String>,
213}
214
215impl Default for RiskConfig {
216    fn default() -> Self {
217        Self {
218            max_portfolio_risk_usd: 10_000.0,
219            max_risk_per_trade_usd: 2_000.0,
220            max_trades_per_day: 3,
221            allowed_underlyings: vec!["SPY".into(), "QQQ".into(), "IWM".into()],
222            blocked_events: vec![],
223        }
224    }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(default)]
229pub struct ExecutionConfig {
230    pub order_type: String,
231    pub require_preview: bool,
232    pub wait_for_fill: bool,
233    pub fill_timeout_seconds: u64,
234}
235
236impl Default for ExecutionConfig {
237    fn default() -> Self {
238        Self {
239            order_type: "limit".into(),
240            require_preview: true,
241            wait_for_fill: true,
242            fill_timeout_seconds: 300,
243        }
244    }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "lowercase")]
249pub enum LlmPhase {
250    Selection,
251    Monitor,
252    OvernightDigest,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(default)]
257pub struct LlmConfig {
258    /// Enable OpenRouter LLM reviews during agent ticks.
259    pub enabled: bool,
260    /// High-intelligence model for entry veto when rules produce candidate trades.
261    pub selection_model: String,
262    /// Cost-efficient model for periodic open-position reviews.
263    pub monitor_model: String,
264    /// Model with web search for macro/event context (selection phase, periodic).
265    pub web_model: String,
266    /// Legacy fallback if selection_model / monitor_model are empty in old configs.
267    #[serde(default, skip_serializing_if = "String::is_empty")]
268    pub model: String,
269    /// Run monitor-phase LLM every N agent ticks (selection always runs when signaled).
270    pub review_every_ticks: u64,
271    /// Use web_model every N selection/monitor LLM reviews (when applicable).
272    pub web_research_every_reviews: u64,
273    pub max_tokens: u32,
274    /// When true, LLM can veto new entries when it recommends defer/skip.
275    pub veto_entries: bool,
276    /// When true, high-urgency LLM close recommendations trigger exits.
277    pub allow_llm_exits: bool,
278    /// Per-phase role, instructions, and strategy context (configurable per rules file).
279    #[serde(default)]
280    pub prompts: LlmPromptsConfig,
281}
282
283/// Configurable LLM instructions per agent strategy. Empty fields use built-in defaults.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(default)]
286pub struct LlmPromptsConfig {
287    /// System instructions for entry/selection phase (role, risk posture, what to optimize).
288    pub selection: String,
289    /// Optional override when web_model is used for selection; falls back to `selection`.
290    pub selection_web: String,
291    /// Extra strategy context prepended to the user message during selection.
292    pub selection_context: String,
293    /// System instructions for open-position monitoring phase.
294    pub monitor: String,
295    /// Extra strategy context prepended to the user message during monitoring.
296    pub monitor_context: String,
297    /// System instructions for overnight web digest (market closed).
298    pub overnight: String,
299    /// Extra context for overnight digest user message.
300    pub overnight_context: String,
301}
302
303impl Default for LlmPromptsConfig {
304    fn default() -> Self {
305        Self {
306            selection: String::new(),
307            selection_web: String::new(),
308            selection_context: String::new(),
309            monitor: String::new(),
310            monitor_context: String::new(),
311            overnight: String::new(),
312            overnight_context: String::new(),
313        }
314    }
315}
316
317pub fn default_selection_prompt() -> &'static str {
318    "You are an expert options income trader specializing in defined-risk credit spreads \
319     and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
320     rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
321     and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
322     favorable within the strategy context provided."
323}
324
325pub fn default_selection_web_prompt() -> &'static str {
326    "You are an expert options income trader evaluating whether to OPEN new spreads. \
327     Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
328     and macro risk via web context. Be conservative: defer or skip if event or volatility \
329     risk outweighs the premium collected."
330}
331
332pub fn default_monitor_prompt() -> &'static str {
333    "You are monitoring existing open option spreads. Mechanical exits (profit target, \
334     stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
335     Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
336     current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
337     CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
338     Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
339     in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
340     stop_triggered is false, the position has NOT hit the mechanical stop.\n\
341     Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
342     Use market_context for recommendations:\n\
343     - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
344     short_otm_pct > 3% for put credits)\n\
345     - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
346     or developing macro/event risk\n\
347     - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
348     urgency high only for imminent assignment/gap risk through short strike\n\
349     Do not recommend close for routine profit — mechanics handle 50% target."
350}
351
352pub fn default_overnight_prompt() -> &'static str {
353    "The US options market is CLOSED. Research overnight and pre-market news (futures, \
354     macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
355     Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
356     whether any open spread thesis is broken, and suggested actions at the open \
357     (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
358     For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
359     thesis-breaking developments."
360}
361
362impl LlmPromptsConfig {
363    pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
364        if use_web {
365            if !self.selection_web.is_empty() {
366                return &self.selection_web;
367            }
368            if !self.selection.is_empty() {
369                return &self.selection;
370            }
371            return default_selection_web_prompt();
372        }
373        if !self.selection.is_empty() {
374            return &self.selection;
375        }
376        default_selection_prompt()
377    }
378
379    pub fn effective_monitor_instructions(&self) -> &str {
380        if !self.monitor.is_empty() {
381            return &self.monitor;
382        }
383        default_monitor_prompt()
384    }
385
386    pub fn effective_overnight_instructions(&self) -> &str {
387        if !self.overnight.is_empty() {
388            return &self.overnight;
389        }
390        default_overnight_prompt()
391    }
392
393    pub fn effective_context(&self, phase: LlmPhase) -> &str {
394        match phase {
395            LlmPhase::Selection => &self.selection_context,
396            LlmPhase::Monitor => &self.monitor_context,
397            LlmPhase::OvernightDigest => &self.overnight_context,
398        }
399    }
400}
401
402impl Default for LlmConfig {
403    fn default() -> Self {
404        Self {
405            enabled: false,
406            selection_model: "anthropic/claude-sonnet-4".into(),
407            monitor_model: "google/gemini-2.5-flash".into(),
408            web_model: "perplexity/sonar".into(),
409            model: String::new(),
410            review_every_ticks: 5,
411            web_research_every_reviews: 3,
412            max_tokens: 2000,
413            veto_entries: true,
414            allow_llm_exits: false,
415            prompts: LlmPromptsConfig::default(),
416        }
417    }
418}
419
420impl LlmConfig {
421    pub fn effective_selection_model(&self) -> &str {
422        if !self.selection_model.is_empty() {
423            &self.selection_model
424        } else if !self.model.is_empty() {
425            &self.model
426        } else {
427            "anthropic/claude-sonnet-4"
428        }
429    }
430
431    pub fn effective_monitor_model(&self) -> &str {
432        if !self.monitor_model.is_empty() {
433            &self.monitor_model
434        } else if !self.model.is_empty() {
435            &self.model
436        } else {
437            "google/gemini-2.5-flash"
438        }
439    }
440
441    /// Resolve which OpenRouter model to call for this phase.
442    pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
443        if use_web {
444            return &self.web_model;
445        }
446        match phase {
447            LlmPhase::Selection => self.effective_selection_model(),
448            LlmPhase::Monitor => self.effective_monitor_model(),
449            LlmPhase::OvernightDigest => &self.web_model,
450        }
451    }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(default)]
456pub struct NotifyConfig {
457    pub telegram: TelegramNotifyConfig,
458}
459
460impl Default for NotifyConfig {
461    fn default() -> Self {
462        Self {
463            telegram: TelegramNotifyConfig::default(),
464        }
465    }
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
469#[serde(default)]
470pub struct TelegramNotifyConfig {
471    pub enabled: bool,
472    /// Notify on every tick summary (can be noisy).
473    pub notify_every_tick: bool,
474    /// Always notify on entries, exits, and LLM alerts.
475    pub notify_on_actions: bool,
476}
477
478impl Default for TelegramNotifyConfig {
479    fn default() -> Self {
480        Self {
481            enabled: false,
482            notify_every_tick: false,
483            notify_on_actions: true,
484        }
485    }
486}
487
488impl RulesConfig {
489    pub fn load(path: &Path) -> Result<Self> {
490        let content = fs::read_to_string(path)
491            .with_context(|| format!("read rules file {}", path.display()))?;
492        let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
493            serde_json::from_str(&content)?
494        } else {
495            serde_yaml::from_str(&content)?
496        };
497        rules.validate()?;
498        Ok(rules)
499    }
500
501    pub fn validate(&self) -> Result<()> {
502        if self.version != RULES_VERSION {
503            anyhow::bail!(
504                "unsupported rules version {} (expected {})",
505                self.version,
506                RULES_VERSION
507            );
508        }
509        if self.agent_id.trim().is_empty() {
510            anyhow::bail!("agent_id is required");
511        }
512        if self.accounts.is_empty() {
513            anyhow::bail!("at least one account is required");
514        }
515        for acct in &self.accounts {
516            if acct.hash.trim().is_empty() {
517                anyhow::bail!("account hash is required");
518            }
519        }
520        if self.watchlist.is_empty() {
521            anyhow::bail!("watchlist must not be empty");
522        }
523        Ok(())
524    }
525
526    pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
527        self.accounts.iter().filter(|a| a.enabled)
528    }
529}
530
531pub fn rules_json_schema() -> Value {
532    json!({
533        "$schema": "https://json-schema.org/draft/2020-12/schema",
534        "title": "Schwab options agent rules",
535        "type": "object",
536        "required": ["version", "agent_id", "accounts", "watchlist"],
537        "properties": {
538            "version": { "const": 1 },
539            "agent_id": { "type": "string" },
540            "accounts": {
541                "type": "array",
542                "items": {
543                    "type": "object",
544                    "required": ["hash"],
545                    "properties": {
546                        "hash": { "type": "string" },
547                        "label": { "type": "string" },
548                        "type": { "enum": ["margin", "ira", "cash"] },
549                        "enabled": { "type": "boolean" }
550                    }
551                }
552            },
553            "schedule": {
554                "type": "object",
555                "properties": {
556                    "tick_interval_seconds": { "type": "integer", "minimum": 5 },
557                    "market_hours_only": { "type": "boolean" },
558                    "timezone": { "type": "string" },
559                    "overnight": {
560                        "type": "object",
561                        "properties": {
562                            "enabled": { "type": "boolean" },
563                            "tick_interval_seconds": { "type": "integer", "minimum": 300 },
564                            "web_digest": { "type": "boolean" },
565                            "skip_llm_when_flat": { "type": "boolean" },
566                            "alert_on_risk_only": { "type": "boolean" }
567                        }
568                    }
569                }
570            },
571            "strategies": {
572                "type": "object",
573                "properties": {
574                    "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
575                    "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
576                }
577            },
578            "watchlist": { "type": "array", "items": { "type": "string" } },
579            "entry_rules": { "type": "object" },
580            "exit_rules": { "type": "object" },
581            "risk": { "type": "object" },
582            "execution": { "type": "object" }
583        }
584    })
585}
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590
591    #[test]
592    fn validates_minimal_rules() {
593        let rules = RulesConfig {
594            version: 1,
595            agent_id: "test".into(),
596            accounts: vec![RulesAccount {
597                hash: "ABC".into(),
598                label: None,
599                r#type: AccountType::Margin,
600                enabled: true,
601            }],
602            schedule: ScheduleConfig::default(),
603            strategies: StrategiesToggle::default(),
604            watchlist: vec!["SPY".into()],
605            entry_rules: EntryRules::default(),
606            exit_rules: ExitRules::default(),
607            risk: RiskConfig::default(),
608            execution: ExecutionConfig::default(),
609            llm: LlmConfig::default(),
610            notify: NotifyConfig::default(),
611        };
612        rules.validate().unwrap();
613    }
614
615    #[test]
616    fn loads_example_rules_yaml() {
617        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
618            .join("../../rules/options-rules.example.yaml");
619        if path.exists() {
620            let rules = RulesConfig::load(&path).unwrap();
621            assert_eq!(rules.agent_id, "spy-income-v1");
622        }
623    }
624
625    #[test]
626    fn llm_config_resolves_phase_models() {
627        let cfg = LlmConfig::default();
628        assert_eq!(
629            cfg.resolve_model(LlmPhase::Selection, false),
630            "anthropic/claude-sonnet-4"
631        );
632        assert_eq!(
633            cfg.resolve_model(LlmPhase::Monitor, false),
634            "google/gemini-2.5-flash"
635        );
636        assert_eq!(
637            cfg.resolve_model(LlmPhase::Selection, true),
638            "perplexity/sonar"
639        );
640    }
641
642    #[test]
643    fn custom_selection_prompt_overrides_default() {
644        let mut prompts = LlmPromptsConfig::default();
645        prompts.selection = "Aggressive premium seller.".into();
646        assert!(prompts.effective_selection_instructions(false).contains("Aggressive"));
647    }
648
649    #[test]
650    fn selection_web_prompt_used_when_set() {
651        let prompts = LlmPromptsConfig {
652            selection: "conservative".into(),
653            selection_web: "web aggressive".into(),
654            ..Default::default()
655        };
656        assert_eq!(
657            prompts.effective_selection_instructions(true),
658            "web aggressive"
659        );
660    }
661}