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, Default, 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
303pub fn default_selection_prompt() -> &'static str {
304 "You are an expert options income trader specializing in defined-risk credit spreads \
305 and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
306 rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
307 and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
308 favorable within the strategy context provided."
309}
310
311pub fn default_selection_web_prompt() -> &'static str {
312 "You are an expert options income trader evaluating whether to OPEN new spreads. \
313 Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
314 and macro risk via web context. Be conservative: defer or skip if event or volatility \
315 risk outweighs the premium collected."
316}
317
318pub fn default_monitor_prompt() -> &'static str {
319 "You are monitoring existing open option spreads. Mechanical exits (profit target, \
320 stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
321 Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
322 current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
323 CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
324 Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
325 in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
326 stop_triggered is false, the position has NOT hit the mechanical stop.\n\
327 Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
328 Use market_context for recommendations:\n\
329 - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
330 short_otm_pct > 3% for put credits)\n\
331 - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
332 or developing macro/event risk\n\
333 - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
334 urgency high only for imminent assignment/gap risk through short strike\n\
335 If market_context is missing but market_context_error is set, rely on mechanical_rules \
336 and recommend hold unless mechanical_rules indicate a triggered exit.\n\
337 For new_entries during monitor phase: recommend proceed only when candidate_entries is \
338 non-empty; otherwise use skip with brief reasoning.\n\
339 Do not recommend close for routine profit — mechanics handle 50% target."
340}
341
342pub fn default_overnight_prompt() -> &'static str {
343 "The US options market is CLOSED. Research overnight and pre-market news (futures, \
344 macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
345 Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
346 whether any open spread thesis is broken, and suggested actions at the open \
347 (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
348 For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
349 thesis-breaking developments."
350}
351
352impl LlmPromptsConfig {
353 pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
354 if use_web {
355 if !self.selection_web.is_empty() {
356 return &self.selection_web;
357 }
358 if !self.selection.is_empty() {
359 return &self.selection;
360 }
361 return default_selection_web_prompt();
362 }
363 if !self.selection.is_empty() {
364 return &self.selection;
365 }
366 default_selection_prompt()
367 }
368
369 pub fn effective_monitor_instructions(&self) -> &str {
370 if !self.monitor.is_empty() {
371 return &self.monitor;
372 }
373 default_monitor_prompt()
374 }
375
376 pub fn effective_overnight_instructions(&self) -> &str {
377 if !self.overnight.is_empty() {
378 return &self.overnight;
379 }
380 default_overnight_prompt()
381 }
382
383 pub fn effective_context(&self, phase: LlmPhase) -> &str {
384 match phase {
385 LlmPhase::Selection => &self.selection_context,
386 LlmPhase::Monitor => &self.monitor_context,
387 LlmPhase::OvernightDigest => &self.overnight_context,
388 }
389 }
390}
391
392impl Default for LlmConfig {
393 fn default() -> Self {
394 Self {
395 enabled: false,
396 selection_model: "anthropic/claude-sonnet-4".into(),
397 monitor_model: "google/gemini-2.5-flash".into(),
398 web_model: "perplexity/sonar".into(),
399 model: String::new(),
400 review_every_ticks: 5,
401 web_research_every_reviews: 3,
402 max_tokens: 2000,
403 veto_entries: true,
404 allow_llm_exits: false,
405 prompts: LlmPromptsConfig::default(),
406 }
407 }
408}
409
410impl LlmConfig {
411 pub fn effective_selection_model(&self) -> &str {
412 if !self.selection_model.is_empty() {
413 &self.selection_model
414 } else if !self.model.is_empty() {
415 &self.model
416 } else {
417 "anthropic/claude-sonnet-4"
418 }
419 }
420
421 pub fn effective_monitor_model(&self) -> &str {
422 if !self.monitor_model.is_empty() {
423 &self.monitor_model
424 } else if !self.model.is_empty() {
425 &self.model
426 } else {
427 "google/gemini-2.5-flash"
428 }
429 }
430
431 pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
433 if use_web {
434 return &self.web_model;
435 }
436 match phase {
437 LlmPhase::Selection => self.effective_selection_model(),
438 LlmPhase::Monitor => self.effective_monitor_model(),
439 LlmPhase::OvernightDigest => &self.web_model,
440 }
441 }
442}
443
444#[derive(Debug, Clone, Default, Serialize, Deserialize)]
445#[serde(default)]
446pub struct NotifyConfig {
447 pub telegram: TelegramNotifyConfig,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(default)]
452pub struct TelegramNotifyConfig {
453 pub enabled: bool,
454 pub notify_every_tick: bool,
456 pub notify_on_actions: bool,
458}
459
460impl Default for TelegramNotifyConfig {
461 fn default() -> Self {
462 Self {
463 enabled: false,
464 notify_every_tick: false,
465 notify_on_actions: true,
466 }
467 }
468}
469
470impl RulesConfig {
471 pub fn load(path: &Path) -> Result<Self> {
472 let content = fs::read_to_string(path)
473 .with_context(|| format!("read rules file {}", path.display()))?;
474 let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
475 serde_json::from_str(&content)?
476 } else {
477 serde_yaml::from_str(&content)?
478 };
479 rules.validate()?;
480 Ok(rules)
481 }
482
483 pub fn validate(&self) -> Result<()> {
484 if self.version != RULES_VERSION {
485 anyhow::bail!(
486 "unsupported rules version {} (expected {})",
487 self.version,
488 RULES_VERSION
489 );
490 }
491 if self.agent_id.trim().is_empty() {
492 anyhow::bail!("agent_id is required");
493 }
494 if self.accounts.is_empty() {
495 anyhow::bail!("at least one account is required");
496 }
497 for acct in &self.accounts {
498 if acct.hash.trim().is_empty() {
499 anyhow::bail!("account hash is required");
500 }
501 }
502 if self.watchlist.is_empty() {
503 anyhow::bail!("watchlist must not be empty");
504 }
505 if !self.schedule.market_hours_only {
506 anyhow::bail!("options agent requires schedule.market_hours_only=true");
507 }
508 if !self.execution.order_type.eq_ignore_ascii_case("limit") {
509 anyhow::bail!("options agent requires execution.order_type=limit");
510 }
511 if !self.execution.require_preview {
512 anyhow::bail!("options agent requires execution.require_preview=true");
513 }
514 Ok(())
515 }
516
517 pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
518 self.accounts.iter().filter(|a| a.enabled)
519 }
520}
521
522pub fn rules_json_schema() -> Value {
523 json!({
524 "$schema": "https://json-schema.org/draft/2020-12/schema",
525 "title": "Schwab options agent rules",
526 "type": "object",
527 "required": ["version", "agent_id", "accounts", "watchlist"],
528 "properties": {
529 "version": { "const": 1 },
530 "agent_id": { "type": "string" },
531 "accounts": {
532 "type": "array",
533 "items": {
534 "type": "object",
535 "required": ["hash"],
536 "properties": {
537 "hash": { "type": "string" },
538 "label": { "type": "string" },
539 "type": { "enum": ["margin", "ira", "cash"] },
540 "enabled": { "type": "boolean" }
541 }
542 }
543 },
544 "schedule": {
545 "type": "object",
546 "properties": {
547 "tick_interval_seconds": { "type": "integer", "minimum": 5 },
548 "market_hours_only": { "type": "boolean" },
549 "timezone": { "type": "string" },
550 "overnight": {
551 "type": "object",
552 "properties": {
553 "enabled": { "type": "boolean" },
554 "tick_interval_seconds": { "type": "integer", "minimum": 300 },
555 "web_digest": { "type": "boolean" },
556 "skip_llm_when_flat": { "type": "boolean" },
557 "alert_on_risk_only": { "type": "boolean" }
558 }
559 }
560 }
561 },
562 "strategies": {
563 "type": "object",
564 "properties": {
565 "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
566 "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
567 }
568 },
569 "watchlist": { "type": "array", "items": { "type": "string" } },
570 "entry_rules": { "type": "object" },
571 "exit_rules": { "type": "object" },
572 "risk": { "type": "object" },
573 "execution": { "type": "object" }
574 }
575 })
576}
577
578#[cfg(test)]
579mod tests {
580 use super::*;
581
582 #[test]
583 fn validates_minimal_rules() {
584 let rules = RulesConfig {
585 version: 1,
586 agent_id: "test".into(),
587 accounts: vec![RulesAccount {
588 hash: "ABC".into(),
589 label: None,
590 r#type: AccountType::Margin,
591 enabled: true,
592 }],
593 schedule: ScheduleConfig::default(),
594 strategies: StrategiesToggle::default(),
595 watchlist: vec!["SPY".into()],
596 entry_rules: EntryRules::default(),
597 exit_rules: ExitRules::default(),
598 risk: RiskConfig::default(),
599 execution: ExecutionConfig::default(),
600 llm: LlmConfig::default(),
601 notify: NotifyConfig::default(),
602 };
603 rules.validate().unwrap();
604 }
605
606 #[test]
607 fn loads_example_rules_yaml() {
608 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
609 .join("../../rules/options-rules.example.yaml");
610 if path.exists() {
611 let rules = RulesConfig::load(&path).unwrap();
612 assert_eq!(rules.agent_id, "spy-income-v1");
613 }
614 }
615
616 #[test]
617 fn llm_config_resolves_phase_models() {
618 let cfg = LlmConfig::default();
619 assert_eq!(
620 cfg.resolve_model(LlmPhase::Selection, false),
621 "anthropic/claude-sonnet-4"
622 );
623 assert_eq!(
624 cfg.resolve_model(LlmPhase::Monitor, false),
625 "google/gemini-2.5-flash"
626 );
627 assert_eq!(
628 cfg.resolve_model(LlmPhase::Selection, true),
629 "perplexity/sonar"
630 );
631 }
632
633 #[test]
634 fn custom_selection_prompt_overrides_default() {
635 let prompts = LlmPromptsConfig {
636 selection: "Aggressive premium seller.".into(),
637 ..Default::default()
638 };
639 assert!(prompts
640 .effective_selection_instructions(false)
641 .contains("Aggressive"));
642 }
643
644 #[test]
645 fn selection_web_prompt_used_when_set() {
646 let prompts = LlmPromptsConfig {
647 selection: "conservative".into(),
648 selection_web: "web aggressive".into(),
649 ..Default::default()
650 };
651 assert_eq!(
652 prompts.effective_selection_instructions(true),
653 "web aggressive"
654 );
655 }
656}