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)]
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 #[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 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
88#[serde(default)]
89pub struct OvernightConfig {
90 pub enabled: bool,
92 pub tick_interval_seconds: u64,
94 pub web_digest: bool,
96 pub skip_llm_when_flat: bool,
98 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#[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#[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 FirstQualifying,
234 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
254#[serde(default)]
255pub struct EntryPolicyConfig {
256 pub mode: EntryScanMode,
257 pub fallback_only_after_primary_exhausted: bool,
259 pub entry_attempt_cooldown_minutes: u32,
261 pub promote_redeploy_symbol: bool,
263 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 #[serde(default)]
317 pub min_pop_pct: Option<f64>,
318 #[serde(default)]
320 pub min_distance_to_be_pct: Option<f64>,
321 #[serde(default)]
323 pub min_credit_to_width_pct: Option<f64>,
324 #[serde(default)]
329 pub reject_short_inside_1sigma: bool,
330 #[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 #[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 pub peak_profit_min_pct: f64,
393 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 pub min_hold_minutes: Option<u32>,
403 pub min_pop_pct_exit: Option<f64>,
405 pub max_short_delta_exit: Option<f64>,
407 pub min_short_otm_pct: Option<f64>,
409 pub exit_short_inside_1sigma: bool,
412 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#[derive(Debug, Clone, Serialize, Deserialize)]
435#[serde(default)]
436pub struct RollConfig {
437 pub enabled: bool,
438 pub min_dte_remaining: u32,
440 pub min_short_otm_pct: f64,
442 pub min_dte_extension: u32,
444 pub target_short_delta_max: f64,
446 pub max_debit_pct_of_entry_credit: f64,
448 pub max_rolls_per_position: u32,
450 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 #[serde(default)]
478 pub stop_loss_require_short_otm_below_pct: Option<f64>,
479 pub dte_close: u32,
480 pub thesis: ThesisExitRules,
481 #[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 pub max_trades_per_day: u32,
507 pub allowed_underlyings: Vec<String>,
508 #[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 pub blocked_events: Vec<String>,
516 #[serde(default)]
519 pub blocked_dates: Vec<BlockedDate>,
520 #[serde(default)]
522 pub correlation_groups: Vec<CorrelationGroupConfig>,
523 #[serde(default)]
526 pub max_drawdown_halt_pct: Option<f64>,
527 #[serde(default)]
530 pub drawdown_sleeve_usd: Option<f64>,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
535pub struct BlockedDate {
536 pub date: String,
538 #[serde(default)]
540 pub lead_days: u32,
541 #[serde(default)]
543 pub label: String,
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548#[serde(default)]
549pub struct CorrelationGroupConfig {
550 pub name: String,
551 pub symbols: Vec<String>,
552 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 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 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#[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 pub pause_entries_vix_above: f64,
634 #[serde(default)]
637 pub pause_entries_vix_below: Option<f64>,
638 #[serde(default = "default_realized_vol_lookback")]
640 pub realized_vol_lookback: usize,
641 #[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 #[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 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 pub enabled: bool,
732 pub selection_model: String,
734 pub monitor_model: String,
736 pub web_model: String,
738 #[serde(default, skip_serializing_if = "String::is_empty")]
740 pub model: String,
741 pub review_every_ticks: u64,
744 #[serde(default)]
747 pub monitor_review_every_ticks: Option<u64>,
748 pub web_research_every_reviews: u64,
750 pub max_tokens: u32,
751 pub veto_entries: bool,
753 pub allow_llm_exits: bool,
755 #[serde(default)]
757 pub prompts: LlmPromptsConfig,
758}
759
760#[derive(Debug, Clone, Default, Serialize, Deserialize)]
762#[serde(default)]
763pub struct LlmPromptsConfig {
764 pub selection: String,
766 pub selection_web: String,
768 pub selection_context: String,
770 pub monitor: String,
772 pub monitor_context: String,
774 pub overnight: String,
776 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
797pub 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 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 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 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 pub notify_every_tick: bool,
997 pub notify_on_actions: bool,
999 pub llm_notify_digest: bool,
1001 pub llm_digest_interval_minutes: u64,
1003 pub llm_notify_urgent: bool,
1005 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}