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    /// Symbols to scan for entries, in priority order. Each entry may be a ticker string or
21    /// `{ symbol, role, min_credit, ... }` with per-symbol overrides.
22    #[serde(default)]
23    pub watchlist: Vec<WatchlistEntry>,
24    #[serde(default)]
25    pub entry_policy: EntryPolicyConfig,
26    #[serde(default)]
27    pub entry_rules: EntryRules,
28    #[serde(default)]
29    pub exit_rules: ExitRules,
30    #[serde(default)]
31    pub risk: RiskConfig,
32    /// Regime-aware strategy selection (put credit / call credit / iron condor / pause).
33    #[serde(default)]
34    pub regime: OptionsRegimeConfig,
35    #[serde(default)]
36    pub execution: ExecutionConfig,
37    #[serde(default)]
38    pub llm: LlmConfig,
39    #[serde(default)]
40    pub notify: NotifyConfig,
41    /// Paper trading (--simulate): virtual budget separate from live agent state.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub simulation: Option<SimulationConfig>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct SimulationConfig {
48    /// Virtual risk budget for paper P&L (defaults to risk.max_portfolio_risk_usd).
49    pub starting_budget_usd: f64,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RulesAccount {
54    pub hash: String,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub label: Option<String>,
57    #[serde(default)]
58    pub r#type: AccountType,
59    #[serde(default = "default_true")]
60    pub enabled: bool,
61}
62
63fn default_true() -> bool {
64    true
65}
66
67#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "lowercase")]
69pub enum AccountType {
70    #[default]
71    Margin,
72    Ira,
73    Cash,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default)]
78pub struct ScheduleConfig {
79    pub tick_interval_seconds: u64,
80    pub market_hours_only: bool,
81    pub timezone: String,
82    #[serde(default)]
83    pub overnight: OvernightConfig,
84}
85
86/// Low-frequency overnight / pre-market behavior when the option market is closed.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default)]
89pub struct OvernightConfig {
90    /// When true, the agent keeps running after the close with a slower tick.
91    pub enabled: bool,
92    /// Seconds between overnight wakes (default 1 hour). LLM digest respects this interval.
93    pub tick_interval_seconds: u64,
94    /// Run web-model digest to build an open playbook (no chain calls, no entries).
95    pub web_digest: bool,
96    /// Skip overnight LLM when flat (no open positions).
97    pub skip_llm_when_flat: bool,
98    /// Telegram only when risk_alerts is non-empty (digest still saved to state).
99    pub alert_on_risk_only: bool,
100}
101
102impl Default for OvernightConfig {
103    fn default() -> Self {
104        Self {
105            enabled: false,
106            tick_interval_seconds: 3600,
107            web_digest: true,
108            skip_llm_when_flat: true,
109            alert_on_risk_only: true,
110        }
111    }
112}
113
114impl Default for ScheduleConfig {
115    fn default() -> Self {
116        Self {
117            tick_interval_seconds: 60,
118            market_hours_only: true,
119            timezone: "America/New_York".into(),
120            overnight: OvernightConfig::default(),
121        }
122    }
123}
124
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
126pub struct StrategiesToggle {
127    #[serde(default)]
128    pub vertical: StrategyEnabled,
129    #[serde(default)]
130    pub iron_condor: StrategyEnabled,
131}
132
133#[derive(Debug, Clone, Default, Serialize, Deserialize)]
134pub struct StrategyEnabled {
135    #[serde(default)]
136    pub enabled: bool,
137}
138
139/// `string` or `{ symbol, role, min_credit, ... }`.
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
141#[serde(untagged)]
142pub enum WatchlistEntry {
143    Symbol(String),
144    Item(WatchlistItemConfig),
145}
146
147impl From<&str> for WatchlistEntry {
148    fn from(value: &str) -> Self {
149        Self::Symbol(value.to_string())
150    }
151}
152
153impl From<String> for WatchlistEntry {
154    fn from(value: String) -> Self {
155        Self::Symbol(value)
156    }
157}
158
159impl WatchlistEntry {
160    pub fn to_item(&self) -> WatchlistItemConfig {
161        match self {
162            Self::Symbol(s) => WatchlistItemConfig {
163                symbol: s.clone(),
164                ..Default::default()
165            },
166            Self::Item(item) => item.clone(),
167        }
168    }
169}
170
171#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
172#[serde(default)]
173pub struct WatchlistItemConfig {
174    pub symbol: String,
175    #[serde(default)]
176    pub role: WatchlistRole,
177    #[serde(flatten)]
178    pub overrides: VerticalEntryOverrides,
179}
180
181#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "snake_case")]
183pub enum WatchlistRole {
184    #[default]
185    Primary,
186    Fallback,
187}
188
189/// Per-symbol vertical entry overrides (merged onto `entry_rules.vertical`).
190#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
191#[serde(default)]
192pub struct VerticalEntryOverrides {
193    pub min_credit: Option<f64>,
194    pub max_width: Option<f64>,
195    pub short_delta_min: Option<f64>,
196    pub short_delta_max: Option<f64>,
197    pub min_pop_pct: Option<f64>,
198    pub min_distance_to_be_pct: Option<f64>,
199    pub min_credit_to_width_pct: Option<f64>,
200}
201
202impl VerticalEntryOverrides {
203    pub fn apply_to(&self, mut base: VerticalEntryRules) -> VerticalEntryRules {
204        if let Some(v) = self.min_credit {
205            base.min_credit = v;
206        }
207        if let Some(v) = self.max_width {
208            base.max_width = v;
209        }
210        if let Some(v) = self.short_delta_min {
211            base.short_delta_min = v;
212        }
213        if let Some(v) = self.short_delta_max {
214            base.short_delta_max = v;
215        }
216        if let Some(v) = self.min_pop_pct {
217            base.min_pop_pct = Some(v);
218        }
219        if let Some(v) = self.min_distance_to_be_pct {
220            base.min_distance_to_be_pct = Some(v);
221        }
222        if let Some(v) = self.min_credit_to_width_pct {
223            base.min_credit_to_width_pct = Some(v);
224        }
225        base
226    }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "snake_case")]
231pub enum EntryScanMode {
232    /// Stop after the first symbol (in watchlist order) that produces a candidate.
233    FirstQualifying,
234    /// Legacy: queue every symbol that passes mechanical gates.
235    AllQualifying,
236}
237
238impl Default for EntryScanMode {
239    fn default() -> Self {
240        Self::FirstQualifying
241    }
242}
243
244fn default_entry_attempt_cooldown_minutes() -> u32 {
245    30
246}
247
248fn default_proceed_cache_minutes() -> u32 {
249    45
250}
251
252/// Config-driven entry scan behavior (watchlist order, LLM gate, retry limits).
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
254#[serde(default)]
255pub struct EntryPolicyConfig {
256    pub mode: EntryScanMode,
257    /// When true, `fallback` role symbols are scanned only if no `primary` produced a candidate.
258    pub fallback_only_after_primary_exhausted: bool,
259    /// Minutes before retrying the same candidate after a non-fill entry attempt.
260    pub entry_attempt_cooldown_minutes: u32,
261    /// When true, after thesis redeploy cooldown the exited symbol is scanned first.
262    pub promote_redeploy_symbol: bool,
263    /// Require LLM `proceed` before live entries (uses proceed_cache_minutes between reviews).
264    pub require_llm_proceed: bool,
265    pub proceed_cache_minutes: u32,
266}
267
268impl Default for EntryPolicyConfig {
269    fn default() -> Self {
270        Self {
271            mode: EntryScanMode::FirstQualifying,
272            fallback_only_after_primary_exhausted: true,
273            entry_attempt_cooldown_minutes: default_entry_attempt_cooldown_minutes(),
274            promote_redeploy_symbol: false,
275            require_llm_proceed: true,
276            proceed_cache_minutes: default_proceed_cache_minutes(),
277        }
278    }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
282#[serde(default)]
283pub struct ReEntryAfterStopLoss {
284    pub enabled: bool,
285    pub cooldown_days: u32,
286}
287
288impl Default for ReEntryAfterStopLoss {
289    fn default() -> Self {
290        Self {
291            enabled: true,
292            cooldown_days: 5,
293        }
294    }
295}
296
297#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298pub struct EntryRules {
299    #[serde(default)]
300    pub vertical: VerticalEntryRules,
301    #[serde(default)]
302    pub iron_condor: IronCondorEntryRules,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
306#[serde(default)]
307pub struct VerticalEntryRules {
308    pub r#type: String,
309    pub dte_min: u32,
310    pub dte_max: u32,
311    pub min_credit: f64,
312    pub max_width: f64,
313    pub short_delta_min: f64,
314    pub short_delta_max: f64,
315    /// Minimum modeled POP vs break-even (percent). Omit to skip.
316    #[serde(default)]
317    pub min_pop_pct: Option<f64>,
318    /// Minimum spot cushion above (puts) or below (calls) break-even as % of spot.
319    #[serde(default)]
320    pub min_distance_to_be_pct: Option<f64>,
321    /// Minimum credit / width ratio (percent). Defaults to 12.5 when omitted.
322    #[serde(default)]
323    pub min_credit_to_width_pct: Option<f64>,
324    /// Reject candidates whose short strike sits inside the 1σ expected move.
325    /// Entry-only (unlike `exit_rules.thesis.exit_short_inside_1sigma`). Prefer with
326    /// farther OTM deltas (~0.10–0.16) so some strikes still clear the gate.
327    /// When enabled and chain IV is missing, the gate **fails closed** (rejects).
328    #[serde(default)]
329    pub reject_short_inside_1sigma: bool,
330    /// Minimum chain IV / realized-vol ratio. Omit to skip.
331    /// When set, missing IV or RV **fails closed** (rejects). Typical starting value: 1.15.
332    #[serde(default)]
333    pub min_iv_rv_ratio: Option<f64>,
334    pub max_open_positions: u32,
335    pub max_contracts_per_trade: u32,
336}
337
338impl Default for VerticalEntryRules {
339    fn default() -> Self {
340        Self {
341            r#type: "put_credit".into(),
342            dte_min: 30,
343            dte_max: 45,
344            min_credit: 0.50,
345            max_width: 5.0,
346            short_delta_min: 0.15,
347            short_delta_max: 0.30,
348            min_pop_pct: Some(60.0),
349            min_distance_to_be_pct: Some(3.0),
350            min_credit_to_width_pct: Some(12.5),
351            reject_short_inside_1sigma: false,
352            min_iv_rv_ratio: None,
353            max_open_positions: 3,
354            max_contracts_per_trade: 2,
355        }
356    }
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[serde(default)]
361pub struct IronCondorEntryRules {
362    pub dte_min: u32,
363    pub dte_max: u32,
364    pub min_credit: f64,
365    pub wing_width: f64,
366    pub short_delta: f64,
367    /// Same meaning as `VerticalEntryRules::min_iv_rv_ratio` (fail-closed when set).
368    #[serde(default)]
369    pub min_iv_rv_ratio: Option<f64>,
370    pub max_open_positions: u32,
371    pub max_contracts_per_trade: u32,
372}
373
374impl Default for IronCondorEntryRules {
375    fn default() -> Self {
376        Self {
377            dte_min: 30,
378            dte_max: 45,
379            min_credit: 1.00,
380            wing_width: 5.0,
381            short_delta: 0.16,
382            min_iv_rv_ratio: None,
383            max_open_positions: 2,
384            max_contracts_per_trade: 1,
385        }
386    }
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ProfitGivebackExit {
391    /// Exit when peak unrealized profit reached at least this %.
392    pub peak_profit_min_pct: f64,
393    /// Exit when current profit falls below this % after the peak threshold was met.
394    pub exit_if_below_pct: f64,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize)]
398#[serde(default)]
399pub struct ThesisExitRules {
400    pub enabled: bool,
401    /// Skip thesis exits (not profit/stop/DTE) until the position has been open this long.
402    pub min_hold_minutes: Option<u32>,
403    /// Close when modeled spread POP falls below this (success probability deteriorated).
404    pub min_pop_pct_exit: Option<f64>,
405    /// Close when |short_delta| reaches this (strike no longer comfortably OTM).
406    pub max_short_delta_exit: Option<f64>,
407    /// Close when short leg is within this % OTM of spot (pin / chop risk).
408    pub min_short_otm_pct: Option<f64>,
409    /// Close when short strike sits inside the 1σ expected move toward ITM.
410    /// Prefer false for ~5% OTM credit spreads — distance < 1σ is normal at entry.
411    pub exit_short_inside_1sigma: bool,
412    /// After a thesis exit, skip same-underlying entry scan for this many minutes.
413    pub redeploy_cooldown_minutes: Option<u32>,
414    pub profit_giveback: Option<ProfitGivebackExit>,
415}
416
417impl Default for ThesisExitRules {
418    fn default() -> Self {
419        Self {
420            enabled: false,
421            min_hold_minutes: None,
422            min_pop_pct_exit: None,
423            max_short_delta_exit: None,
424            min_short_otm_pct: None,
425            exit_short_inside_1sigma: false,
426            redeploy_cooldown_minutes: None,
427            profit_giveback: None,
428        }
429    }
430}
431
432/// Defensive roll when a credit vertical hits the mechanical stop.
433/// Close then reopen farther OTM / later DTE (two sequential orders). Off by default.
434#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(default)]
436pub struct RollConfig {
437    pub enabled: bool,
438    /// Minimum DTE remaining on the tested position to attempt a roll.
439    pub min_dte_remaining: u32,
440    /// Short must still be at least this % OTM (do not roll already-ITM / near-ITM shorts).
441    pub min_short_otm_pct: f64,
442    /// Prefer expiry at least this many calendar days beyond the closed DTE.
443    pub min_dte_extension: u32,
444    /// Cap |short_delta| on the replacement (also tightened vs original short delta − 0.04).
445    pub target_short_delta_max: f64,
446    /// Allow net debit up to this % of entry credit: `new_credit - close_debit >= -entry * pct/100`.
447    pub max_debit_pct_of_entry_credit: f64,
448    /// Max successful rolls for a single lineage (`TrackedPosition.rolls_used`).
449    pub max_rolls_per_position: u32,
450    /// Soft account-wide cap on successful rolls per calendar day.
451    pub max_rolls_per_day: u32,
452}
453
454impl Default for RollConfig {
455    fn default() -> Self {
456        Self {
457            enabled: false,
458            min_dte_remaining: 21,
459            min_short_otm_pct: 0.5,
460            min_dte_extension: 7,
461            target_short_delta_max: 0.12,
462            max_debit_pct_of_entry_credit: 25.0,
463            max_rolls_per_position: 1,
464            max_rolls_per_day: 1,
465        }
466    }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize)]
470#[serde(default)]
471pub struct ExitRules {
472    pub profit_target_pct: f64,
473    pub stop_loss_pct: f64,
474    /// Arm the credit-multiple stop only when short OTM% is below this.
475    /// While the short is farther OTM than this cushion, ignore mark-based stops
476    /// (profit target, DTE, and thesis exits still apply). `None` = always arm (legacy).
477    #[serde(default)]
478    pub stop_loss_require_short_otm_below_pct: Option<f64>,
479    pub dte_close: u32,
480    pub thesis: ThesisExitRules,
481    /// Prefer a managed roll over a hard stop when eligible (verticals only).
482    #[serde(default)]
483    pub roll: RollConfig,
484}
485
486impl Default for ExitRules {
487    fn default() -> Self {
488        Self {
489            profit_target_pct: 50.0,
490            stop_loss_pct: 200.0,
491            stop_loss_require_short_otm_below_pct: None,
492            dte_close: 21,
493            thesis: ThesisExitRules::default(),
494            roll: RollConfig::default(),
495        }
496    }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
500#[serde(default)]
501pub struct RiskConfig {
502    pub max_portfolio_risk_usd: f64,
503    pub max_risk_per_trade_usd: f64,
504    /// Soft churn cap. Prefer `max_open_positions` + portfolio/trade risk as hard gates.
505    /// `0` = unlimited (no daily trade-count pause).
506    pub max_trades_per_day: u32,
507    pub allowed_underlyings: Vec<String>,
508    /// Optional per-symbol open-slot caps (e.g. SPY: 2, IWM: 1). Keys are case-insensitive.
509    #[serde(default)]
510    pub max_open_per_underlying: std::collections::HashMap<String, u32>,
511    #[serde(default)]
512    pub re_entry_after_stop_loss: ReEntryAfterStopLoss,
513    /// Manual hard pause: any non-empty list blocks **all** new entries until cleared.
514    /// Not a calendar — use `blocked_dates` for FOMC/CPI/NFP blackouts.
515    pub blocked_events: Vec<String>,
516    /// Date-aware entry blackout (macro/Fed calendar). Blocks when
517    /// `event_date - lead_days <= today <= event_date` (inclusive).
518    #[serde(default)]
519    pub blocked_dates: Vec<BlockedDate>,
520    /// Correlated underlyings — cap concurrent open positions per group.
521    #[serde(default)]
522    pub correlation_groups: Vec<CorrelationGroupConfig>,
523    /// Halt new entries when sleeve drawdown from HWM reaches this % (e.g. 15.0).
524    /// `null` / omit / `0` = disabled. Exits always continue.
525    #[serde(default)]
526    pub max_drawdown_halt_pct: Option<f64>,
527    /// Sleeve equity base for drawdown HWM. Defaults to `max_portfolio_risk_usd`
528    /// (or sim starting budget). Set to the real options cash sleeve (e.g. 4000).
529    #[serde(default)]
530    pub drawdown_sleeve_usd: Option<f64>,
531}
532
533/// One calendar blackout for new option entries.
534#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
535pub struct BlockedDate {
536    /// Event date `YYYY-MM-DD` (America/New_York calendar day).
537    pub date: String,
538    /// Block starting this many calendar days before `date` (0 = event day only).
539    #[serde(default)]
540    pub lead_days: u32,
541    /// Human label for skip messages / tick JSON (e.g. `FOMC`, `CPI`).
542    #[serde(default)]
543    pub label: String,
544}
545
546/// Correlated symbols — cap concurrent open positions per group.
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548#[serde(default)]
549pub struct CorrelationGroupConfig {
550    pub name: String,
551    pub symbols: Vec<String>,
552    /// Max open positions from this group at once (`0` = no cap for this group).
553    pub max_open: u32,
554}
555
556impl Default for CorrelationGroupConfig {
557    fn default() -> Self {
558        Self {
559            name: String::new(),
560            symbols: vec![],
561            max_open: 1,
562        }
563    }
564}
565
566impl Default for RiskConfig {
567    fn default() -> Self {
568        Self {
569            max_portfolio_risk_usd: 10_000.0,
570            max_risk_per_trade_usd: 2_000.0,
571            max_trades_per_day: 3,
572            allowed_underlyings: vec!["SPY".into(), "QQQ".into(), "IWM".into()],
573            max_open_per_underlying: std::collections::HashMap::new(),
574            re_entry_after_stop_loss: ReEntryAfterStopLoss::default(),
575            blocked_events: vec![],
576            blocked_dates: vec![],
577            correlation_groups: vec![],
578            max_drawdown_halt_pct: None,
579            drawdown_sleeve_usd: None,
580        }
581    }
582}
583
584impl RiskConfig {
585    pub fn max_open_for_underlying(&self, underlying: &str) -> Option<u32> {
586        self.max_open_per_underlying
587            .iter()
588            .find(|(k, _)| k.eq_ignore_ascii_case(underlying))
589            .map(|(_, v)| *v)
590    }
591
592    /// Labels of `blocked_dates` entries active on `today` (inclusive lead window).
593    pub fn active_blocked_date_labels(&self, today: chrono::NaiveDate) -> Vec<String> {
594        self.blocked_dates
595            .iter()
596            .filter_map(|b| {
597                let event = chrono::NaiveDate::parse_from_str(b.date.trim(), "%Y-%m-%d").ok()?;
598                let start = event - chrono::Duration::days(b.lead_days as i64);
599                if today >= start && today <= event {
600                    let label = if b.label.trim().is_empty() {
601                        b.date.clone()
602                    } else {
603                        format!("{} ({})", b.label.trim(), b.date)
604                    };
605                    Some(label)
606                } else {
607                    None
608                }
609            })
610            .collect()
611    }
612
613    /// Group containing `underlying`, if any.
614    pub fn correlation_group_for(&self, underlying: &str) -> Option<&CorrelationGroupConfig> {
615        self.correlation_groups.iter().find(|g| {
616            g.symbols
617                .iter()
618                .any(|s| s.eq_ignore_ascii_case(underlying))
619        })
620    }
621}
622
623/// Maps market regime → preferred options structure.
624#[derive(Debug, Clone, Serialize, Deserialize)]
625#[serde(default)]
626pub struct OptionsRegimeConfig {
627    pub enabled: bool,
628    pub benchmark_symbol: String,
629    pub vix_symbol: String,
630    pub vix_low: f64,
631    pub vix_high: f64,
632    /// Pause all new entries when VIX is at or above this (hostile / crash regime).
633    pub pause_entries_vix_above: f64,
634    /// Pause all new entries when VIX is at or below this (crushed-vol / cheap insurance).
635    /// Omit or null to disable. Independent of `vix_low` (classification only).
636    #[serde(default)]
637    pub pause_entries_vix_below: Option<f64>,
638    /// Lookback days for realized-vol used by `min_iv_rv_ratio` entry gates.
639    #[serde(default = "default_realized_vol_lookback")]
640    pub realized_vol_lookback: usize,
641    /// regime class → preferred strategy: `put_credit`, `call_credit`, `iron_condor`, `pause`.
642    #[serde(default)]
643    pub strategy_map: std::collections::HashMap<String, String>,
644}
645
646fn default_realized_vol_lookback() -> usize {
647    20
648}
649
650impl Default for OptionsRegimeConfig {
651    fn default() -> Self {
652        let mut strategy_map = std::collections::HashMap::new();
653        strategy_map.insert("low_vol_trend".into(), "put_credit".into());
654        strategy_map.insert("elevated_vol".into(), "put_credit".into());
655        strategy_map.insert("high_vol_chop".into(), "iron_condor".into());
656        strategy_map.insert("bearish_trend".into(), "call_credit".into());
657        strategy_map.insert("hostile".into(), "pause".into());
658        strategy_map.insert("neutral".into(), "put_credit".into());
659        Self {
660            enabled: false,
661            benchmark_symbol: "SPY".into(),
662            vix_symbol: "$VIX".into(),
663            vix_low: 14.0,
664            vix_high: 25.0,
665            pause_entries_vix_above: 30.0,
666            pause_entries_vix_below: None,
667            realized_vol_lookback: default_realized_vol_lookback(),
668            strategy_map,
669        }
670    }
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize)]
674#[serde(default)]
675pub struct ExecutionConfig {
676    pub order_type: String,
677    pub require_preview: bool,
678    pub wait_for_fill: bool,
679    pub fill_timeout_seconds: u64,
680    /// Broker-resident GTC profit-target close order, placed after each live entry fill.
681    /// Schwab rejects stop triggers on multi-leg option orders, so this only covers the
682    /// profit-target side of exit protection — stop-loss/DTE-close remain mechanical
683    /// (see docs/OPTIONS_RULES.md).
684    #[serde(default)]
685    pub protective_order: ProtectiveOrderConfig,
686}
687
688impl Default for ExecutionConfig {
689    fn default() -> Self {
690        Self {
691            order_type: "limit".into(),
692            require_preview: true,
693            wait_for_fill: true,
694            fill_timeout_seconds: 300,
695            protective_order: ProtectiveOrderConfig::default(),
696        }
697    }
698}
699
700#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
701#[serde(default)]
702pub struct ProtectiveOrderConfig {
703    pub enabled: bool,
704    /// Placement attempts before giving up (the reconcile pass keeps retrying afterward).
705    pub max_attempts: u32,
706    pub max_seconds: u64,
707}
708
709impl Default for ProtectiveOrderConfig {
710    fn default() -> Self {
711        Self {
712            enabled: true,
713            max_attempts: 3,
714            max_seconds: 30,
715        }
716    }
717}
718
719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(rename_all = "lowercase")]
721pub enum LlmPhase {
722    Selection,
723    Monitor,
724    OvernightDigest,
725}
726
727#[derive(Debug, Clone, Serialize, Deserialize)]
728#[serde(default)]
729pub struct LlmConfig {
730    /// Enable OpenRouter LLM reviews during agent ticks.
731    pub enabled: bool,
732    /// High-intelligence model for entry veto when rules produce candidate trades.
733    pub selection_model: String,
734    /// Cost-efficient model for periodic open-position reviews.
735    pub monitor_model: String,
736    /// Model with web search for macro/event context (selection phase, periodic).
737    pub web_model: String,
738    /// Legacy fallback if selection_model / monitor_model are empty in old configs.
739    #[serde(default, skip_serializing_if = "String::is_empty")]
740    pub model: String,
741    /// Run LLM review every N agent ticks when flat (no open positions).
742    /// With open positions, `effective_monitor_review_ticks` applies to both selection and monitor.
743    pub review_every_ticks: u64,
744    /// Monitor-phase interval when open spreads are above `dte_close` (long-dated / low gamma).
745    /// Falls back to `review_every_ticks` when unset or when any position is in the gamma window.
746    #[serde(default)]
747    pub monitor_review_every_ticks: Option<u64>,
748    /// Use web_model every N selection/monitor LLM reviews (when applicable).
749    pub web_research_every_reviews: u64,
750    pub max_tokens: u32,
751    /// When true, LLM can veto new entries when it recommends defer/skip.
752    pub veto_entries: bool,
753    /// When true, high-urgency LLM close recommendations trigger exits.
754    pub allow_llm_exits: bool,
755    /// Per-phase role, instructions, and strategy context (configurable per rules file).
756    #[serde(default)]
757    pub prompts: LlmPromptsConfig,
758}
759
760/// Configurable LLM instructions per agent strategy. Empty fields use built-in defaults.
761#[derive(Debug, Clone, Default, Serialize, Deserialize)]
762#[serde(default)]
763pub struct LlmPromptsConfig {
764    /// System instructions for entry/selection phase (role, risk posture, what to optimize).
765    pub selection: String,
766    /// Optional override when web_model is used for selection; falls back to `selection`.
767    pub selection_web: String,
768    /// Extra strategy context prepended to the user message during selection.
769    pub selection_context: String,
770    /// System instructions for open-position monitoring phase.
771    pub monitor: String,
772    /// Extra strategy context prepended to the user message during monitoring.
773    pub monitor_context: String,
774    /// System instructions for overnight web digest (market closed).
775    pub overnight: String,
776    /// Extra context for overnight digest user message.
777    pub overnight_context: String,
778}
779
780pub fn default_selection_prompt() -> &'static str {
781    "You are an expert options income trader specializing in defined-risk credit spreads \
782     and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
783     rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
784     and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
785     favorable within the strategy context provided."
786}
787
788pub fn default_selection_web_prompt() -> &'static str {
789    "You are an expert options income trader evaluating whether to OPEN new spreads. \
790     Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
791     and macro risk via web context. When candidate_entries is non-empty, ground strike and \
792     greek analysis in each candidate's market_context — web research supplements but does \
793     not replace chain fields. Be conservative: defer or skip if event or volatility \
794     risk outweighs the premium collected."
795}
796
797/// Appended to every selection-phase system prompt (custom YAML included).
798pub fn selection_market_context_guardrails() -> &'static str {
799    "CHAIN DATA GUARDRAILS (binding):\n\
800     - candidate_entries[] are built only after a successful Schwab chain fetch. Each item \
801     includes market_context (underlying_price, short_delta, chain_iv, spread_pop_pct, \
802     break_even_price, expected_move_1sigma, credit_to_width_pct, DTE, etc.).\n\
803     - ivr_available: false means IV Rank is not provided — NOT missing chain data. Use chain_iv.\n\
804     - FORBIDDEN defer/skip reasons: \"lack of live chain data\", \"missing greeks\", \
805     \"no IV data\", or similar vague claims when market_context has underlying_price AND \
806     short_delta.\n\
807     - If you believe data is incomplete, cite the exact null/missing JSON field (e.g. \
808     \"short_theta is null\") and do not veto solely for absent IV Rank, theta, or gamma.\n\
809     - Defer or skip based on macro, event risk, poor premium/delta/strike placement, or \
810     extreme chain_iv — not invented data gaps.\n\
811     - If candidate_entries is empty, recommend skip with \"no mechanical candidates\" — do \
812     not claim chain API failure."
813}
814
815pub fn default_monitor_prompt() -> &'static str {
816    "You are monitoring existing open option spreads. Mechanical exits (profit target, \
817     stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
818     Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
819     current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
820     CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
821     Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
822     in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
823     stop_triggered is false, the position has NOT hit the mechanical stop.\n\
824     Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
825     Use market_context for recommendations:\n\
826     - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
827     short_otm_pct > 3% for put credits)\n\
828     - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
829     or developing macro/event risk\n\
830     - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
831     urgency high only for imminent assignment/gap risk through short strike\n\
832     For 30-45 DTE income trades: keep market_commentary to 1-2 sentences unless \
833     delta, POP, P/L, or event risk changed materially since the last review. Do not \
834     repeat overnight playbook themes when mechanical_rules show no triggered exit.\n\
835     If market_context is missing but market_context_error is set, rely on mechanical_rules \
836     and recommend hold unless mechanical_rules indicate a triggered exit.\n\
837     For new_entries during monitor phase: recommend proceed only when candidate_entries is \
838     non-empty; otherwise use skip with brief reasoning.\n\
839     Do not recommend close for routine profit — mechanics handle 50% target."
840}
841
842pub fn default_overnight_prompt() -> &'static str {
843    "The US options market is CLOSED. Research overnight and pre-market news (futures, \
844     macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
845     Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
846     whether any open spread thesis is broken, and suggested actions at the open \
847     (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
848     For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
849     thesis-breaking developments."
850}
851
852impl LlmPromptsConfig {
853    pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
854        if use_web {
855            if !self.selection_web.is_empty() {
856                return &self.selection_web;
857            }
858            if !self.selection.is_empty() {
859                return &self.selection;
860            }
861            return default_selection_web_prompt();
862        }
863        if !self.selection.is_empty() {
864            return &self.selection;
865        }
866        default_selection_prompt()
867    }
868
869    pub fn effective_monitor_instructions(&self) -> &str {
870        if !self.monitor.is_empty() {
871            return &self.monitor;
872        }
873        default_monitor_prompt()
874    }
875
876    pub fn effective_overnight_instructions(&self) -> &str {
877        if !self.overnight.is_empty() {
878            return &self.overnight;
879        }
880        default_overnight_prompt()
881    }
882
883    pub fn effective_context(&self, phase: LlmPhase) -> &str {
884        match phase {
885            LlmPhase::Selection => &self.selection_context,
886            LlmPhase::Monitor => &self.monitor_context,
887            LlmPhase::OvernightDigest => &self.overnight_context,
888        }
889    }
890}
891
892impl Default for LlmConfig {
893    fn default() -> Self {
894        Self {
895            enabled: false,
896            selection_model: "anthropic/claude-sonnet-4".into(),
897            monitor_model: "google/gemini-2.5-flash".into(),
898            web_model: "perplexity/sonar".into(),
899            model: String::new(),
900            review_every_ticks: 5,
901            monitor_review_every_ticks: None,
902            web_research_every_reviews: 3,
903            max_tokens: 2000,
904            veto_entries: true,
905            allow_llm_exits: false,
906            prompts: LlmPromptsConfig::default(),
907        }
908    }
909}
910
911impl LlmConfig {
912    pub fn effective_selection_model(&self) -> &str {
913        if !self.selection_model.is_empty() {
914            &self.selection_model
915        } else if !self.model.is_empty() {
916            &self.model
917        } else {
918            "anthropic/claude-sonnet-4"
919        }
920    }
921
922    pub fn effective_monitor_model(&self) -> &str {
923        if !self.monitor_model.is_empty() {
924            &self.monitor_model
925        } else if !self.model.is_empty() {
926            &self.model
927        } else {
928            "google/gemini-2.5-flash"
929        }
930    }
931
932    /// Resolve which OpenRouter model to call for this phase.
933    pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
934        if use_web {
935            return &self.web_model;
936        }
937        match phase {
938            LlmPhase::Selection => self.effective_selection_model(),
939            LlmPhase::Monitor => self.effective_monitor_model(),
940            LlmPhase::OvernightDigest => &self.web_model,
941        }
942    }
943
944    /// Monitor LLM cadence: slower above gamma window (DTE > dte_close), faster inside it.
945    pub fn effective_monitor_review_ticks(
946        &self,
947        min_open_dte: Option<i64>,
948        dte_close: u32,
949    ) -> u64 {
950        let fast = self.review_every_ticks.max(1);
951        let slow = self.monitor_review_every_ticks.unwrap_or(30).max(1);
952        match min_open_dte {
953            Some(dte) if dte > dte_close as i64 => slow,
954            _ => fast,
955        }
956    }
957
958    /// Shared cadence for selection and monitor LLM reviews.
959    pub fn effective_llm_review_ticks(
960        &self,
961        has_open_positions: bool,
962        min_open_dte: Option<i64>,
963        dte_close: u32,
964    ) -> u64 {
965        if has_open_positions {
966            self.effective_monitor_review_ticks(min_open_dte, dte_close)
967        } else {
968            self.review_every_ticks.max(1)
969        }
970    }
971
972    pub fn monitor_interval_minutes(
973        &self,
974        tick_interval_seconds: u64,
975        min_open_dte: Option<i64>,
976        dte_close: u32,
977    ) -> u64 {
978        let secs = self
979            .effective_monitor_review_ticks(min_open_dte, dte_close)
980            .saturating_mul(tick_interval_seconds.max(1));
981        (secs / 60).max(1)
982    }
983}
984
985#[derive(Debug, Clone, Default, Serialize, Deserialize)]
986#[serde(default)]
987pub struct NotifyConfig {
988    pub telegram: TelegramNotifyConfig,
989}
990
991#[derive(Debug, Clone, Serialize, Deserialize)]
992#[serde(default)]
993pub struct TelegramNotifyConfig {
994    pub enabled: bool,
995    /// Notify on every tick summary (can be noisy).
996    pub notify_every_tick: bool,
997    /// Notify on fills, exits, and LLM updates (when urgency/digest rules allow).
998    pub notify_on_actions: bool,
999    /// Send routine LLM status digests (same recommendation is not repeated).
1000    pub llm_notify_digest: bool,
1001    /// Minimum minutes between routine LLM digests (0 = urgent-only).
1002    pub llm_digest_interval_minutes: u64,
1003    /// Send immediately when LLM says proceed, high urgency, or urgent close.
1004    pub llm_notify_urgent: bool,
1005    /// Do not repeat the same urgent LLM message within this many minutes.
1006    pub llm_urgent_cooldown_minutes: u64,
1007}
1008
1009impl Default for TelegramNotifyConfig {
1010    fn default() -> Self {
1011        Self {
1012            enabled: false,
1013            notify_every_tick: false,
1014            notify_on_actions: true,
1015            llm_notify_digest: true,
1016            llm_digest_interval_minutes: 60,
1017            llm_notify_urgent: true,
1018            llm_urgent_cooldown_minutes: 30,
1019        }
1020    }
1021}
1022
1023impl RulesConfig {
1024    pub fn load(path: &Path) -> Result<Self> {
1025        let content = fs::read_to_string(path)
1026            .with_context(|| format!("read rules file {}", path.display()))?;
1027        let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
1028            serde_json::from_str(&content)?
1029        } else {
1030            serde_yaml::from_str(&content)?
1031        };
1032        rules.validate()?;
1033        Ok(rules)
1034    }
1035
1036    pub fn validate(&self) -> Result<()> {
1037        if self.version != RULES_VERSION {
1038            anyhow::bail!(
1039                "unsupported rules version {} (expected {})",
1040                self.version,
1041                RULES_VERSION
1042            );
1043        }
1044        if self.agent_id.trim().is_empty() {
1045            anyhow::bail!("agent_id is required");
1046        }
1047        if self.accounts.is_empty() {
1048            anyhow::bail!("at least one account is required");
1049        }
1050        for acct in &self.accounts {
1051            if acct.hash.trim().is_empty() {
1052                anyhow::bail!("account hash is required");
1053            }
1054        }
1055        if self.watchlist.is_empty() {
1056            anyhow::bail!("watchlist must not be empty");
1057        }
1058        if !self.schedule.market_hours_only {
1059            anyhow::bail!("options agent requires schedule.market_hours_only=true");
1060        }
1061        if !self.execution.order_type.eq_ignore_ascii_case("limit") {
1062            anyhow::bail!("options agent requires execution.order_type=limit");
1063        }
1064        if !self.execution.require_preview {
1065            anyhow::bail!("options agent requires execution.require_preview=true");
1066        }
1067        Ok(())
1068    }
1069
1070    pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
1071        self.accounts.iter().filter(|a| a.enabled)
1072    }
1073
1074    pub fn watchlist_items(&self) -> Vec<WatchlistItemConfig> {
1075        self.watchlist.iter().map(WatchlistEntry::to_item).collect()
1076    }
1077
1078    pub fn watchlist_symbols(&self) -> Vec<String> {
1079        self.watchlist_items()
1080            .into_iter()
1081            .map(|i| i.symbol.to_uppercase())
1082            .collect()
1083    }
1084
1085    pub fn effective_vertical_entry(&self, symbol: &str) -> VerticalEntryRules {
1086        let base = self.entry_rules.vertical.clone();
1087        if let Some(item) = self
1088            .watchlist_items()
1089            .into_iter()
1090            .find(|i| i.symbol.eq_ignore_ascii_case(symbol))
1091        {
1092            return item.overrides.apply_to(base);
1093        }
1094        base
1095    }
1096}
1097
1098pub fn rules_json_schema() -> Value {
1099    json!({
1100        "$schema": "https://json-schema.org/draft/2020-12/schema",
1101        "title": "Schwab options agent rules",
1102        "type": "object",
1103        "required": ["version", "agent_id", "accounts", "watchlist"],
1104        "properties": {
1105            "version": { "const": 1 },
1106            "agent_id": { "type": "string" },
1107            "accounts": {
1108                "type": "array",
1109                "items": {
1110                    "type": "object",
1111                    "required": ["hash"],
1112                    "properties": {
1113                        "hash": { "type": "string" },
1114                        "label": { "type": "string" },
1115                        "type": { "enum": ["margin", "ira", "cash"] },
1116                        "enabled": { "type": "boolean" }
1117                    }
1118                }
1119            },
1120            "schedule": {
1121                "type": "object",
1122                "properties": {
1123                    "tick_interval_seconds": { "type": "integer", "minimum": 5 },
1124                    "market_hours_only": { "type": "boolean" },
1125                    "timezone": { "type": "string" },
1126                    "overnight": {
1127                        "type": "object",
1128                        "properties": {
1129                            "enabled": { "type": "boolean" },
1130                            "tick_interval_seconds": { "type": "integer", "minimum": 300 },
1131                            "web_digest": { "type": "boolean" },
1132                            "skip_llm_when_flat": { "type": "boolean" },
1133                            "alert_on_risk_only": { "type": "boolean" }
1134                        }
1135                    }
1136                }
1137            },
1138            "strategies": {
1139                "type": "object",
1140                "properties": {
1141                    "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
1142                    "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
1143                }
1144            },
1145            "watchlist": {
1146                "type": "array",
1147                "items": {
1148                    "oneOf": [
1149                        { "type": "string" },
1150                        {
1151                            "type": "object",
1152                            "required": ["symbol"],
1153                            "properties": {
1154                                "symbol": { "type": "string" },
1155                                "role": { "enum": ["primary", "fallback"] },
1156                                "min_credit": { "type": "number" },
1157                                "min_credit_to_width_pct": { "type": "number" }
1158                            }
1159                        }
1160                    ]
1161                }
1162            },
1163            "entry_policy": { "type": "object" },
1164            "entry_rules": { "type": "object" },
1165            "exit_rules": { "type": "object" },
1166            "risk": { "type": "object" },
1167            "execution": { "type": "object" }
1168        }
1169    })
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175
1176    #[test]
1177    fn watchlist_item_overrides_merge_into_vertical_entry() {
1178        let yaml = r#"
1179version: 1
1180agent_id: t
1181accounts:
1182  - hash: ABC
1183    enabled: true
1184watchlist:
1185  - symbol: SPY
1186    role: primary
1187    min_credit: 0.12
1188  - symbol: IWM
1189    role: fallback
1190    min_credit: 0.30
1191entry_rules:
1192  vertical:
1193    min_credit: 0.25
1194"#;
1195        let rules: RulesConfig = serde_yaml::from_str(yaml).unwrap();
1196        assert!((rules.effective_vertical_entry("SPY").min_credit - 0.12).abs() < f64::EPSILON);
1197        assert!((rules.effective_vertical_entry("IWM").min_credit - 0.30).abs() < f64::EPSILON);
1198        assert_eq!(rules.watchlist_items()[1].role, WatchlistRole::Fallback);
1199    }
1200
1201    #[test]
1202    fn validates_minimal_rules() {
1203        let rules = RulesConfig {
1204            version: 1,
1205            agent_id: "test".into(),
1206            accounts: vec![RulesAccount {
1207                hash: "ABC".into(),
1208                label: None,
1209                r#type: AccountType::Margin,
1210                enabled: true,
1211            }],
1212            schedule: ScheduleConfig::default(),
1213            strategies: StrategiesToggle::default(),
1214            watchlist: vec![WatchlistEntry::from("SPY")],
1215            entry_policy: EntryPolicyConfig::default(),
1216            entry_rules: EntryRules::default(),
1217            exit_rules: ExitRules::default(),
1218            risk: RiskConfig::default(),
1219            regime: OptionsRegimeConfig::default(),
1220            execution: ExecutionConfig::default(),
1221            llm: LlmConfig::default(),
1222            notify: NotifyConfig::default(),
1223            simulation: None,
1224        };
1225        rules.validate().unwrap();
1226    }
1227
1228    #[test]
1229    fn loads_example_rules_yaml() {
1230        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1231            .join("../../rules/options-rules.example.yaml");
1232        if path.exists() {
1233            let rules = RulesConfig::load(&path).unwrap();
1234            assert_eq!(rules.agent_id, "spy-income-v1");
1235        }
1236    }
1237
1238    #[test]
1239    fn blocked_dates_respect_lead_window() {
1240        let risk = RiskConfig {
1241            blocked_dates: vec![
1242                BlockedDate {
1243                    date: "2026-09-16".into(),
1244                    lead_days: 1,
1245                    label: "FOMC".into(),
1246                },
1247                BlockedDate {
1248                    date: "2026-09-11".into(),
1249                    lead_days: 0,
1250                    label: "CPI".into(),
1251                },
1252            ],
1253            ..Default::default()
1254        };
1255        let sep15 = chrono::NaiveDate::from_ymd_opt(2026, 9, 15).unwrap();
1256        let sep16 = chrono::NaiveDate::from_ymd_opt(2026, 9, 16).unwrap();
1257        let sep14 = chrono::NaiveDate::from_ymd_opt(2026, 9, 14).unwrap();
1258        let sep11 = chrono::NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
1259        let sep10 = chrono::NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
1260
1261        assert!(risk
1262            .active_blocked_date_labels(sep15)
1263            .iter()
1264            .any(|l| l.contains("FOMC")));
1265        assert!(risk
1266            .active_blocked_date_labels(sep16)
1267            .iter()
1268            .any(|l| l.contains("FOMC")));
1269        assert!(risk.active_blocked_date_labels(sep14).is_empty());
1270        assert!(risk
1271            .active_blocked_date_labels(sep11)
1272            .iter()
1273            .any(|l| l.contains("CPI")));
1274        assert!(risk.active_blocked_date_labels(sep10).is_empty());
1275    }
1276
1277    #[test]
1278    fn llm_config_resolves_phase_models() {
1279        let cfg = LlmConfig::default();
1280        assert_eq!(
1281            cfg.resolve_model(LlmPhase::Selection, false),
1282            "anthropic/claude-sonnet-4"
1283        );
1284        assert_eq!(
1285            cfg.resolve_model(LlmPhase::Monitor, false),
1286            "google/gemini-2.5-flash"
1287        );
1288        assert_eq!(
1289            cfg.resolve_model(LlmPhase::Selection, true),
1290            "perplexity/sonar"
1291        );
1292    }
1293
1294    #[test]
1295    fn custom_selection_prompt_overrides_default() {
1296        let prompts = LlmPromptsConfig {
1297            selection: "Aggressive premium seller.".into(),
1298            ..Default::default()
1299        };
1300        assert!(prompts
1301            .effective_selection_instructions(false)
1302            .contains("Aggressive"));
1303    }
1304
1305    #[test]
1306    fn selection_web_prompt_used_when_set() {
1307        let prompts = LlmPromptsConfig {
1308            selection: "conservative".into(),
1309            selection_web: "web aggressive".into(),
1310            ..Default::default()
1311        };
1312        assert_eq!(
1313            prompts.effective_selection_instructions(true),
1314            "web aggressive"
1315        );
1316    }
1317
1318    #[test]
1319    fn selection_guardrails_are_documented() {
1320        let g = selection_market_context_guardrails();
1321        assert!(g.contains("FORBIDDEN"));
1322        assert!(g.contains("underlying_price"));
1323        assert!(g.contains("ivr_available"));
1324    }
1325
1326    #[test]
1327    fn monitor_review_slower_above_gamma_window() {
1328        let llm = LlmConfig {
1329            review_every_ticks: 5,
1330            monitor_review_every_ticks: Some(45),
1331            ..Default::default()
1332        };
1333        assert_eq!(llm.effective_monitor_review_ticks(Some(30), 21), 45);
1334        assert_eq!(llm.effective_monitor_review_ticks(Some(18), 21), 5);
1335    }
1336
1337    #[test]
1338    fn llm_review_ticks_use_monitor_cadence_with_open_positions() {
1339        let llm = LlmConfig {
1340            review_every_ticks: 5,
1341            monitor_review_every_ticks: Some(45),
1342            ..Default::default()
1343        };
1344        assert_eq!(llm.effective_llm_review_ticks(false, None, 21), 5);
1345        assert_eq!(llm.effective_llm_review_ticks(true, Some(30), 21), 45);
1346        assert_eq!(llm.effective_llm_review_ticks(true, Some(18), 21), 5);
1347    }
1348}