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 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub simulation: Option<SimulationConfig>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SimulationConfig {
41 pub starting_budget_usd: f64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RulesAccount {
47 pub hash: String,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub label: Option<String>,
50 #[serde(default)]
51 pub r#type: AccountType,
52 #[serde(default = "default_true")]
53 pub enabled: bool,
54}
55
56fn default_true() -> bool {
57 true
58}
59
60#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "lowercase")]
62pub enum AccountType {
63 #[default]
64 Margin,
65 Ira,
66 Cash,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(default)]
71pub struct ScheduleConfig {
72 pub tick_interval_seconds: u64,
73 pub market_hours_only: bool,
74 pub timezone: String,
75 #[serde(default)]
76 pub overnight: OvernightConfig,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(default)]
82pub struct OvernightConfig {
83 pub enabled: bool,
85 pub tick_interval_seconds: u64,
87 pub web_digest: bool,
89 pub skip_llm_when_flat: bool,
91 pub alert_on_risk_only: bool,
93}
94
95impl Default for OvernightConfig {
96 fn default() -> Self {
97 Self {
98 enabled: false,
99 tick_interval_seconds: 3600,
100 web_digest: true,
101 skip_llm_when_flat: true,
102 alert_on_risk_only: true,
103 }
104 }
105}
106
107impl Default for ScheduleConfig {
108 fn default() -> Self {
109 Self {
110 tick_interval_seconds: 60,
111 market_hours_only: true,
112 timezone: "America/New_York".into(),
113 overnight: OvernightConfig::default(),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119pub struct StrategiesToggle {
120 #[serde(default)]
121 pub vertical: StrategyEnabled,
122 #[serde(default)]
123 pub iron_condor: StrategyEnabled,
124}
125
126#[derive(Debug, Clone, Default, Serialize, Deserialize)]
127pub struct StrategyEnabled {
128 #[serde(default)]
129 pub enabled: bool,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize)]
133pub struct EntryRules {
134 #[serde(default)]
135 pub vertical: VerticalEntryRules,
136 #[serde(default)]
137 pub iron_condor: IronCondorEntryRules,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141#[serde(default)]
142pub struct VerticalEntryRules {
143 pub r#type: String,
144 pub dte_min: u32,
145 pub dte_max: u32,
146 pub min_credit: f64,
147 pub max_width: f64,
148 pub short_delta_min: f64,
149 pub short_delta_max: f64,
150 #[serde(default)]
152 pub min_pop_pct: Option<f64>,
153 #[serde(default)]
155 pub min_distance_to_be_pct: Option<f64>,
156 #[serde(default)]
158 pub min_credit_to_width_pct: Option<f64>,
159 pub max_open_positions: u32,
160 pub max_contracts_per_trade: u32,
161}
162
163impl Default for VerticalEntryRules {
164 fn default() -> Self {
165 Self {
166 r#type: "put_credit".into(),
167 dte_min: 30,
168 dte_max: 45,
169 min_credit: 0.50,
170 max_width: 5.0,
171 short_delta_min: 0.15,
172 short_delta_max: 0.30,
173 min_pop_pct: Some(60.0),
174 min_distance_to_be_pct: Some(3.0),
175 min_credit_to_width_pct: Some(12.5),
176 max_open_positions: 3,
177 max_contracts_per_trade: 2,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183#[serde(default)]
184pub struct IronCondorEntryRules {
185 pub dte_min: u32,
186 pub dte_max: u32,
187 pub min_credit: f64,
188 pub wing_width: f64,
189 pub short_delta: f64,
190 pub max_open_positions: u32,
191 pub max_contracts_per_trade: u32,
192}
193
194impl Default for IronCondorEntryRules {
195 fn default() -> Self {
196 Self {
197 dte_min: 30,
198 dte_max: 45,
199 min_credit: 1.00,
200 wing_width: 5.0,
201 short_delta: 0.16,
202 max_open_positions: 2,
203 max_contracts_per_trade: 1,
204 }
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(default)]
210pub struct ExitRules {
211 pub profit_target_pct: f64,
212 pub stop_loss_pct: f64,
213 pub dte_close: u32,
214}
215
216impl Default for ExitRules {
217 fn default() -> Self {
218 Self {
219 profit_target_pct: 50.0,
220 stop_loss_pct: 200.0,
221 dte_close: 21,
222 }
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227#[serde(default)]
228pub struct RiskConfig {
229 pub max_portfolio_risk_usd: f64,
230 pub max_risk_per_trade_usd: f64,
231 pub max_trades_per_day: u32,
232 pub allowed_underlyings: Vec<String>,
233 pub blocked_events: Vec<String>,
234}
235
236impl Default for RiskConfig {
237 fn default() -> Self {
238 Self {
239 max_portfolio_risk_usd: 10_000.0,
240 max_risk_per_trade_usd: 2_000.0,
241 max_trades_per_day: 3,
242 allowed_underlyings: vec!["SPY".into(), "QQQ".into(), "IWM".into()],
243 blocked_events: vec![],
244 }
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249#[serde(default)]
250pub struct ExecutionConfig {
251 pub order_type: String,
252 pub require_preview: bool,
253 pub wait_for_fill: bool,
254 pub fill_timeout_seconds: u64,
255}
256
257impl Default for ExecutionConfig {
258 fn default() -> Self {
259 Self {
260 order_type: "limit".into(),
261 require_preview: true,
262 wait_for_fill: true,
263 fill_timeout_seconds: 300,
264 }
265 }
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "lowercase")]
270pub enum LlmPhase {
271 Selection,
272 Monitor,
273 OvernightDigest,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(default)]
278pub struct LlmConfig {
279 pub enabled: bool,
281 pub selection_model: String,
283 pub monitor_model: String,
285 pub web_model: String,
287 #[serde(default, skip_serializing_if = "String::is_empty")]
289 pub model: String,
290 pub review_every_ticks: u64,
293 #[serde(default)]
296 pub monitor_review_every_ticks: Option<u64>,
297 pub web_research_every_reviews: u64,
299 pub max_tokens: u32,
300 pub veto_entries: bool,
302 pub allow_llm_exits: bool,
304 #[serde(default)]
306 pub prompts: LlmPromptsConfig,
307}
308
309#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311#[serde(default)]
312pub struct LlmPromptsConfig {
313 pub selection: String,
315 pub selection_web: String,
317 pub selection_context: String,
319 pub monitor: String,
321 pub monitor_context: String,
323 pub overnight: String,
325 pub overnight_context: String,
327}
328
329pub fn default_selection_prompt() -> &'static str {
330 "You are an expert options income trader specializing in defined-risk credit spreads \
331 and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
332 rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
333 and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
334 favorable within the strategy context provided."
335}
336
337pub fn default_selection_web_prompt() -> &'static str {
338 "You are an expert options income trader evaluating whether to OPEN new spreads. \
339 Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
340 and macro risk via web context. When candidate_entries is non-empty, ground strike and \
341 greek analysis in each candidate's market_context — web research supplements but does \
342 not replace chain fields. Be conservative: defer or skip if event or volatility \
343 risk outweighs the premium collected."
344}
345
346pub fn selection_market_context_guardrails() -> &'static str {
348 "CHAIN DATA GUARDRAILS (binding):\n\
349 - candidate_entries[] are built only after a successful Schwab chain fetch. Each item \
350 includes market_context (underlying_price, short_delta, chain_iv, spread_pop_pct, \
351 break_even_price, expected_move_1sigma, credit_to_width_pct, DTE, etc.).\n\
352 - ivr_available: false means IV Rank is not provided — NOT missing chain data. Use chain_iv.\n\
353 - FORBIDDEN defer/skip reasons: \"lack of live chain data\", \"missing greeks\", \
354 \"no IV data\", or similar vague claims when market_context has underlying_price AND \
355 short_delta.\n\
356 - If you believe data is incomplete, cite the exact null/missing JSON field (e.g. \
357 \"short_theta is null\") and do not veto solely for absent IV Rank, theta, or gamma.\n\
358 - Defer or skip based on macro, event risk, poor premium/delta/strike placement, or \
359 extreme chain_iv — not invented data gaps.\n\
360 - If candidate_entries is empty, recommend skip with \"no mechanical candidates\" — do \
361 not claim chain API failure."
362}
363
364pub fn default_monitor_prompt() -> &'static str {
365 "You are monitoring existing open option spreads. Mechanical exits (profit target, \
366 stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
367 Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
368 current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
369 CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
370 Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
371 in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
372 stop_triggered is false, the position has NOT hit the mechanical stop.\n\
373 Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
374 Use market_context for recommendations:\n\
375 - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
376 short_otm_pct > 3% for put credits)\n\
377 - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
378 or developing macro/event risk\n\
379 - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
380 urgency high only for imminent assignment/gap risk through short strike\n\
381 For 30-45 DTE income trades: keep market_commentary to 1-2 sentences unless \
382 delta, POP, P/L, or event risk changed materially since the last review. Do not \
383 repeat overnight playbook themes when mechanical_rules show no triggered exit.\n\
384 If market_context is missing but market_context_error is set, rely on mechanical_rules \
385 and recommend hold unless mechanical_rules indicate a triggered exit.\n\
386 For new_entries during monitor phase: recommend proceed only when candidate_entries is \
387 non-empty; otherwise use skip with brief reasoning.\n\
388 Do not recommend close for routine profit — mechanics handle 50% target."
389}
390
391pub fn default_overnight_prompt() -> &'static str {
392 "The US options market is CLOSED. Research overnight and pre-market news (futures, \
393 macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
394 Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
395 whether any open spread thesis is broken, and suggested actions at the open \
396 (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
397 For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
398 thesis-breaking developments."
399}
400
401impl LlmPromptsConfig {
402 pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
403 if use_web {
404 if !self.selection_web.is_empty() {
405 return &self.selection_web;
406 }
407 if !self.selection.is_empty() {
408 return &self.selection;
409 }
410 return default_selection_web_prompt();
411 }
412 if !self.selection.is_empty() {
413 return &self.selection;
414 }
415 default_selection_prompt()
416 }
417
418 pub fn effective_monitor_instructions(&self) -> &str {
419 if !self.monitor.is_empty() {
420 return &self.monitor;
421 }
422 default_monitor_prompt()
423 }
424
425 pub fn effective_overnight_instructions(&self) -> &str {
426 if !self.overnight.is_empty() {
427 return &self.overnight;
428 }
429 default_overnight_prompt()
430 }
431
432 pub fn effective_context(&self, phase: LlmPhase) -> &str {
433 match phase {
434 LlmPhase::Selection => &self.selection_context,
435 LlmPhase::Monitor => &self.monitor_context,
436 LlmPhase::OvernightDigest => &self.overnight_context,
437 }
438 }
439}
440
441impl Default for LlmConfig {
442 fn default() -> Self {
443 Self {
444 enabled: false,
445 selection_model: "anthropic/claude-sonnet-4".into(),
446 monitor_model: "google/gemini-2.5-flash".into(),
447 web_model: "perplexity/sonar".into(),
448 model: String::new(),
449 review_every_ticks: 5,
450 monitor_review_every_ticks: None,
451 web_research_every_reviews: 3,
452 max_tokens: 2000,
453 veto_entries: true,
454 allow_llm_exits: false,
455 prompts: LlmPromptsConfig::default(),
456 }
457 }
458}
459
460impl LlmConfig {
461 pub fn effective_selection_model(&self) -> &str {
462 if !self.selection_model.is_empty() {
463 &self.selection_model
464 } else if !self.model.is_empty() {
465 &self.model
466 } else {
467 "anthropic/claude-sonnet-4"
468 }
469 }
470
471 pub fn effective_monitor_model(&self) -> &str {
472 if !self.monitor_model.is_empty() {
473 &self.monitor_model
474 } else if !self.model.is_empty() {
475 &self.model
476 } else {
477 "google/gemini-2.5-flash"
478 }
479 }
480
481 pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
483 if use_web {
484 return &self.web_model;
485 }
486 match phase {
487 LlmPhase::Selection => self.effective_selection_model(),
488 LlmPhase::Monitor => self.effective_monitor_model(),
489 LlmPhase::OvernightDigest => &self.web_model,
490 }
491 }
492
493 pub fn effective_monitor_review_ticks(
495 &self,
496 min_open_dte: Option<i64>,
497 dte_close: u32,
498 ) -> u64 {
499 let fast = self.review_every_ticks.max(1);
500 let slow = self.monitor_review_every_ticks.unwrap_or(30).max(1);
501 match min_open_dte {
502 Some(dte) if dte > dte_close as i64 => slow,
503 _ => fast,
504 }
505 }
506
507 pub fn effective_llm_review_ticks(
509 &self,
510 has_open_positions: bool,
511 min_open_dte: Option<i64>,
512 dte_close: u32,
513 ) -> u64 {
514 if has_open_positions {
515 self.effective_monitor_review_ticks(min_open_dte, dte_close)
516 } else {
517 self.review_every_ticks.max(1)
518 }
519 }
520
521 pub fn monitor_interval_minutes(
522 &self,
523 tick_interval_seconds: u64,
524 min_open_dte: Option<i64>,
525 dte_close: u32,
526 ) -> u64 {
527 let secs = self
528 .effective_monitor_review_ticks(min_open_dte, dte_close)
529 .saturating_mul(tick_interval_seconds.max(1));
530 (secs / 60).max(1)
531 }
532}
533
534#[derive(Debug, Clone, Default, Serialize, Deserialize)]
535#[serde(default)]
536pub struct NotifyConfig {
537 pub telegram: TelegramNotifyConfig,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
541#[serde(default)]
542pub struct TelegramNotifyConfig {
543 pub enabled: bool,
544 pub notify_every_tick: bool,
546 pub notify_on_actions: bool,
548 pub llm_notify_digest: bool,
550 pub llm_digest_interval_minutes: u64,
552 pub llm_notify_urgent: bool,
554 pub llm_urgent_cooldown_minutes: u64,
556}
557
558impl Default for TelegramNotifyConfig {
559 fn default() -> Self {
560 Self {
561 enabled: false,
562 notify_every_tick: false,
563 notify_on_actions: true,
564 llm_notify_digest: true,
565 llm_digest_interval_minutes: 60,
566 llm_notify_urgent: true,
567 llm_urgent_cooldown_minutes: 30,
568 }
569 }
570}
571
572impl RulesConfig {
573 pub fn load(path: &Path) -> Result<Self> {
574 let content = fs::read_to_string(path)
575 .with_context(|| format!("read rules file {}", path.display()))?;
576 let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
577 serde_json::from_str(&content)?
578 } else {
579 serde_yaml::from_str(&content)?
580 };
581 rules.validate()?;
582 Ok(rules)
583 }
584
585 pub fn validate(&self) -> Result<()> {
586 if self.version != RULES_VERSION {
587 anyhow::bail!(
588 "unsupported rules version {} (expected {})",
589 self.version,
590 RULES_VERSION
591 );
592 }
593 if self.agent_id.trim().is_empty() {
594 anyhow::bail!("agent_id is required");
595 }
596 if self.accounts.is_empty() {
597 anyhow::bail!("at least one account is required");
598 }
599 for acct in &self.accounts {
600 if acct.hash.trim().is_empty() {
601 anyhow::bail!("account hash is required");
602 }
603 }
604 if self.watchlist.is_empty() {
605 anyhow::bail!("watchlist must not be empty");
606 }
607 if !self.schedule.market_hours_only {
608 anyhow::bail!("options agent requires schedule.market_hours_only=true");
609 }
610 if !self.execution.order_type.eq_ignore_ascii_case("limit") {
611 anyhow::bail!("options agent requires execution.order_type=limit");
612 }
613 if !self.execution.require_preview {
614 anyhow::bail!("options agent requires execution.require_preview=true");
615 }
616 Ok(())
617 }
618
619 pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
620 self.accounts.iter().filter(|a| a.enabled)
621 }
622}
623
624pub fn rules_json_schema() -> Value {
625 json!({
626 "$schema": "https://json-schema.org/draft/2020-12/schema",
627 "title": "Schwab options agent rules",
628 "type": "object",
629 "required": ["version", "agent_id", "accounts", "watchlist"],
630 "properties": {
631 "version": { "const": 1 },
632 "agent_id": { "type": "string" },
633 "accounts": {
634 "type": "array",
635 "items": {
636 "type": "object",
637 "required": ["hash"],
638 "properties": {
639 "hash": { "type": "string" },
640 "label": { "type": "string" },
641 "type": { "enum": ["margin", "ira", "cash"] },
642 "enabled": { "type": "boolean" }
643 }
644 }
645 },
646 "schedule": {
647 "type": "object",
648 "properties": {
649 "tick_interval_seconds": { "type": "integer", "minimum": 5 },
650 "market_hours_only": { "type": "boolean" },
651 "timezone": { "type": "string" },
652 "overnight": {
653 "type": "object",
654 "properties": {
655 "enabled": { "type": "boolean" },
656 "tick_interval_seconds": { "type": "integer", "minimum": 300 },
657 "web_digest": { "type": "boolean" },
658 "skip_llm_when_flat": { "type": "boolean" },
659 "alert_on_risk_only": { "type": "boolean" }
660 }
661 }
662 }
663 },
664 "strategies": {
665 "type": "object",
666 "properties": {
667 "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
668 "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
669 }
670 },
671 "watchlist": { "type": "array", "items": { "type": "string" } },
672 "entry_rules": { "type": "object" },
673 "exit_rules": { "type": "object" },
674 "risk": { "type": "object" },
675 "execution": { "type": "object" }
676 }
677 })
678}
679
680#[cfg(test)]
681mod tests {
682 use super::*;
683
684 #[test]
685 fn validates_minimal_rules() {
686 let rules = RulesConfig {
687 version: 1,
688 agent_id: "test".into(),
689 accounts: vec![RulesAccount {
690 hash: "ABC".into(),
691 label: None,
692 r#type: AccountType::Margin,
693 enabled: true,
694 }],
695 schedule: ScheduleConfig::default(),
696 strategies: StrategiesToggle::default(),
697 watchlist: vec!["SPY".into()],
698 entry_rules: EntryRules::default(),
699 exit_rules: ExitRules::default(),
700 risk: RiskConfig::default(),
701 execution: ExecutionConfig::default(),
702 llm: LlmConfig::default(),
703 notify: NotifyConfig::default(),
704 simulation: None,
705 };
706 rules.validate().unwrap();
707 }
708
709 #[test]
710 fn loads_example_rules_yaml() {
711 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
712 .join("../../rules/options-rules.example.yaml");
713 if path.exists() {
714 let rules = RulesConfig::load(&path).unwrap();
715 assert_eq!(rules.agent_id, "spy-income-v1");
716 }
717 }
718
719 #[test]
720 fn llm_config_resolves_phase_models() {
721 let cfg = LlmConfig::default();
722 assert_eq!(
723 cfg.resolve_model(LlmPhase::Selection, false),
724 "anthropic/claude-sonnet-4"
725 );
726 assert_eq!(
727 cfg.resolve_model(LlmPhase::Monitor, false),
728 "google/gemini-2.5-flash"
729 );
730 assert_eq!(
731 cfg.resolve_model(LlmPhase::Selection, true),
732 "perplexity/sonar"
733 );
734 }
735
736 #[test]
737 fn custom_selection_prompt_overrides_default() {
738 let prompts = LlmPromptsConfig {
739 selection: "Aggressive premium seller.".into(),
740 ..Default::default()
741 };
742 assert!(prompts
743 .effective_selection_instructions(false)
744 .contains("Aggressive"));
745 }
746
747 #[test]
748 fn selection_web_prompt_used_when_set() {
749 let prompts = LlmPromptsConfig {
750 selection: "conservative".into(),
751 selection_web: "web aggressive".into(),
752 ..Default::default()
753 };
754 assert_eq!(
755 prompts.effective_selection_instructions(true),
756 "web aggressive"
757 );
758 }
759
760 #[test]
761 fn selection_guardrails_are_documented() {
762 let g = selection_market_context_guardrails();
763 assert!(g.contains("FORBIDDEN"));
764 assert!(g.contains("underlying_price"));
765 assert!(g.contains("ivr_available"));
766 }
767
768 #[test]
769 fn monitor_review_slower_above_gamma_window() {
770 let llm = LlmConfig {
771 review_every_ticks: 5,
772 monitor_review_every_ticks: Some(45),
773 ..Default::default()
774 };
775 assert_eq!(llm.effective_monitor_review_ticks(Some(30), 21), 45);
776 assert_eq!(llm.effective_monitor_review_ticks(Some(18), 21), 5);
777 }
778
779 #[test]
780 fn llm_review_ticks_use_monitor_cadence_with_open_positions() {
781 let llm = LlmConfig {
782 review_every_ticks: 5,
783 monitor_review_every_ticks: Some(45),
784 ..Default::default()
785 };
786 assert_eq!(llm.effective_llm_review_ticks(false, None, 21), 5);
787 assert_eq!(llm.effective_llm_review_ticks(true, Some(30), 21), 45);
788 assert_eq!(llm.effective_llm_review_ticks(true, Some(18), 21), 5);
789 }
790}