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    /// Paper trading (--simulate): virtual budget separate from live agent state.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub simulation: Option<SimulationConfig>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SimulationConfig {
41    /// Virtual risk budget for paper P&L (defaults to risk.max_portfolio_risk_usd).
42    pub starting_budget_usd: f64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RulesAccount {
47    pub hash: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub label: Option<String>,
50    #[serde(default)]
51    pub r#type: AccountType,
52    #[serde(default = "default_true")]
53    pub enabled: bool,
54}
55
56fn default_true() -> bool {
57    true
58}
59
60#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "lowercase")]
62pub enum AccountType {
63    #[default]
64    Margin,
65    Ira,
66    Cash,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(default)]
71pub struct ScheduleConfig {
72    pub tick_interval_seconds: u64,
73    pub market_hours_only: bool,
74    pub timezone: String,
75    #[serde(default)]
76    pub overnight: OvernightConfig,
77}
78
79/// Low-frequency overnight / pre-market behavior when the option market is closed.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(default)]
82pub struct OvernightConfig {
83    /// When true, the agent keeps running after the close with a slower tick.
84    pub enabled: bool,
85    /// Seconds between overnight wakes (default 1 hour). LLM digest respects this interval.
86    pub tick_interval_seconds: u64,
87    /// Run web-model digest to build an open playbook (no chain calls, no entries).
88    pub web_digest: bool,
89    /// Skip overnight LLM when flat (no open positions).
90    pub skip_llm_when_flat: bool,
91    /// Telegram only when risk_alerts is non-empty (digest still saved to state).
92    pub alert_on_risk_only: bool,
93}
94
95impl Default for OvernightConfig {
96    fn default() -> Self {
97        Self {
98            enabled: false,
99            tick_interval_seconds: 3600,
100            web_digest: true,
101            skip_llm_when_flat: true,
102            alert_on_risk_only: true,
103        }
104    }
105}
106
107impl Default for ScheduleConfig {
108    fn default() -> Self {
109        Self {
110            tick_interval_seconds: 60,
111            market_hours_only: true,
112            timezone: "America/New_York".into(),
113            overnight: OvernightConfig::default(),
114        }
115    }
116}
117
118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119pub struct StrategiesToggle {
120    #[serde(default)]
121    pub vertical: StrategyEnabled,
122    #[serde(default)]
123    pub iron_condor: StrategyEnabled,
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct StrategyEnabled {
128    #[serde(default)]
129    pub enabled: bool,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct EntryRules {
134    #[serde(default)]
135    pub vertical: VerticalEntryRules,
136    #[serde(default)]
137    pub iron_condor: IronCondorEntryRules,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct VerticalEntryRules {
143    pub r#type: String,
144    pub dte_min: u32,
145    pub dte_max: u32,
146    pub min_credit: f64,
147    pub max_width: f64,
148    pub short_delta_min: f64,
149    pub short_delta_max: f64,
150    /// Minimum modeled POP vs break-even (percent). Omit to skip.
151    #[serde(default)]
152    pub min_pop_pct: Option<f64>,
153    /// Minimum spot cushion above (puts) or below (calls) break-even as % of spot.
154    #[serde(default)]
155    pub min_distance_to_be_pct: Option<f64>,
156    /// Minimum credit / width ratio (percent). Defaults to 12.5 when omitted.
157    #[serde(default)]
158    pub min_credit_to_width_pct: Option<f64>,
159    pub max_open_positions: u32,
160    pub max_contracts_per_trade: u32,
161}
162
163impl Default for VerticalEntryRules {
164    fn default() -> Self {
165        Self {
166            r#type: "put_credit".into(),
167            dte_min: 30,
168            dte_max: 45,
169            min_credit: 0.50,
170            max_width: 5.0,
171            short_delta_min: 0.15,
172            short_delta_max: 0.30,
173            min_pop_pct: Some(60.0),
174            min_distance_to_be_pct: Some(3.0),
175            min_credit_to_width_pct: Some(12.5),
176            max_open_positions: 3,
177            max_contracts_per_trade: 2,
178        }
179    }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(default)]
184pub struct IronCondorEntryRules {
185    pub dte_min: u32,
186    pub dte_max: u32,
187    pub min_credit: f64,
188    pub wing_width: f64,
189    pub short_delta: f64,
190    pub max_open_positions: u32,
191    pub max_contracts_per_trade: u32,
192}
193
194impl Default for IronCondorEntryRules {
195    fn default() -> Self {
196        Self {
197            dte_min: 30,
198            dte_max: 45,
199            min_credit: 1.00,
200            wing_width: 5.0,
201            short_delta: 0.16,
202            max_open_positions: 2,
203            max_contracts_per_trade: 1,
204        }
205    }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct ProfitGivebackExit {
210    /// Exit when peak unrealized profit reached at least this %.
211    pub peak_profit_min_pct: f64,
212    /// Exit when current profit falls below this % after the peak threshold was met.
213    pub exit_if_below_pct: f64,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(default)]
218pub struct ThesisExitRules {
219    pub enabled: bool,
220    /// Skip thesis exits (not profit/stop/DTE) until the position has been open this long.
221    pub min_hold_minutes: Option<u32>,
222    /// Close when modeled spread POP falls below this (success probability deteriorated).
223    pub min_pop_pct_exit: Option<f64>,
224    /// Close when |short_delta| reaches this (strike no longer comfortably OTM).
225    pub max_short_delta_exit: Option<f64>,
226    /// Close when short leg is within this % OTM of spot (pin / chop risk).
227    pub min_short_otm_pct: Option<f64>,
228    /// Close when short strike sits inside the 1σ expected move toward ITM.
229    /// Prefer false for ~5% OTM credit spreads — distance < 1σ is normal at entry.
230    pub exit_short_inside_1sigma: bool,
231    /// After a thesis exit, skip same-underlying entry scan for this many minutes.
232    pub redeploy_cooldown_minutes: Option<u32>,
233    pub profit_giveback: Option<ProfitGivebackExit>,
234}
235
236impl Default for ThesisExitRules {
237    fn default() -> Self {
238        Self {
239            enabled: false,
240            min_hold_minutes: None,
241            min_pop_pct_exit: None,
242            max_short_delta_exit: None,
243            min_short_otm_pct: None,
244            exit_short_inside_1sigma: false,
245            redeploy_cooldown_minutes: None,
246            profit_giveback: None,
247        }
248    }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252#[serde(default)]
253pub struct ExitRules {
254    pub profit_target_pct: f64,
255    pub stop_loss_pct: f64,
256    pub dte_close: u32,
257    pub thesis: ThesisExitRules,
258}
259
260impl Default for ExitRules {
261    fn default() -> Self {
262        Self {
263            profit_target_pct: 50.0,
264            stop_loss_pct: 200.0,
265            dte_close: 21,
266            thesis: ThesisExitRules::default(),
267        }
268    }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(default)]
273pub struct RiskConfig {
274    pub max_portfolio_risk_usd: f64,
275    pub max_risk_per_trade_usd: f64,
276    pub max_trades_per_day: u32,
277    pub allowed_underlyings: Vec<String>,
278    pub blocked_events: Vec<String>,
279}
280
281impl Default for RiskConfig {
282    fn default() -> Self {
283        Self {
284            max_portfolio_risk_usd: 10_000.0,
285            max_risk_per_trade_usd: 2_000.0,
286            max_trades_per_day: 3,
287            allowed_underlyings: vec!["SPY".into(), "QQQ".into(), "IWM".into()],
288            blocked_events: vec![],
289        }
290    }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(default)]
295pub struct ExecutionConfig {
296    pub order_type: String,
297    pub require_preview: bool,
298    pub wait_for_fill: bool,
299    pub fill_timeout_seconds: u64,
300}
301
302impl Default for ExecutionConfig {
303    fn default() -> Self {
304        Self {
305            order_type: "limit".into(),
306            require_preview: true,
307            wait_for_fill: true,
308            fill_timeout_seconds: 300,
309        }
310    }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "lowercase")]
315pub enum LlmPhase {
316    Selection,
317    Monitor,
318    OvernightDigest,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(default)]
323pub struct LlmConfig {
324    /// Enable OpenRouter LLM reviews during agent ticks.
325    pub enabled: bool,
326    /// High-intelligence model for entry veto when rules produce candidate trades.
327    pub selection_model: String,
328    /// Cost-efficient model for periodic open-position reviews.
329    pub monitor_model: String,
330    /// Model with web search for macro/event context (selection phase, periodic).
331    pub web_model: String,
332    /// Legacy fallback if selection_model / monitor_model are empty in old configs.
333    #[serde(default, skip_serializing_if = "String::is_empty")]
334    pub model: String,
335    /// Run LLM review every N agent ticks when flat (no open positions).
336    /// With open positions, `effective_monitor_review_ticks` applies to both selection and monitor.
337    pub review_every_ticks: u64,
338    /// Monitor-phase interval when open spreads are above `dte_close` (long-dated / low gamma).
339    /// Falls back to `review_every_ticks` when unset or when any position is in the gamma window.
340    #[serde(default)]
341    pub monitor_review_every_ticks: Option<u64>,
342    /// Use web_model every N selection/monitor LLM reviews (when applicable).
343    pub web_research_every_reviews: u64,
344    pub max_tokens: u32,
345    /// When true, LLM can veto new entries when it recommends defer/skip.
346    pub veto_entries: bool,
347    /// When true, high-urgency LLM close recommendations trigger exits.
348    pub allow_llm_exits: bool,
349    /// Per-phase role, instructions, and strategy context (configurable per rules file).
350    #[serde(default)]
351    pub prompts: LlmPromptsConfig,
352}
353
354/// Configurable LLM instructions per agent strategy. Empty fields use built-in defaults.
355#[derive(Debug, Clone, Default, Serialize, Deserialize)]
356#[serde(default)]
357pub struct LlmPromptsConfig {
358    /// System instructions for entry/selection phase (role, risk posture, what to optimize).
359    pub selection: String,
360    /// Optional override when web_model is used for selection; falls back to `selection`.
361    pub selection_web: String,
362    /// Extra strategy context prepended to the user message during selection.
363    pub selection_context: String,
364    /// System instructions for open-position monitoring phase.
365    pub monitor: String,
366    /// Extra strategy context prepended to the user message during monitoring.
367    pub monitor_context: String,
368    /// System instructions for overnight web digest (market closed).
369    pub overnight: String,
370    /// Extra context for overnight digest user message.
371    pub overnight_context: String,
372}
373
374pub fn default_selection_prompt() -> &'static str {
375    "You are an expert options income trader specializing in defined-risk credit spreads \
376     and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
377     rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
378     and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
379     favorable within the strategy context provided."
380}
381
382pub fn default_selection_web_prompt() -> &'static str {
383    "You are an expert options income trader evaluating whether to OPEN new spreads. \
384     Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
385     and macro risk via web context. When candidate_entries is non-empty, ground strike and \
386     greek analysis in each candidate's market_context — web research supplements but does \
387     not replace chain fields. Be conservative: defer or skip if event or volatility \
388     risk outweighs the premium collected."
389}
390
391/// Appended to every selection-phase system prompt (custom YAML included).
392pub fn selection_market_context_guardrails() -> &'static str {
393    "CHAIN DATA GUARDRAILS (binding):\n\
394     - candidate_entries[] are built only after a successful Schwab chain fetch. Each item \
395     includes market_context (underlying_price, short_delta, chain_iv, spread_pop_pct, \
396     break_even_price, expected_move_1sigma, credit_to_width_pct, DTE, etc.).\n\
397     - ivr_available: false means IV Rank is not provided — NOT missing chain data. Use chain_iv.\n\
398     - FORBIDDEN defer/skip reasons: \"lack of live chain data\", \"missing greeks\", \
399     \"no IV data\", or similar vague claims when market_context has underlying_price AND \
400     short_delta.\n\
401     - If you believe data is incomplete, cite the exact null/missing JSON field (e.g. \
402     \"short_theta is null\") and do not veto solely for absent IV Rank, theta, or gamma.\n\
403     - Defer or skip based on macro, event risk, poor premium/delta/strike placement, or \
404     extreme chain_iv — not invented data gaps.\n\
405     - If candidate_entries is empty, recommend skip with \"no mechanical candidates\" — do \
406     not claim chain API failure."
407}
408
409pub fn default_monitor_prompt() -> &'static str {
410    "You are monitoring existing open option spreads. Mechanical exits (profit target, \
411     stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
412     Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
413     current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
414     CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
415     Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
416     in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
417     stop_triggered is false, the position has NOT hit the mechanical stop.\n\
418     Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
419     Use market_context for recommendations:\n\
420     - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
421     short_otm_pct > 3% for put credits)\n\
422     - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
423     or developing macro/event risk\n\
424     - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
425     urgency high only for imminent assignment/gap risk through short strike\n\
426     For 30-45 DTE income trades: keep market_commentary to 1-2 sentences unless \
427     delta, POP, P/L, or event risk changed materially since the last review. Do not \
428     repeat overnight playbook themes when mechanical_rules show no triggered exit.\n\
429     If market_context is missing but market_context_error is set, rely on mechanical_rules \
430     and recommend hold unless mechanical_rules indicate a triggered exit.\n\
431     For new_entries during monitor phase: recommend proceed only when candidate_entries is \
432     non-empty; otherwise use skip with brief reasoning.\n\
433     Do not recommend close for routine profit — mechanics handle 50% target."
434}
435
436pub fn default_overnight_prompt() -> &'static str {
437    "The US options market is CLOSED. Research overnight and pre-market news (futures, \
438     macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
439     Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
440     whether any open spread thesis is broken, and suggested actions at the open \
441     (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
442     For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
443     thesis-breaking developments."
444}
445
446impl LlmPromptsConfig {
447    pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
448        if use_web {
449            if !self.selection_web.is_empty() {
450                return &self.selection_web;
451            }
452            if !self.selection.is_empty() {
453                return &self.selection;
454            }
455            return default_selection_web_prompt();
456        }
457        if !self.selection.is_empty() {
458            return &self.selection;
459        }
460        default_selection_prompt()
461    }
462
463    pub fn effective_monitor_instructions(&self) -> &str {
464        if !self.monitor.is_empty() {
465            return &self.monitor;
466        }
467        default_monitor_prompt()
468    }
469
470    pub fn effective_overnight_instructions(&self) -> &str {
471        if !self.overnight.is_empty() {
472            return &self.overnight;
473        }
474        default_overnight_prompt()
475    }
476
477    pub fn effective_context(&self, phase: LlmPhase) -> &str {
478        match phase {
479            LlmPhase::Selection => &self.selection_context,
480            LlmPhase::Monitor => &self.monitor_context,
481            LlmPhase::OvernightDigest => &self.overnight_context,
482        }
483    }
484}
485
486impl Default for LlmConfig {
487    fn default() -> Self {
488        Self {
489            enabled: false,
490            selection_model: "anthropic/claude-sonnet-4".into(),
491            monitor_model: "google/gemini-2.5-flash".into(),
492            web_model: "perplexity/sonar".into(),
493            model: String::new(),
494            review_every_ticks: 5,
495            monitor_review_every_ticks: None,
496            web_research_every_reviews: 3,
497            max_tokens: 2000,
498            veto_entries: true,
499            allow_llm_exits: false,
500            prompts: LlmPromptsConfig::default(),
501        }
502    }
503}
504
505impl LlmConfig {
506    pub fn effective_selection_model(&self) -> &str {
507        if !self.selection_model.is_empty() {
508            &self.selection_model
509        } else if !self.model.is_empty() {
510            &self.model
511        } else {
512            "anthropic/claude-sonnet-4"
513        }
514    }
515
516    pub fn effective_monitor_model(&self) -> &str {
517        if !self.monitor_model.is_empty() {
518            &self.monitor_model
519        } else if !self.model.is_empty() {
520            &self.model
521        } else {
522            "google/gemini-2.5-flash"
523        }
524    }
525
526    /// Resolve which OpenRouter model to call for this phase.
527    pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
528        if use_web {
529            return &self.web_model;
530        }
531        match phase {
532            LlmPhase::Selection => self.effective_selection_model(),
533            LlmPhase::Monitor => self.effective_monitor_model(),
534            LlmPhase::OvernightDigest => &self.web_model,
535        }
536    }
537
538    /// Monitor LLM cadence: slower above gamma window (DTE > dte_close), faster inside it.
539    pub fn effective_monitor_review_ticks(
540        &self,
541        min_open_dte: Option<i64>,
542        dte_close: u32,
543    ) -> u64 {
544        let fast = self.review_every_ticks.max(1);
545        let slow = self.monitor_review_every_ticks.unwrap_or(30).max(1);
546        match min_open_dte {
547            Some(dte) if dte > dte_close as i64 => slow,
548            _ => fast,
549        }
550    }
551
552    /// Shared cadence for selection and monitor LLM reviews.
553    pub fn effective_llm_review_ticks(
554        &self,
555        has_open_positions: bool,
556        min_open_dte: Option<i64>,
557        dte_close: u32,
558    ) -> u64 {
559        if has_open_positions {
560            self.effective_monitor_review_ticks(min_open_dte, dte_close)
561        } else {
562            self.review_every_ticks.max(1)
563        }
564    }
565
566    pub fn monitor_interval_minutes(
567        &self,
568        tick_interval_seconds: u64,
569        min_open_dte: Option<i64>,
570        dte_close: u32,
571    ) -> u64 {
572        let secs = self
573            .effective_monitor_review_ticks(min_open_dte, dte_close)
574            .saturating_mul(tick_interval_seconds.max(1));
575        (secs / 60).max(1)
576    }
577}
578
579#[derive(Debug, Clone, Default, Serialize, Deserialize)]
580#[serde(default)]
581pub struct NotifyConfig {
582    pub telegram: TelegramNotifyConfig,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(default)]
587pub struct TelegramNotifyConfig {
588    pub enabled: bool,
589    /// Notify on every tick summary (can be noisy).
590    pub notify_every_tick: bool,
591    /// Notify on fills, exits, and LLM updates (when urgency/digest rules allow).
592    pub notify_on_actions: bool,
593    /// Send routine LLM status digests (same recommendation is not repeated).
594    pub llm_notify_digest: bool,
595    /// Minimum minutes between routine LLM digests (0 = urgent-only).
596    pub llm_digest_interval_minutes: u64,
597    /// Send immediately when LLM says proceed, high urgency, or urgent close.
598    pub llm_notify_urgent: bool,
599    /// Do not repeat the same urgent LLM message within this many minutes.
600    pub llm_urgent_cooldown_minutes: u64,
601}
602
603impl Default for TelegramNotifyConfig {
604    fn default() -> Self {
605        Self {
606            enabled: false,
607            notify_every_tick: false,
608            notify_on_actions: true,
609            llm_notify_digest: true,
610            llm_digest_interval_minutes: 60,
611            llm_notify_urgent: true,
612            llm_urgent_cooldown_minutes: 30,
613        }
614    }
615}
616
617impl RulesConfig {
618    pub fn load(path: &Path) -> Result<Self> {
619        let content = fs::read_to_string(path)
620            .with_context(|| format!("read rules file {}", path.display()))?;
621        let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
622            serde_json::from_str(&content)?
623        } else {
624            serde_yaml::from_str(&content)?
625        };
626        rules.validate()?;
627        Ok(rules)
628    }
629
630    pub fn validate(&self) -> Result<()> {
631        if self.version != RULES_VERSION {
632            anyhow::bail!(
633                "unsupported rules version {} (expected {})",
634                self.version,
635                RULES_VERSION
636            );
637        }
638        if self.agent_id.trim().is_empty() {
639            anyhow::bail!("agent_id is required");
640        }
641        if self.accounts.is_empty() {
642            anyhow::bail!("at least one account is required");
643        }
644        for acct in &self.accounts {
645            if acct.hash.trim().is_empty() {
646                anyhow::bail!("account hash is required");
647            }
648        }
649        if self.watchlist.is_empty() {
650            anyhow::bail!("watchlist must not be empty");
651        }
652        if !self.schedule.market_hours_only {
653            anyhow::bail!("options agent requires schedule.market_hours_only=true");
654        }
655        if !self.execution.order_type.eq_ignore_ascii_case("limit") {
656            anyhow::bail!("options agent requires execution.order_type=limit");
657        }
658        if !self.execution.require_preview {
659            anyhow::bail!("options agent requires execution.require_preview=true");
660        }
661        Ok(())
662    }
663
664    pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
665        self.accounts.iter().filter(|a| a.enabled)
666    }
667}
668
669pub fn rules_json_schema() -> Value {
670    json!({
671        "$schema": "https://json-schema.org/draft/2020-12/schema",
672        "title": "Schwab options agent rules",
673        "type": "object",
674        "required": ["version", "agent_id", "accounts", "watchlist"],
675        "properties": {
676            "version": { "const": 1 },
677            "agent_id": { "type": "string" },
678            "accounts": {
679                "type": "array",
680                "items": {
681                    "type": "object",
682                    "required": ["hash"],
683                    "properties": {
684                        "hash": { "type": "string" },
685                        "label": { "type": "string" },
686                        "type": { "enum": ["margin", "ira", "cash"] },
687                        "enabled": { "type": "boolean" }
688                    }
689                }
690            },
691            "schedule": {
692                "type": "object",
693                "properties": {
694                    "tick_interval_seconds": { "type": "integer", "minimum": 5 },
695                    "market_hours_only": { "type": "boolean" },
696                    "timezone": { "type": "string" },
697                    "overnight": {
698                        "type": "object",
699                        "properties": {
700                            "enabled": { "type": "boolean" },
701                            "tick_interval_seconds": { "type": "integer", "minimum": 300 },
702                            "web_digest": { "type": "boolean" },
703                            "skip_llm_when_flat": { "type": "boolean" },
704                            "alert_on_risk_only": { "type": "boolean" }
705                        }
706                    }
707                }
708            },
709            "strategies": {
710                "type": "object",
711                "properties": {
712                    "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
713                    "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
714                }
715            },
716            "watchlist": { "type": "array", "items": { "type": "string" } },
717            "entry_rules": { "type": "object" },
718            "exit_rules": { "type": "object" },
719            "risk": { "type": "object" },
720            "execution": { "type": "object" }
721        }
722    })
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    #[test]
730    fn validates_minimal_rules() {
731        let rules = RulesConfig {
732            version: 1,
733            agent_id: "test".into(),
734            accounts: vec![RulesAccount {
735                hash: "ABC".into(),
736                label: None,
737                r#type: AccountType::Margin,
738                enabled: true,
739            }],
740            schedule: ScheduleConfig::default(),
741            strategies: StrategiesToggle::default(),
742            watchlist: vec!["SPY".into()],
743            entry_rules: EntryRules::default(),
744            exit_rules: ExitRules::default(),
745            risk: RiskConfig::default(),
746            execution: ExecutionConfig::default(),
747            llm: LlmConfig::default(),
748            notify: NotifyConfig::default(),
749            simulation: None,
750        };
751        rules.validate().unwrap();
752    }
753
754    #[test]
755    fn loads_example_rules_yaml() {
756        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
757            .join("../../rules/options-rules.example.yaml");
758        if path.exists() {
759            let rules = RulesConfig::load(&path).unwrap();
760            assert_eq!(rules.agent_id, "spy-income-v1");
761        }
762    }
763
764    #[test]
765    fn llm_config_resolves_phase_models() {
766        let cfg = LlmConfig::default();
767        assert_eq!(
768            cfg.resolve_model(LlmPhase::Selection, false),
769            "anthropic/claude-sonnet-4"
770        );
771        assert_eq!(
772            cfg.resolve_model(LlmPhase::Monitor, false),
773            "google/gemini-2.5-flash"
774        );
775        assert_eq!(
776            cfg.resolve_model(LlmPhase::Selection, true),
777            "perplexity/sonar"
778        );
779    }
780
781    #[test]
782    fn custom_selection_prompt_overrides_default() {
783        let prompts = LlmPromptsConfig {
784            selection: "Aggressive premium seller.".into(),
785            ..Default::default()
786        };
787        assert!(prompts
788            .effective_selection_instructions(false)
789            .contains("Aggressive"));
790    }
791
792    #[test]
793    fn selection_web_prompt_used_when_set() {
794        let prompts = LlmPromptsConfig {
795            selection: "conservative".into(),
796            selection_web: "web aggressive".into(),
797            ..Default::default()
798        };
799        assert_eq!(
800            prompts.effective_selection_instructions(true),
801            "web aggressive"
802        );
803    }
804
805    #[test]
806    fn selection_guardrails_are_documented() {
807        let g = selection_market_context_guardrails();
808        assert!(g.contains("FORBIDDEN"));
809        assert!(g.contains("underlying_price"));
810        assert!(g.contains("ivr_available"));
811    }
812
813    #[test]
814    fn monitor_review_slower_above_gamma_window() {
815        let llm = LlmConfig {
816            review_every_ticks: 5,
817            monitor_review_every_ticks: Some(45),
818            ..Default::default()
819        };
820        assert_eq!(llm.effective_monitor_review_ticks(Some(30), 21), 45);
821        assert_eq!(llm.effective_monitor_review_ticks(Some(18), 21), 5);
822    }
823
824    #[test]
825    fn llm_review_ticks_use_monitor_cadence_with_open_positions() {
826        let llm = LlmConfig {
827            review_every_ticks: 5,
828            monitor_review_every_ticks: Some(45),
829            ..Default::default()
830        };
831        assert_eq!(llm.effective_llm_review_ticks(false, None, 21), 5);
832        assert_eq!(llm.effective_llm_review_ticks(true, Some(30), 21), 45);
833        assert_eq!(llm.effective_llm_review_ticks(true, Some(18), 21), 5);
834    }
835}