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)]
209#[serde(default)]
210pub struct ExitRules {
211    pub profit_target_pct: f64,
212    pub stop_loss_pct: f64,
213    pub dte_close: u32,
214}
215
216impl Default for ExitRules {
217    fn default() -> Self {
218        Self {
219            profit_target_pct: 50.0,
220            stop_loss_pct: 200.0,
221            dte_close: 21,
222        }
223    }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(default)]
228pub struct RiskConfig {
229    pub max_portfolio_risk_usd: f64,
230    pub max_risk_per_trade_usd: f64,
231    pub max_trades_per_day: u32,
232    pub allowed_underlyings: Vec<String>,
233    pub blocked_events: Vec<String>,
234}
235
236impl Default for RiskConfig {
237    fn default() -> Self {
238        Self {
239            max_portfolio_risk_usd: 10_000.0,
240            max_risk_per_trade_usd: 2_000.0,
241            max_trades_per_day: 3,
242            allowed_underlyings: vec!["SPY".into(), "QQQ".into(), "IWM".into()],
243            blocked_events: vec![],
244        }
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249#[serde(default)]
250pub struct ExecutionConfig {
251    pub order_type: String,
252    pub require_preview: bool,
253    pub wait_for_fill: bool,
254    pub fill_timeout_seconds: u64,
255}
256
257impl Default for ExecutionConfig {
258    fn default() -> Self {
259        Self {
260            order_type: "limit".into(),
261            require_preview: true,
262            wait_for_fill: true,
263            fill_timeout_seconds: 300,
264        }
265    }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "lowercase")]
270pub enum LlmPhase {
271    Selection,
272    Monitor,
273    OvernightDigest,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(default)]
278pub struct LlmConfig {
279    /// Enable OpenRouter LLM reviews during agent ticks.
280    pub enabled: bool,
281    /// High-intelligence model for entry veto when rules produce candidate trades.
282    pub selection_model: String,
283    /// Cost-efficient model for periodic open-position reviews.
284    pub monitor_model: String,
285    /// Model with web search for macro/event context (selection phase, periodic).
286    pub web_model: String,
287    /// Legacy fallback if selection_model / monitor_model are empty in old configs.
288    #[serde(default, skip_serializing_if = "String::is_empty")]
289    pub model: String,
290    /// Run LLM review every N agent ticks when flat (no open positions).
291    /// With open positions, `effective_monitor_review_ticks` applies to both selection and monitor.
292    pub review_every_ticks: u64,
293    /// Monitor-phase interval when open spreads are above `dte_close` (long-dated / low gamma).
294    /// Falls back to `review_every_ticks` when unset or when any position is in the gamma window.
295    #[serde(default)]
296    pub monitor_review_every_ticks: Option<u64>,
297    /// Use web_model every N selection/monitor LLM reviews (when applicable).
298    pub web_research_every_reviews: u64,
299    pub max_tokens: u32,
300    /// When true, LLM can veto new entries when it recommends defer/skip.
301    pub veto_entries: bool,
302    /// When true, high-urgency LLM close recommendations trigger exits.
303    pub allow_llm_exits: bool,
304    /// Per-phase role, instructions, and strategy context (configurable per rules file).
305    #[serde(default)]
306    pub prompts: LlmPromptsConfig,
307}
308
309/// Configurable LLM instructions per agent strategy. Empty fields use built-in defaults.
310#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311#[serde(default)]
312pub struct LlmPromptsConfig {
313    /// System instructions for entry/selection phase (role, risk posture, what to optimize).
314    pub selection: String,
315    /// Optional override when web_model is used for selection; falls back to `selection`.
316    pub selection_web: String,
317    /// Extra strategy context prepended to the user message during selection.
318    pub selection_context: String,
319    /// System instructions for open-position monitoring phase.
320    pub monitor: String,
321    /// Extra strategy context prepended to the user message during monitoring.
322    pub monitor_context: String,
323    /// System instructions for overnight web digest (market closed).
324    pub overnight: String,
325    /// Extra context for overnight digest user message.
326    pub overnight_context: String,
327}
328
329pub fn default_selection_prompt() -> &'static str {
330    "You are an expert options income trader specializing in defined-risk credit spreads \
331     and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
332     rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
333     and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
334     favorable within the strategy context provided."
335}
336
337pub fn default_selection_web_prompt() -> &'static str {
338    "You are an expert options income trader evaluating whether to OPEN new spreads. \
339     Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
340     and macro risk via web context. When candidate_entries is non-empty, ground strike and \
341     greek analysis in each candidate's market_context — web research supplements but does \
342     not replace chain fields. Be conservative: defer or skip if event or volatility \
343     risk outweighs the premium collected."
344}
345
346/// Appended to every selection-phase system prompt (custom YAML included).
347pub fn selection_market_context_guardrails() -> &'static str {
348    "CHAIN DATA GUARDRAILS (binding):\n\
349     - candidate_entries[] are built only after a successful Schwab chain fetch. Each item \
350     includes market_context (underlying_price, short_delta, chain_iv, spread_pop_pct, \
351     break_even_price, expected_move_1sigma, credit_to_width_pct, DTE, etc.).\n\
352     - ivr_available: false means IV Rank is not provided — NOT missing chain data. Use chain_iv.\n\
353     - FORBIDDEN defer/skip reasons: \"lack of live chain data\", \"missing greeks\", \
354     \"no IV data\", or similar vague claims when market_context has underlying_price AND \
355     short_delta.\n\
356     - If you believe data is incomplete, cite the exact null/missing JSON field (e.g. \
357     \"short_theta is null\") and do not veto solely for absent IV Rank, theta, or gamma.\n\
358     - Defer or skip based on macro, event risk, poor premium/delta/strike placement, or \
359     extreme chain_iv — not invented data gaps.\n\
360     - If candidate_entries is empty, recommend skip with \"no mechanical candidates\" — do \
361     not claim chain API failure."
362}
363
364pub fn default_monitor_prompt() -> &'static str {
365    "You are monitoring existing open option spreads. Mechanical exits (profit target, \
366     stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
367     Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
368     current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
369     CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
370     Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
371     in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
372     stop_triggered is false, the position has NOT hit the mechanical stop.\n\
373     Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
374     Use market_context for recommendations:\n\
375     - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
376     short_otm_pct > 3% for put credits)\n\
377     - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
378     or developing macro/event risk\n\
379     - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
380     urgency high only for imminent assignment/gap risk through short strike\n\
381     For 30-45 DTE income trades: keep market_commentary to 1-2 sentences unless \
382     delta, POP, P/L, or event risk changed materially since the last review. Do not \
383     repeat overnight playbook themes when mechanical_rules show no triggered exit.\n\
384     If market_context is missing but market_context_error is set, rely on mechanical_rules \
385     and recommend hold unless mechanical_rules indicate a triggered exit.\n\
386     For new_entries during monitor phase: recommend proceed only when candidate_entries is \
387     non-empty; otherwise use skip with brief reasoning.\n\
388     Do not recommend close for routine profit — mechanics handle 50% target."
389}
390
391pub fn default_overnight_prompt() -> &'static str {
392    "The US options market is CLOSED. Research overnight and pre-market news (futures, \
393     macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
394     Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
395     whether any open spread thesis is broken, and suggested actions at the open \
396     (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
397     For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
398     thesis-breaking developments."
399}
400
401impl LlmPromptsConfig {
402    pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
403        if use_web {
404            if !self.selection_web.is_empty() {
405                return &self.selection_web;
406            }
407            if !self.selection.is_empty() {
408                return &self.selection;
409            }
410            return default_selection_web_prompt();
411        }
412        if !self.selection.is_empty() {
413            return &self.selection;
414        }
415        default_selection_prompt()
416    }
417
418    pub fn effective_monitor_instructions(&self) -> &str {
419        if !self.monitor.is_empty() {
420            return &self.monitor;
421        }
422        default_monitor_prompt()
423    }
424
425    pub fn effective_overnight_instructions(&self) -> &str {
426        if !self.overnight.is_empty() {
427            return &self.overnight;
428        }
429        default_overnight_prompt()
430    }
431
432    pub fn effective_context(&self, phase: LlmPhase) -> &str {
433        match phase {
434            LlmPhase::Selection => &self.selection_context,
435            LlmPhase::Monitor => &self.monitor_context,
436            LlmPhase::OvernightDigest => &self.overnight_context,
437        }
438    }
439}
440
441impl Default for LlmConfig {
442    fn default() -> Self {
443        Self {
444            enabled: false,
445            selection_model: "anthropic/claude-sonnet-4".into(),
446            monitor_model: "google/gemini-2.5-flash".into(),
447            web_model: "perplexity/sonar".into(),
448            model: String::new(),
449            review_every_ticks: 5,
450            monitor_review_every_ticks: None,
451            web_research_every_reviews: 3,
452            max_tokens: 2000,
453            veto_entries: true,
454            allow_llm_exits: false,
455            prompts: LlmPromptsConfig::default(),
456        }
457    }
458}
459
460impl LlmConfig {
461    pub fn effective_selection_model(&self) -> &str {
462        if !self.selection_model.is_empty() {
463            &self.selection_model
464        } else if !self.model.is_empty() {
465            &self.model
466        } else {
467            "anthropic/claude-sonnet-4"
468        }
469    }
470
471    pub fn effective_monitor_model(&self) -> &str {
472        if !self.monitor_model.is_empty() {
473            &self.monitor_model
474        } else if !self.model.is_empty() {
475            &self.model
476        } else {
477            "google/gemini-2.5-flash"
478        }
479    }
480
481    /// Resolve which OpenRouter model to call for this phase.
482    pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
483        if use_web {
484            return &self.web_model;
485        }
486        match phase {
487            LlmPhase::Selection => self.effective_selection_model(),
488            LlmPhase::Monitor => self.effective_monitor_model(),
489            LlmPhase::OvernightDigest => &self.web_model,
490        }
491    }
492
493    /// Monitor LLM cadence: slower above gamma window (DTE > dte_close), faster inside it.
494    pub fn effective_monitor_review_ticks(
495        &self,
496        min_open_dte: Option<i64>,
497        dte_close: u32,
498    ) -> u64 {
499        let fast = self.review_every_ticks.max(1);
500        let slow = self.monitor_review_every_ticks.unwrap_or(30).max(1);
501        match min_open_dte {
502            Some(dte) if dte > dte_close as i64 => slow,
503            _ => fast,
504        }
505    }
506
507    /// Shared cadence for selection and monitor LLM reviews.
508    pub fn effective_llm_review_ticks(
509        &self,
510        has_open_positions: bool,
511        min_open_dte: Option<i64>,
512        dte_close: u32,
513    ) -> u64 {
514        if has_open_positions {
515            self.effective_monitor_review_ticks(min_open_dte, dte_close)
516        } else {
517            self.review_every_ticks.max(1)
518        }
519    }
520
521    pub fn monitor_interval_minutes(
522        &self,
523        tick_interval_seconds: u64,
524        min_open_dte: Option<i64>,
525        dte_close: u32,
526    ) -> u64 {
527        let secs = self
528            .effective_monitor_review_ticks(min_open_dte, dte_close)
529            .saturating_mul(tick_interval_seconds.max(1));
530        (secs / 60).max(1)
531    }
532}
533
534#[derive(Debug, Clone, Default, Serialize, Deserialize)]
535#[serde(default)]
536pub struct NotifyConfig {
537    pub telegram: TelegramNotifyConfig,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
541#[serde(default)]
542pub struct TelegramNotifyConfig {
543    pub enabled: bool,
544    /// Notify on every tick summary (can be noisy).
545    pub notify_every_tick: bool,
546    /// Notify on fills, exits, and LLM updates (when urgency/digest rules allow).
547    pub notify_on_actions: bool,
548    /// Send routine LLM status digests (same recommendation is not repeated).
549    pub llm_notify_digest: bool,
550    /// Minimum minutes between routine LLM digests (0 = urgent-only).
551    pub llm_digest_interval_minutes: u64,
552    /// Send immediately when LLM says proceed, high urgency, or urgent close.
553    pub llm_notify_urgent: bool,
554    /// Do not repeat the same urgent LLM message within this many minutes.
555    pub llm_urgent_cooldown_minutes: u64,
556}
557
558impl Default for TelegramNotifyConfig {
559    fn default() -> Self {
560        Self {
561            enabled: false,
562            notify_every_tick: false,
563            notify_on_actions: true,
564            llm_notify_digest: true,
565            llm_digest_interval_minutes: 60,
566            llm_notify_urgent: true,
567            llm_urgent_cooldown_minutes: 30,
568        }
569    }
570}
571
572impl RulesConfig {
573    pub fn load(path: &Path) -> Result<Self> {
574        let content = fs::read_to_string(path)
575            .with_context(|| format!("read rules file {}", path.display()))?;
576        let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
577            serde_json::from_str(&content)?
578        } else {
579            serde_yaml::from_str(&content)?
580        };
581        rules.validate()?;
582        Ok(rules)
583    }
584
585    pub fn validate(&self) -> Result<()> {
586        if self.version != RULES_VERSION {
587            anyhow::bail!(
588                "unsupported rules version {} (expected {})",
589                self.version,
590                RULES_VERSION
591            );
592        }
593        if self.agent_id.trim().is_empty() {
594            anyhow::bail!("agent_id is required");
595        }
596        if self.accounts.is_empty() {
597            anyhow::bail!("at least one account is required");
598        }
599        for acct in &self.accounts {
600            if acct.hash.trim().is_empty() {
601                anyhow::bail!("account hash is required");
602            }
603        }
604        if self.watchlist.is_empty() {
605            anyhow::bail!("watchlist must not be empty");
606        }
607        if !self.schedule.market_hours_only {
608            anyhow::bail!("options agent requires schedule.market_hours_only=true");
609        }
610        if !self.execution.order_type.eq_ignore_ascii_case("limit") {
611            anyhow::bail!("options agent requires execution.order_type=limit");
612        }
613        if !self.execution.require_preview {
614            anyhow::bail!("options agent requires execution.require_preview=true");
615        }
616        Ok(())
617    }
618
619    pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
620        self.accounts.iter().filter(|a| a.enabled)
621    }
622}
623
624pub fn rules_json_schema() -> Value {
625    json!({
626        "$schema": "https://json-schema.org/draft/2020-12/schema",
627        "title": "Schwab options agent rules",
628        "type": "object",
629        "required": ["version", "agent_id", "accounts", "watchlist"],
630        "properties": {
631            "version": { "const": 1 },
632            "agent_id": { "type": "string" },
633            "accounts": {
634                "type": "array",
635                "items": {
636                    "type": "object",
637                    "required": ["hash"],
638                    "properties": {
639                        "hash": { "type": "string" },
640                        "label": { "type": "string" },
641                        "type": { "enum": ["margin", "ira", "cash"] },
642                        "enabled": { "type": "boolean" }
643                    }
644                }
645            },
646            "schedule": {
647                "type": "object",
648                "properties": {
649                    "tick_interval_seconds": { "type": "integer", "minimum": 5 },
650                    "market_hours_only": { "type": "boolean" },
651                    "timezone": { "type": "string" },
652                    "overnight": {
653                        "type": "object",
654                        "properties": {
655                            "enabled": { "type": "boolean" },
656                            "tick_interval_seconds": { "type": "integer", "minimum": 300 },
657                            "web_digest": { "type": "boolean" },
658                            "skip_llm_when_flat": { "type": "boolean" },
659                            "alert_on_risk_only": { "type": "boolean" }
660                        }
661                    }
662                }
663            },
664            "strategies": {
665                "type": "object",
666                "properties": {
667                    "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
668                    "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
669                }
670            },
671            "watchlist": { "type": "array", "items": { "type": "string" } },
672            "entry_rules": { "type": "object" },
673            "exit_rules": { "type": "object" },
674            "risk": { "type": "object" },
675            "execution": { "type": "object" }
676        }
677    })
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    #[test]
685    fn validates_minimal_rules() {
686        let rules = RulesConfig {
687            version: 1,
688            agent_id: "test".into(),
689            accounts: vec![RulesAccount {
690                hash: "ABC".into(),
691                label: None,
692                r#type: AccountType::Margin,
693                enabled: true,
694            }],
695            schedule: ScheduleConfig::default(),
696            strategies: StrategiesToggle::default(),
697            watchlist: vec!["SPY".into()],
698            entry_rules: EntryRules::default(),
699            exit_rules: ExitRules::default(),
700            risk: RiskConfig::default(),
701            execution: ExecutionConfig::default(),
702            llm: LlmConfig::default(),
703            notify: NotifyConfig::default(),
704            simulation: None,
705        };
706        rules.validate().unwrap();
707    }
708
709    #[test]
710    fn loads_example_rules_yaml() {
711        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
712            .join("../../rules/options-rules.example.yaml");
713        if path.exists() {
714            let rules = RulesConfig::load(&path).unwrap();
715            assert_eq!(rules.agent_id, "spy-income-v1");
716        }
717    }
718
719    #[test]
720    fn llm_config_resolves_phase_models() {
721        let cfg = LlmConfig::default();
722        assert_eq!(
723            cfg.resolve_model(LlmPhase::Selection, false),
724            "anthropic/claude-sonnet-4"
725        );
726        assert_eq!(
727            cfg.resolve_model(LlmPhase::Monitor, false),
728            "google/gemini-2.5-flash"
729        );
730        assert_eq!(
731            cfg.resolve_model(LlmPhase::Selection, true),
732            "perplexity/sonar"
733        );
734    }
735
736    #[test]
737    fn custom_selection_prompt_overrides_default() {
738        let prompts = LlmPromptsConfig {
739            selection: "Aggressive premium seller.".into(),
740            ..Default::default()
741        };
742        assert!(prompts
743            .effective_selection_instructions(false)
744            .contains("Aggressive"));
745    }
746
747    #[test]
748    fn selection_web_prompt_used_when_set() {
749        let prompts = LlmPromptsConfig {
750            selection: "conservative".into(),
751            selection_web: "web aggressive".into(),
752            ..Default::default()
753        };
754        assert_eq!(
755            prompts.effective_selection_instructions(true),
756            "web aggressive"
757        );
758    }
759
760    #[test]
761    fn selection_guardrails_are_documented() {
762        let g = selection_market_context_guardrails();
763        assert!(g.contains("FORBIDDEN"));
764        assert!(g.contains("underlying_price"));
765        assert!(g.contains("ivr_available"));
766    }
767
768    #[test]
769    fn monitor_review_slower_above_gamma_window() {
770        let llm = LlmConfig {
771            review_every_ticks: 5,
772            monitor_review_every_ticks: Some(45),
773            ..Default::default()
774        };
775        assert_eq!(llm.effective_monitor_review_ticks(Some(30), 21), 45);
776        assert_eq!(llm.effective_monitor_review_ticks(Some(18), 21), 5);
777    }
778
779    #[test]
780    fn llm_review_ticks_use_monitor_cadence_with_open_positions() {
781        let llm = LlmConfig {
782            review_every_ticks: 5,
783            monitor_review_every_ticks: Some(45),
784            ..Default::default()
785        };
786        assert_eq!(llm.effective_llm_review_ticks(false, None, 21), 5);
787        assert_eq!(llm.effective_llm_review_ticks(true, Some(30), 21), 45);
788        assert_eq!(llm.effective_llm_review_ticks(true, Some(18), 21), 5);
789    }
790}