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, Default, 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
303pub fn default_selection_prompt() -> &'static str {
304    "You are an expert options income trader specializing in defined-risk credit spreads \
305     and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
306     rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
307     and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
308     favorable within the strategy context provided."
309}
310
311pub fn default_selection_web_prompt() -> &'static str {
312    "You are an expert options income trader evaluating whether to OPEN new spreads. \
313     Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
314     and macro risk via web context. Be conservative: defer or skip if event or volatility \
315     risk outweighs the premium collected."
316}
317
318pub fn default_monitor_prompt() -> &'static str {
319    "You are monitoring existing open option spreads. Mechanical exits (profit target, \
320     stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
321     Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
322     current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
323     CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
324     Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
325     in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
326     stop_triggered is false, the position has NOT hit the mechanical stop.\n\
327     Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
328     Use market_context for recommendations:\n\
329     - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
330     short_otm_pct > 3% for put credits)\n\
331     - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
332     or developing macro/event risk\n\
333     - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
334     urgency high only for imminent assignment/gap risk through short strike\n\
335     If market_context is missing but market_context_error is set, rely on mechanical_rules \
336     and recommend hold unless mechanical_rules indicate a triggered exit.\n\
337     For new_entries during monitor phase: recommend proceed only when candidate_entries is \
338     non-empty; otherwise use skip with brief reasoning.\n\
339     Do not recommend close for routine profit — mechanics handle 50% target."
340}
341
342pub fn default_overnight_prompt() -> &'static str {
343    "The US options market is CLOSED. Research overnight and pre-market news (futures, \
344     macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
345     Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
346     whether any open spread thesis is broken, and suggested actions at the open \
347     (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
348     For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
349     thesis-breaking developments."
350}
351
352impl LlmPromptsConfig {
353    pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
354        if use_web {
355            if !self.selection_web.is_empty() {
356                return &self.selection_web;
357            }
358            if !self.selection.is_empty() {
359                return &self.selection;
360            }
361            return default_selection_web_prompt();
362        }
363        if !self.selection.is_empty() {
364            return &self.selection;
365        }
366        default_selection_prompt()
367    }
368
369    pub fn effective_monitor_instructions(&self) -> &str {
370        if !self.monitor.is_empty() {
371            return &self.monitor;
372        }
373        default_monitor_prompt()
374    }
375
376    pub fn effective_overnight_instructions(&self) -> &str {
377        if !self.overnight.is_empty() {
378            return &self.overnight;
379        }
380        default_overnight_prompt()
381    }
382
383    pub fn effective_context(&self, phase: LlmPhase) -> &str {
384        match phase {
385            LlmPhase::Selection => &self.selection_context,
386            LlmPhase::Monitor => &self.monitor_context,
387            LlmPhase::OvernightDigest => &self.overnight_context,
388        }
389    }
390}
391
392impl Default for LlmConfig {
393    fn default() -> Self {
394        Self {
395            enabled: false,
396            selection_model: "anthropic/claude-sonnet-4".into(),
397            monitor_model: "google/gemini-2.5-flash".into(),
398            web_model: "perplexity/sonar".into(),
399            model: String::new(),
400            review_every_ticks: 5,
401            web_research_every_reviews: 3,
402            max_tokens: 2000,
403            veto_entries: true,
404            allow_llm_exits: false,
405            prompts: LlmPromptsConfig::default(),
406        }
407    }
408}
409
410impl LlmConfig {
411    pub fn effective_selection_model(&self) -> &str {
412        if !self.selection_model.is_empty() {
413            &self.selection_model
414        } else if !self.model.is_empty() {
415            &self.model
416        } else {
417            "anthropic/claude-sonnet-4"
418        }
419    }
420
421    pub fn effective_monitor_model(&self) -> &str {
422        if !self.monitor_model.is_empty() {
423            &self.monitor_model
424        } else if !self.model.is_empty() {
425            &self.model
426        } else {
427            "google/gemini-2.5-flash"
428        }
429    }
430
431    /// Resolve which OpenRouter model to call for this phase.
432    pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
433        if use_web {
434            return &self.web_model;
435        }
436        match phase {
437            LlmPhase::Selection => self.effective_selection_model(),
438            LlmPhase::Monitor => self.effective_monitor_model(),
439            LlmPhase::OvernightDigest => &self.web_model,
440        }
441    }
442}
443
444#[derive(Debug, Clone, Default, Serialize, Deserialize)]
445#[serde(default)]
446pub struct NotifyConfig {
447    pub telegram: TelegramNotifyConfig,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(default)]
452pub struct TelegramNotifyConfig {
453    pub enabled: bool,
454    /// Notify on every tick summary (can be noisy).
455    pub notify_every_tick: bool,
456    /// Always notify on entries, exits, and LLM alerts.
457    pub notify_on_actions: bool,
458}
459
460impl Default for TelegramNotifyConfig {
461    fn default() -> Self {
462        Self {
463            enabled: false,
464            notify_every_tick: false,
465            notify_on_actions: true,
466        }
467    }
468}
469
470impl RulesConfig {
471    pub fn load(path: &Path) -> Result<Self> {
472        let content = fs::read_to_string(path)
473            .with_context(|| format!("read rules file {}", path.display()))?;
474        let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
475            serde_json::from_str(&content)?
476        } else {
477            serde_yaml::from_str(&content)?
478        };
479        rules.validate()?;
480        Ok(rules)
481    }
482
483    pub fn validate(&self) -> Result<()> {
484        if self.version != RULES_VERSION {
485            anyhow::bail!(
486                "unsupported rules version {} (expected {})",
487                self.version,
488                RULES_VERSION
489            );
490        }
491        if self.agent_id.trim().is_empty() {
492            anyhow::bail!("agent_id is required");
493        }
494        if self.accounts.is_empty() {
495            anyhow::bail!("at least one account is required");
496        }
497        for acct in &self.accounts {
498            if acct.hash.trim().is_empty() {
499                anyhow::bail!("account hash is required");
500            }
501        }
502        if self.watchlist.is_empty() {
503            anyhow::bail!("watchlist must not be empty");
504        }
505        if !self.schedule.market_hours_only {
506            anyhow::bail!("options agent requires schedule.market_hours_only=true");
507        }
508        if !self.execution.order_type.eq_ignore_ascii_case("limit") {
509            anyhow::bail!("options agent requires execution.order_type=limit");
510        }
511        if !self.execution.require_preview {
512            anyhow::bail!("options agent requires execution.require_preview=true");
513        }
514        Ok(())
515    }
516
517    pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
518        self.accounts.iter().filter(|a| a.enabled)
519    }
520}
521
522pub fn rules_json_schema() -> Value {
523    json!({
524        "$schema": "https://json-schema.org/draft/2020-12/schema",
525        "title": "Schwab options agent rules",
526        "type": "object",
527        "required": ["version", "agent_id", "accounts", "watchlist"],
528        "properties": {
529            "version": { "const": 1 },
530            "agent_id": { "type": "string" },
531            "accounts": {
532                "type": "array",
533                "items": {
534                    "type": "object",
535                    "required": ["hash"],
536                    "properties": {
537                        "hash": { "type": "string" },
538                        "label": { "type": "string" },
539                        "type": { "enum": ["margin", "ira", "cash"] },
540                        "enabled": { "type": "boolean" }
541                    }
542                }
543            },
544            "schedule": {
545                "type": "object",
546                "properties": {
547                    "tick_interval_seconds": { "type": "integer", "minimum": 5 },
548                    "market_hours_only": { "type": "boolean" },
549                    "timezone": { "type": "string" },
550                    "overnight": {
551                        "type": "object",
552                        "properties": {
553                            "enabled": { "type": "boolean" },
554                            "tick_interval_seconds": { "type": "integer", "minimum": 300 },
555                            "web_digest": { "type": "boolean" },
556                            "skip_llm_when_flat": { "type": "boolean" },
557                            "alert_on_risk_only": { "type": "boolean" }
558                        }
559                    }
560                }
561            },
562            "strategies": {
563                "type": "object",
564                "properties": {
565                    "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
566                    "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
567                }
568            },
569            "watchlist": { "type": "array", "items": { "type": "string" } },
570            "entry_rules": { "type": "object" },
571            "exit_rules": { "type": "object" },
572            "risk": { "type": "object" },
573            "execution": { "type": "object" }
574        }
575    })
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn validates_minimal_rules() {
584        let rules = RulesConfig {
585            version: 1,
586            agent_id: "test".into(),
587            accounts: vec![RulesAccount {
588                hash: "ABC".into(),
589                label: None,
590                r#type: AccountType::Margin,
591                enabled: true,
592            }],
593            schedule: ScheduleConfig::default(),
594            strategies: StrategiesToggle::default(),
595            watchlist: vec!["SPY".into()],
596            entry_rules: EntryRules::default(),
597            exit_rules: ExitRules::default(),
598            risk: RiskConfig::default(),
599            execution: ExecutionConfig::default(),
600            llm: LlmConfig::default(),
601            notify: NotifyConfig::default(),
602        };
603        rules.validate().unwrap();
604    }
605
606    #[test]
607    fn loads_example_rules_yaml() {
608        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
609            .join("../../rules/options-rules.example.yaml");
610        if path.exists() {
611            let rules = RulesConfig::load(&path).unwrap();
612            assert_eq!(rules.agent_id, "spy-income-v1");
613        }
614    }
615
616    #[test]
617    fn llm_config_resolves_phase_models() {
618        let cfg = LlmConfig::default();
619        assert_eq!(
620            cfg.resolve_model(LlmPhase::Selection, false),
621            "anthropic/claude-sonnet-4"
622        );
623        assert_eq!(
624            cfg.resolve_model(LlmPhase::Monitor, false),
625            "google/gemini-2.5-flash"
626        );
627        assert_eq!(
628            cfg.resolve_model(LlmPhase::Selection, true),
629            "perplexity/sonar"
630        );
631    }
632
633    #[test]
634    fn custom_selection_prompt_overrides_default() {
635        let prompts = LlmPromptsConfig {
636            selection: "Aggressive premium seller.".into(),
637            ..Default::default()
638        };
639        assert!(prompts
640            .effective_selection_instructions(false)
641            .contains("Aggressive"));
642    }
643
644    #[test]
645    fn selection_web_prompt_used_when_set() {
646        let prompts = LlmPromptsConfig {
647            selection: "conservative".into(),
648            selection_web: "web aggressive".into(),
649            ..Default::default()
650        };
651        assert_eq!(
652            prompts.effective_selection_instructions(true),
653            "web aggressive"
654        );
655    }
656}