1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7
8pub const RULES_VERSION: u32 = 1;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RulesConfig {
12 pub version: u32,
13 pub agent_id: String,
14 #[serde(default)]
15 pub accounts: Vec<RulesAccount>,
16 #[serde(default)]
17 pub schedule: ScheduleConfig,
18 #[serde(default)]
19 pub strategies: StrategiesToggle,
20 #[serde(default)]
21 pub watchlist: Vec<String>,
22 #[serde(default)]
23 pub entry_rules: EntryRules,
24 #[serde(default)]
25 pub exit_rules: ExitRules,
26 #[serde(default)]
27 pub risk: RiskConfig,
28 #[serde(default)]
29 pub execution: ExecutionConfig,
30 #[serde(default)]
31 pub llm: LlmConfig,
32 #[serde(default)]
33 pub notify: NotifyConfig,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RulesAccount {
38 pub hash: String,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub label: Option<String>,
41 #[serde(default)]
42 pub r#type: AccountType,
43 #[serde(default = "default_true")]
44 pub enabled: bool,
45}
46
47fn default_true() -> bool {
48 true
49}
50
51#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "lowercase")]
53pub enum AccountType {
54 #[default]
55 Margin,
56 Ira,
57 Cash,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct ScheduleConfig {
63 pub tick_interval_seconds: u64,
64 pub market_hours_only: bool,
65 pub timezone: String,
66 #[serde(default)]
67 pub overnight: OvernightConfig,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(default)]
73pub struct OvernightConfig {
74 pub enabled: bool,
76 pub tick_interval_seconds: u64,
78 pub web_digest: bool,
80 pub skip_llm_when_flat: bool,
82 pub alert_on_risk_only: bool,
84}
85
86impl Default for OvernightConfig {
87 fn default() -> Self {
88 Self {
89 enabled: false,
90 tick_interval_seconds: 3600,
91 web_digest: true,
92 skip_llm_when_flat: true,
93 alert_on_risk_only: true,
94 }
95 }
96}
97
98impl Default for ScheduleConfig {
99 fn default() -> Self {
100 Self {
101 tick_interval_seconds: 60,
102 market_hours_only: true,
103 timezone: "America/New_York".into(),
104 overnight: OvernightConfig::default(),
105 }
106 }
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct StrategiesToggle {
111 #[serde(default)]
112 pub vertical: StrategyEnabled,
113 #[serde(default)]
114 pub iron_condor: StrategyEnabled,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
118pub struct StrategyEnabled {
119 #[serde(default)]
120 pub enabled: bool,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
124pub struct EntryRules {
125 #[serde(default)]
126 pub vertical: VerticalEntryRules,
127 #[serde(default)]
128 pub iron_condor: IronCondorEntryRules,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(default)]
133pub struct VerticalEntryRules {
134 pub r#type: String,
135 pub dte_min: u32,
136 pub dte_max: u32,
137 pub min_credit: f64,
138 pub max_width: f64,
139 pub short_delta_min: f64,
140 pub short_delta_max: f64,
141 pub max_open_positions: u32,
142 pub max_contracts_per_trade: u32,
143}
144
145impl Default for VerticalEntryRules {
146 fn default() -> Self {
147 Self {
148 r#type: "put_credit".into(),
149 dte_min: 30,
150 dte_max: 45,
151 min_credit: 0.50,
152 max_width: 5.0,
153 short_delta_min: 0.15,
154 short_delta_max: 0.30,
155 max_open_positions: 3,
156 max_contracts_per_trade: 2,
157 }
158 }
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[serde(default)]
163pub struct IronCondorEntryRules {
164 pub dte_min: u32,
165 pub dte_max: u32,
166 pub min_credit: f64,
167 pub wing_width: f64,
168 pub short_delta: f64,
169 pub max_open_positions: u32,
170 pub max_contracts_per_trade: u32,
171}
172
173impl Default for IronCondorEntryRules {
174 fn default() -> Self {
175 Self {
176 dte_min: 30,
177 dte_max: 45,
178 min_credit: 1.00,
179 wing_width: 5.0,
180 short_delta: 0.16,
181 max_open_positions: 2,
182 max_contracts_per_trade: 1,
183 }
184 }
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(default)]
189pub struct ExitRules {
190 pub profit_target_pct: f64,
191 pub stop_loss_pct: f64,
192 pub dte_close: u32,
193}
194
195impl Default for ExitRules {
196 fn default() -> Self {
197 Self {
198 profit_target_pct: 50.0,
199 stop_loss_pct: 200.0,
200 dte_close: 21,
201 }
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(default)]
207pub struct RiskConfig {
208 pub max_portfolio_risk_usd: f64,
209 pub max_risk_per_trade_usd: f64,
210 pub max_trades_per_day: u32,
211 pub allowed_underlyings: Vec<String>,
212 pub blocked_events: Vec<String>,
213}
214
215impl Default for RiskConfig {
216 fn default() -> Self {
217 Self {
218 max_portfolio_risk_usd: 10_000.0,
219 max_risk_per_trade_usd: 2_000.0,
220 max_trades_per_day: 3,
221 allowed_underlyings: vec!["SPY".into(), "QQQ".into(), "IWM".into()],
222 blocked_events: vec![],
223 }
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(default)]
229pub struct ExecutionConfig {
230 pub order_type: String,
231 pub require_preview: bool,
232 pub wait_for_fill: bool,
233 pub fill_timeout_seconds: u64,
234}
235
236impl Default for ExecutionConfig {
237 fn default() -> Self {
238 Self {
239 order_type: "limit".into(),
240 require_preview: true,
241 wait_for_fill: true,
242 fill_timeout_seconds: 300,
243 }
244 }
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "lowercase")]
249pub enum LlmPhase {
250 Selection,
251 Monitor,
252 OvernightDigest,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(default)]
257pub struct LlmConfig {
258 pub enabled: bool,
260 pub selection_model: String,
262 pub monitor_model: String,
264 pub web_model: String,
266 #[serde(default, skip_serializing_if = "String::is_empty")]
268 pub model: String,
269 pub review_every_ticks: u64,
271 pub web_research_every_reviews: u64,
273 pub max_tokens: u32,
274 pub veto_entries: bool,
276 pub allow_llm_exits: bool,
278 #[serde(default)]
280 pub prompts: LlmPromptsConfig,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
285#[serde(default)]
286pub struct LlmPromptsConfig {
287 pub selection: String,
289 pub selection_web: String,
291 pub selection_context: String,
293 pub monitor: String,
295 pub monitor_context: String,
297 pub overnight: String,
299 pub overnight_context: String,
301}
302
303impl Default for LlmPromptsConfig {
304 fn default() -> Self {
305 Self {
306 selection: String::new(),
307 selection_web: String::new(),
308 selection_context: String::new(),
309 monitor: String::new(),
310 monitor_context: String::new(),
311 overnight: String::new(),
312 overnight_context: String::new(),
313 }
314 }
315}
316
317pub fn default_selection_prompt() -> &'static str {
318 "You are an expert options income trader specializing in defined-risk credit spreads \
319 and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
320 rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
321 and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
322 favorable within the strategy context provided."
323}
324
325pub fn default_selection_web_prompt() -> &'static str {
326 "You are an expert options income trader evaluating whether to OPEN new spreads. \
327 Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
328 and macro risk via web context. Be conservative: defer or skip if event or volatility \
329 risk outweighs the premium collected."
330}
331
332pub fn default_monitor_prompt() -> &'static str {
333 "You are monitoring existing open option spreads. Mechanical exits (profit target, \
334 stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
335 Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
336 current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
337 CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
338 Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
339 in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
340 stop_triggered is false, the position has NOT hit the mechanical stop.\n\
341 Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
342 Use market_context for recommendations:\n\
343 - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
344 short_otm_pct > 3% for put credits)\n\
345 - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
346 or developing macro/event risk\n\
347 - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
348 urgency high only for imminent assignment/gap risk through short strike\n\
349 Do not recommend close for routine profit — mechanics handle 50% target."
350}
351
352pub fn default_overnight_prompt() -> &'static str {
353 "The US options market is CLOSED. Research overnight and pre-market news (futures, \
354 macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
355 Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
356 whether any open spread thesis is broken, and suggested actions at the open \
357 (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
358 For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
359 thesis-breaking developments."
360}
361
362impl LlmPromptsConfig {
363 pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
364 if use_web {
365 if !self.selection_web.is_empty() {
366 return &self.selection_web;
367 }
368 if !self.selection.is_empty() {
369 return &self.selection;
370 }
371 return default_selection_web_prompt();
372 }
373 if !self.selection.is_empty() {
374 return &self.selection;
375 }
376 default_selection_prompt()
377 }
378
379 pub fn effective_monitor_instructions(&self) -> &str {
380 if !self.monitor.is_empty() {
381 return &self.monitor;
382 }
383 default_monitor_prompt()
384 }
385
386 pub fn effective_overnight_instructions(&self) -> &str {
387 if !self.overnight.is_empty() {
388 return &self.overnight;
389 }
390 default_overnight_prompt()
391 }
392
393 pub fn effective_context(&self, phase: LlmPhase) -> &str {
394 match phase {
395 LlmPhase::Selection => &self.selection_context,
396 LlmPhase::Monitor => &self.monitor_context,
397 LlmPhase::OvernightDigest => &self.overnight_context,
398 }
399 }
400}
401
402impl Default for LlmConfig {
403 fn default() -> Self {
404 Self {
405 enabled: false,
406 selection_model: "anthropic/claude-sonnet-4".into(),
407 monitor_model: "google/gemini-2.5-flash".into(),
408 web_model: "perplexity/sonar".into(),
409 model: String::new(),
410 review_every_ticks: 5,
411 web_research_every_reviews: 3,
412 max_tokens: 2000,
413 veto_entries: true,
414 allow_llm_exits: false,
415 prompts: LlmPromptsConfig::default(),
416 }
417 }
418}
419
420impl LlmConfig {
421 pub fn effective_selection_model(&self) -> &str {
422 if !self.selection_model.is_empty() {
423 &self.selection_model
424 } else if !self.model.is_empty() {
425 &self.model
426 } else {
427 "anthropic/claude-sonnet-4"
428 }
429 }
430
431 pub fn effective_monitor_model(&self) -> &str {
432 if !self.monitor_model.is_empty() {
433 &self.monitor_model
434 } else if !self.model.is_empty() {
435 &self.model
436 } else {
437 "google/gemini-2.5-flash"
438 }
439 }
440
441 pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
443 if use_web {
444 return &self.web_model;
445 }
446 match phase {
447 LlmPhase::Selection => self.effective_selection_model(),
448 LlmPhase::Monitor => self.effective_monitor_model(),
449 LlmPhase::OvernightDigest => &self.web_model,
450 }
451 }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize)]
455#[serde(default)]
456pub struct NotifyConfig {
457 pub telegram: TelegramNotifyConfig,
458}
459
460impl Default for NotifyConfig {
461 fn default() -> Self {
462 Self {
463 telegram: TelegramNotifyConfig::default(),
464 }
465 }
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize)]
469#[serde(default)]
470pub struct TelegramNotifyConfig {
471 pub enabled: bool,
472 pub notify_every_tick: bool,
474 pub notify_on_actions: bool,
476}
477
478impl Default for TelegramNotifyConfig {
479 fn default() -> Self {
480 Self {
481 enabled: false,
482 notify_every_tick: false,
483 notify_on_actions: true,
484 }
485 }
486}
487
488impl RulesConfig {
489 pub fn load(path: &Path) -> Result<Self> {
490 let content = fs::read_to_string(path)
491 .with_context(|| format!("read rules file {}", path.display()))?;
492 let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
493 serde_json::from_str(&content)?
494 } else {
495 serde_yaml::from_str(&content)?
496 };
497 rules.validate()?;
498 Ok(rules)
499 }
500
501 pub fn validate(&self) -> Result<()> {
502 if self.version != RULES_VERSION {
503 anyhow::bail!(
504 "unsupported rules version {} (expected {})",
505 self.version,
506 RULES_VERSION
507 );
508 }
509 if self.agent_id.trim().is_empty() {
510 anyhow::bail!("agent_id is required");
511 }
512 if self.accounts.is_empty() {
513 anyhow::bail!("at least one account is required");
514 }
515 for acct in &self.accounts {
516 if acct.hash.trim().is_empty() {
517 anyhow::bail!("account hash is required");
518 }
519 }
520 if self.watchlist.is_empty() {
521 anyhow::bail!("watchlist must not be empty");
522 }
523 Ok(())
524 }
525
526 pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
527 self.accounts.iter().filter(|a| a.enabled)
528 }
529}
530
531pub fn rules_json_schema() -> Value {
532 json!({
533 "$schema": "https://json-schema.org/draft/2020-12/schema",
534 "title": "Schwab options agent rules",
535 "type": "object",
536 "required": ["version", "agent_id", "accounts", "watchlist"],
537 "properties": {
538 "version": { "const": 1 },
539 "agent_id": { "type": "string" },
540 "accounts": {
541 "type": "array",
542 "items": {
543 "type": "object",
544 "required": ["hash"],
545 "properties": {
546 "hash": { "type": "string" },
547 "label": { "type": "string" },
548 "type": { "enum": ["margin", "ira", "cash"] },
549 "enabled": { "type": "boolean" }
550 }
551 }
552 },
553 "schedule": {
554 "type": "object",
555 "properties": {
556 "tick_interval_seconds": { "type": "integer", "minimum": 5 },
557 "market_hours_only": { "type": "boolean" },
558 "timezone": { "type": "string" },
559 "overnight": {
560 "type": "object",
561 "properties": {
562 "enabled": { "type": "boolean" },
563 "tick_interval_seconds": { "type": "integer", "minimum": 300 },
564 "web_digest": { "type": "boolean" },
565 "skip_llm_when_flat": { "type": "boolean" },
566 "alert_on_risk_only": { "type": "boolean" }
567 }
568 }
569 }
570 },
571 "strategies": {
572 "type": "object",
573 "properties": {
574 "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
575 "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
576 }
577 },
578 "watchlist": { "type": "array", "items": { "type": "string" } },
579 "entry_rules": { "type": "object" },
580 "exit_rules": { "type": "object" },
581 "risk": { "type": "object" },
582 "execution": { "type": "object" }
583 }
584 })
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn validates_minimal_rules() {
593 let rules = RulesConfig {
594 version: 1,
595 agent_id: "test".into(),
596 accounts: vec![RulesAccount {
597 hash: "ABC".into(),
598 label: None,
599 r#type: AccountType::Margin,
600 enabled: true,
601 }],
602 schedule: ScheduleConfig::default(),
603 strategies: StrategiesToggle::default(),
604 watchlist: vec!["SPY".into()],
605 entry_rules: EntryRules::default(),
606 exit_rules: ExitRules::default(),
607 risk: RiskConfig::default(),
608 execution: ExecutionConfig::default(),
609 llm: LlmConfig::default(),
610 notify: NotifyConfig::default(),
611 };
612 rules.validate().unwrap();
613 }
614
615 #[test]
616 fn loads_example_rules_yaml() {
617 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
618 .join("../../rules/options-rules.example.yaml");
619 if path.exists() {
620 let rules = RulesConfig::load(&path).unwrap();
621 assert_eq!(rules.agent_id, "spy-income-v1");
622 }
623 }
624
625 #[test]
626 fn llm_config_resolves_phase_models() {
627 let cfg = LlmConfig::default();
628 assert_eq!(
629 cfg.resolve_model(LlmPhase::Selection, false),
630 "anthropic/claude-sonnet-4"
631 );
632 assert_eq!(
633 cfg.resolve_model(LlmPhase::Monitor, false),
634 "google/gemini-2.5-flash"
635 );
636 assert_eq!(
637 cfg.resolve_model(LlmPhase::Selection, true),
638 "perplexity/sonar"
639 );
640 }
641
642 #[test]
643 fn custom_selection_prompt_overrides_default() {
644 let mut prompts = LlmPromptsConfig::default();
645 prompts.selection = "Aggressive premium seller.".into();
646 assert!(prompts.effective_selection_instructions(false).contains("Aggressive"));
647 }
648
649 #[test]
650 fn selection_web_prompt_used_when_set() {
651 let prompts = LlmPromptsConfig {
652 selection: "conservative".into(),
653 selection_web: "web aggressive".into(),
654 ..Default::default()
655 };
656 assert_eq!(
657 prompts.effective_selection_instructions(true),
658 "web aggressive"
659 );
660 }
661}