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)]
209pub struct ProfitGivebackExit {
210 pub peak_profit_min_pct: f64,
212 pub exit_if_below_pct: f64,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[serde(default)]
218pub struct ThesisExitRules {
219 pub enabled: bool,
220 pub min_hold_minutes: Option<u32>,
222 pub min_pop_pct_exit: Option<f64>,
224 pub max_short_delta_exit: Option<f64>,
226 pub min_short_otm_pct: Option<f64>,
228 pub exit_short_inside_1sigma: bool,
231 pub redeploy_cooldown_minutes: Option<u32>,
233 pub profit_giveback: Option<ProfitGivebackExit>,
234}
235
236impl Default for ThesisExitRules {
237 fn default() -> Self {
238 Self {
239 enabled: false,
240 min_hold_minutes: None,
241 min_pop_pct_exit: None,
242 max_short_delta_exit: None,
243 min_short_otm_pct: None,
244 exit_short_inside_1sigma: false,
245 redeploy_cooldown_minutes: None,
246 profit_giveback: None,
247 }
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252#[serde(default)]
253pub struct ExitRules {
254 pub profit_target_pct: f64,
255 pub stop_loss_pct: f64,
256 pub dte_close: u32,
257 pub thesis: ThesisExitRules,
258}
259
260impl Default for ExitRules {
261 fn default() -> Self {
262 Self {
263 profit_target_pct: 50.0,
264 stop_loss_pct: 200.0,
265 dte_close: 21,
266 thesis: ThesisExitRules::default(),
267 }
268 }
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[serde(default)]
273pub struct RiskConfig {
274 pub max_portfolio_risk_usd: f64,
275 pub max_risk_per_trade_usd: f64,
276 pub max_trades_per_day: u32,
277 pub allowed_underlyings: Vec<String>,
278 pub blocked_events: Vec<String>,
279}
280
281impl Default for RiskConfig {
282 fn default() -> Self {
283 Self {
284 max_portfolio_risk_usd: 10_000.0,
285 max_risk_per_trade_usd: 2_000.0,
286 max_trades_per_day: 3,
287 allowed_underlyings: vec!["SPY".into(), "QQQ".into(), "IWM".into()],
288 blocked_events: vec![],
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[serde(default)]
295pub struct ExecutionConfig {
296 pub order_type: String,
297 pub require_preview: bool,
298 pub wait_for_fill: bool,
299 pub fill_timeout_seconds: u64,
300}
301
302impl Default for ExecutionConfig {
303 fn default() -> Self {
304 Self {
305 order_type: "limit".into(),
306 require_preview: true,
307 wait_for_fill: true,
308 fill_timeout_seconds: 300,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "lowercase")]
315pub enum LlmPhase {
316 Selection,
317 Monitor,
318 OvernightDigest,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
322#[serde(default)]
323pub struct LlmConfig {
324 pub enabled: bool,
326 pub selection_model: String,
328 pub monitor_model: String,
330 pub web_model: String,
332 #[serde(default, skip_serializing_if = "String::is_empty")]
334 pub model: String,
335 pub review_every_ticks: u64,
338 #[serde(default)]
341 pub monitor_review_every_ticks: Option<u64>,
342 pub web_research_every_reviews: u64,
344 pub max_tokens: u32,
345 pub veto_entries: bool,
347 pub allow_llm_exits: bool,
349 #[serde(default)]
351 pub prompts: LlmPromptsConfig,
352}
353
354#[derive(Debug, Clone, Default, Serialize, Deserialize)]
356#[serde(default)]
357pub struct LlmPromptsConfig {
358 pub selection: String,
360 pub selection_web: String,
362 pub selection_context: String,
364 pub monitor: String,
366 pub monitor_context: String,
368 pub overnight: String,
370 pub overnight_context: String,
372}
373
374pub fn default_selection_prompt() -> &'static str {
375 "You are an expert options income trader specializing in defined-risk credit spreads \
376 and iron condors. You are evaluating whether to OPEN new spreads found by deterministic \
377 rules. Analyze candidate_entries for credit vs width, delta, timing, portfolio risk, \
378 and event risk. Be conservative: recommend defer or skip unless the setup is clearly \
379 favorable within the strategy context provided."
380}
381
382pub fn default_selection_web_prompt() -> &'static str {
383 "You are an expert options income trader evaluating whether to OPEN new spreads. \
384 Research current market conditions, upcoming events (FOMC, CPI, earnings), IV regime, \
385 and macro risk via web context. When candidate_entries is non-empty, ground strike and \
386 greek analysis in each candidate's market_context — web research supplements but does \
387 not replace chain fields. Be conservative: defer or skip if event or volatility \
388 risk outweighs the premium collected."
389}
390
391pub fn selection_market_context_guardrails() -> &'static str {
393 "CHAIN DATA GUARDRAILS (binding):\n\
394 - candidate_entries[] are built only after a successful Schwab chain fetch. Each item \
395 includes market_context (underlying_price, short_delta, chain_iv, spread_pop_pct, \
396 break_even_price, expected_move_1sigma, credit_to_width_pct, DTE, etc.).\n\
397 - ivr_available: false means IV Rank is not provided — NOT missing chain data. Use chain_iv.\n\
398 - FORBIDDEN defer/skip reasons: \"lack of live chain data\", \"missing greeks\", \
399 \"no IV data\", or similar vague claims when market_context has underlying_price AND \
400 short_delta.\n\
401 - If you believe data is incomplete, cite the exact null/missing JSON field (e.g. \
402 \"short_theta is null\") and do not veto solely for absent IV Rank, theta, or gamma.\n\
403 - Defer or skip based on macro, event risk, poor premium/delta/strike placement, or \
404 extreme chain_iv — not invented data gaps.\n\
405 - If candidate_entries is empty, recommend skip with \"no mechanical candidates\" — do \
406 not claim chain API failure."
407}
408
409pub fn default_monitor_prompt() -> &'static str {
410 "You are monitoring existing open option spreads. Mechanical exits (profit target, \
411 stop loss, DTE) run every tick without you — do not duplicate those rules.\n\
412 Each open_positions[] item includes mechanical_rules (stop_debit_threshold_per_share, \
413 current_debit_to_close, stop_triggered) and market_context (greeks, OTM distance).\n\
414 CRITICAL: Never use net_market_value for stop-loss or profit-target decisions — it is \
415 Schwab leg market value in dollars, not per-share debit_to_close. Only cite a stop hit \
416 in risk_alerts when mechanical_rules.stop_triggered is true. If status is holding and \
417 stop_triggered is false, the position has NOT hit the mechanical stop.\n\
418 Early in a 30-45 DTE trade, mark-to-market swings are normal; theta needs time.\n\
419 Use market_context for recommendations:\n\
420 - hold: thesis intact, short leg comfortably OTM (typically |short_delta| < 0.30, \
421 short_otm_pct > 3% for put credits)\n\
422 - watch: elevated delta (|short_delta| >= 0.30), price within ~2% of short strike, \
423 or developing macro/event risk\n\
424 - close: thesis broken (recommendation only; mechanical stop handles P/L) — use \
425 urgency high only for imminent assignment/gap risk through short strike\n\
426 For 30-45 DTE income trades: keep market_commentary to 1-2 sentences unless \
427 delta, POP, P/L, or event risk changed materially since the last review. Do not \
428 repeat overnight playbook themes when mechanical_rules show no triggered exit.\n\
429 If market_context is missing but market_context_error is set, rely on mechanical_rules \
430 and recommend hold unless mechanical_rules indicate a triggered exit.\n\
431 For new_entries during monitor phase: recommend proceed only when candidate_entries is \
432 non-empty; otherwise use skip with brief reasoning.\n\
433 Do not recommend close for routine profit — mechanics handle 50% target."
434}
435
436pub fn default_overnight_prompt() -> &'static str {
437 "The US options market is CLOSED. Research overnight and pre-market news (futures, \
438 macro, geopolitical, scheduled data) affecting the watchlist and open positions. \
439 Build a concise OPEN PLAYBOOK for the next session: what to watch at the bell, \
440 whether any open spread thesis is broken, and suggested actions at the open \
441 (hold, close at market, or wait). Do NOT recommend opening new trades overnight. \
442 For new_entries always recommend skip. Only flag high-urgency risk_alerts for \
443 thesis-breaking developments."
444}
445
446impl LlmPromptsConfig {
447 pub fn effective_selection_instructions(&self, use_web: bool) -> &str {
448 if use_web {
449 if !self.selection_web.is_empty() {
450 return &self.selection_web;
451 }
452 if !self.selection.is_empty() {
453 return &self.selection;
454 }
455 return default_selection_web_prompt();
456 }
457 if !self.selection.is_empty() {
458 return &self.selection;
459 }
460 default_selection_prompt()
461 }
462
463 pub fn effective_monitor_instructions(&self) -> &str {
464 if !self.monitor.is_empty() {
465 return &self.monitor;
466 }
467 default_monitor_prompt()
468 }
469
470 pub fn effective_overnight_instructions(&self) -> &str {
471 if !self.overnight.is_empty() {
472 return &self.overnight;
473 }
474 default_overnight_prompt()
475 }
476
477 pub fn effective_context(&self, phase: LlmPhase) -> &str {
478 match phase {
479 LlmPhase::Selection => &self.selection_context,
480 LlmPhase::Monitor => &self.monitor_context,
481 LlmPhase::OvernightDigest => &self.overnight_context,
482 }
483 }
484}
485
486impl Default for LlmConfig {
487 fn default() -> Self {
488 Self {
489 enabled: false,
490 selection_model: "anthropic/claude-sonnet-4".into(),
491 monitor_model: "google/gemini-2.5-flash".into(),
492 web_model: "perplexity/sonar".into(),
493 model: String::new(),
494 review_every_ticks: 5,
495 monitor_review_every_ticks: None,
496 web_research_every_reviews: 3,
497 max_tokens: 2000,
498 veto_entries: true,
499 allow_llm_exits: false,
500 prompts: LlmPromptsConfig::default(),
501 }
502 }
503}
504
505impl LlmConfig {
506 pub fn effective_selection_model(&self) -> &str {
507 if !self.selection_model.is_empty() {
508 &self.selection_model
509 } else if !self.model.is_empty() {
510 &self.model
511 } else {
512 "anthropic/claude-sonnet-4"
513 }
514 }
515
516 pub fn effective_monitor_model(&self) -> &str {
517 if !self.monitor_model.is_empty() {
518 &self.monitor_model
519 } else if !self.model.is_empty() {
520 &self.model
521 } else {
522 "google/gemini-2.5-flash"
523 }
524 }
525
526 pub fn resolve_model(&self, phase: LlmPhase, use_web: bool) -> &str {
528 if use_web {
529 return &self.web_model;
530 }
531 match phase {
532 LlmPhase::Selection => self.effective_selection_model(),
533 LlmPhase::Monitor => self.effective_monitor_model(),
534 LlmPhase::OvernightDigest => &self.web_model,
535 }
536 }
537
538 pub fn effective_monitor_review_ticks(
540 &self,
541 min_open_dte: Option<i64>,
542 dte_close: u32,
543 ) -> u64 {
544 let fast = self.review_every_ticks.max(1);
545 let slow = self.monitor_review_every_ticks.unwrap_or(30).max(1);
546 match min_open_dte {
547 Some(dte) if dte > dte_close as i64 => slow,
548 _ => fast,
549 }
550 }
551
552 pub fn effective_llm_review_ticks(
554 &self,
555 has_open_positions: bool,
556 min_open_dte: Option<i64>,
557 dte_close: u32,
558 ) -> u64 {
559 if has_open_positions {
560 self.effective_monitor_review_ticks(min_open_dte, dte_close)
561 } else {
562 self.review_every_ticks.max(1)
563 }
564 }
565
566 pub fn monitor_interval_minutes(
567 &self,
568 tick_interval_seconds: u64,
569 min_open_dte: Option<i64>,
570 dte_close: u32,
571 ) -> u64 {
572 let secs = self
573 .effective_monitor_review_ticks(min_open_dte, dte_close)
574 .saturating_mul(tick_interval_seconds.max(1));
575 (secs / 60).max(1)
576 }
577}
578
579#[derive(Debug, Clone, Default, Serialize, Deserialize)]
580#[serde(default)]
581pub struct NotifyConfig {
582 pub telegram: TelegramNotifyConfig,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[serde(default)]
587pub struct TelegramNotifyConfig {
588 pub enabled: bool,
589 pub notify_every_tick: bool,
591 pub notify_on_actions: bool,
593 pub llm_notify_digest: bool,
595 pub llm_digest_interval_minutes: u64,
597 pub llm_notify_urgent: bool,
599 pub llm_urgent_cooldown_minutes: u64,
601}
602
603impl Default for TelegramNotifyConfig {
604 fn default() -> Self {
605 Self {
606 enabled: false,
607 notify_every_tick: false,
608 notify_on_actions: true,
609 llm_notify_digest: true,
610 llm_digest_interval_minutes: 60,
611 llm_notify_urgent: true,
612 llm_urgent_cooldown_minutes: 30,
613 }
614 }
615}
616
617impl RulesConfig {
618 pub fn load(path: &Path) -> Result<Self> {
619 let content = fs::read_to_string(path)
620 .with_context(|| format!("read rules file {}", path.display()))?;
621 let rules: RulesConfig = if path.extension().is_some_and(|e| e == "json") {
622 serde_json::from_str(&content)?
623 } else {
624 serde_yaml::from_str(&content)?
625 };
626 rules.validate()?;
627 Ok(rules)
628 }
629
630 pub fn validate(&self) -> Result<()> {
631 if self.version != RULES_VERSION {
632 anyhow::bail!(
633 "unsupported rules version {} (expected {})",
634 self.version,
635 RULES_VERSION
636 );
637 }
638 if self.agent_id.trim().is_empty() {
639 anyhow::bail!("agent_id is required");
640 }
641 if self.accounts.is_empty() {
642 anyhow::bail!("at least one account is required");
643 }
644 for acct in &self.accounts {
645 if acct.hash.trim().is_empty() {
646 anyhow::bail!("account hash is required");
647 }
648 }
649 if self.watchlist.is_empty() {
650 anyhow::bail!("watchlist must not be empty");
651 }
652 if !self.schedule.market_hours_only {
653 anyhow::bail!("options agent requires schedule.market_hours_only=true");
654 }
655 if !self.execution.order_type.eq_ignore_ascii_case("limit") {
656 anyhow::bail!("options agent requires execution.order_type=limit");
657 }
658 if !self.execution.require_preview {
659 anyhow::bail!("options agent requires execution.require_preview=true");
660 }
661 Ok(())
662 }
663
664 pub fn enabled_accounts(&self) -> impl Iterator<Item = &RulesAccount> {
665 self.accounts.iter().filter(|a| a.enabled)
666 }
667}
668
669pub fn rules_json_schema() -> Value {
670 json!({
671 "$schema": "https://json-schema.org/draft/2020-12/schema",
672 "title": "Schwab options agent rules",
673 "type": "object",
674 "required": ["version", "agent_id", "accounts", "watchlist"],
675 "properties": {
676 "version": { "const": 1 },
677 "agent_id": { "type": "string" },
678 "accounts": {
679 "type": "array",
680 "items": {
681 "type": "object",
682 "required": ["hash"],
683 "properties": {
684 "hash": { "type": "string" },
685 "label": { "type": "string" },
686 "type": { "enum": ["margin", "ira", "cash"] },
687 "enabled": { "type": "boolean" }
688 }
689 }
690 },
691 "schedule": {
692 "type": "object",
693 "properties": {
694 "tick_interval_seconds": { "type": "integer", "minimum": 5 },
695 "market_hours_only": { "type": "boolean" },
696 "timezone": { "type": "string" },
697 "overnight": {
698 "type": "object",
699 "properties": {
700 "enabled": { "type": "boolean" },
701 "tick_interval_seconds": { "type": "integer", "minimum": 300 },
702 "web_digest": { "type": "boolean" },
703 "skip_llm_when_flat": { "type": "boolean" },
704 "alert_on_risk_only": { "type": "boolean" }
705 }
706 }
707 }
708 },
709 "strategies": {
710 "type": "object",
711 "properties": {
712 "vertical": { "type": "object", "properties": { "enabled": { "type": "boolean" } } },
713 "iron_condor": { "type": "object", "properties": { "enabled": { "type": "boolean" } } }
714 }
715 },
716 "watchlist": { "type": "array", "items": { "type": "string" } },
717 "entry_rules": { "type": "object" },
718 "exit_rules": { "type": "object" },
719 "risk": { "type": "object" },
720 "execution": { "type": "object" }
721 }
722 })
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728
729 #[test]
730 fn validates_minimal_rules() {
731 let rules = RulesConfig {
732 version: 1,
733 agent_id: "test".into(),
734 accounts: vec![RulesAccount {
735 hash: "ABC".into(),
736 label: None,
737 r#type: AccountType::Margin,
738 enabled: true,
739 }],
740 schedule: ScheduleConfig::default(),
741 strategies: StrategiesToggle::default(),
742 watchlist: vec!["SPY".into()],
743 entry_rules: EntryRules::default(),
744 exit_rules: ExitRules::default(),
745 risk: RiskConfig::default(),
746 execution: ExecutionConfig::default(),
747 llm: LlmConfig::default(),
748 notify: NotifyConfig::default(),
749 simulation: None,
750 };
751 rules.validate().unwrap();
752 }
753
754 #[test]
755 fn loads_example_rules_yaml() {
756 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
757 .join("../../rules/options-rules.example.yaml");
758 if path.exists() {
759 let rules = RulesConfig::load(&path).unwrap();
760 assert_eq!(rules.agent_id, "spy-income-v1");
761 }
762 }
763
764 #[test]
765 fn llm_config_resolves_phase_models() {
766 let cfg = LlmConfig::default();
767 assert_eq!(
768 cfg.resolve_model(LlmPhase::Selection, false),
769 "anthropic/claude-sonnet-4"
770 );
771 assert_eq!(
772 cfg.resolve_model(LlmPhase::Monitor, false),
773 "google/gemini-2.5-flash"
774 );
775 assert_eq!(
776 cfg.resolve_model(LlmPhase::Selection, true),
777 "perplexity/sonar"
778 );
779 }
780
781 #[test]
782 fn custom_selection_prompt_overrides_default() {
783 let prompts = LlmPromptsConfig {
784 selection: "Aggressive premium seller.".into(),
785 ..Default::default()
786 };
787 assert!(prompts
788 .effective_selection_instructions(false)
789 .contains("Aggressive"));
790 }
791
792 #[test]
793 fn selection_web_prompt_used_when_set() {
794 let prompts = LlmPromptsConfig {
795 selection: "conservative".into(),
796 selection_web: "web aggressive".into(),
797 ..Default::default()
798 };
799 assert_eq!(
800 prompts.effective_selection_instructions(true),
801 "web aggressive"
802 );
803 }
804
805 #[test]
806 fn selection_guardrails_are_documented() {
807 let g = selection_market_context_guardrails();
808 assert!(g.contains("FORBIDDEN"));
809 assert!(g.contains("underlying_price"));
810 assert!(g.contains("ivr_available"));
811 }
812
813 #[test]
814 fn monitor_review_slower_above_gamma_window() {
815 let llm = LlmConfig {
816 review_every_ticks: 5,
817 monitor_review_every_ticks: Some(45),
818 ..Default::default()
819 };
820 assert_eq!(llm.effective_monitor_review_ticks(Some(30), 21), 45);
821 assert_eq!(llm.effective_monitor_review_ticks(Some(18), 21), 5);
822 }
823
824 #[test]
825 fn llm_review_ticks_use_monitor_cadence_with_open_positions() {
826 let llm = LlmConfig {
827 review_every_ticks: 5,
828 monitor_review_every_ticks: Some(45),
829 ..Default::default()
830 };
831 assert_eq!(llm.effective_llm_review_ticks(false, None, 21), 5);
832 assert_eq!(llm.effective_llm_review_ticks(true, Some(30), 21), 45);
833 assert_eq!(llm.effective_llm_review_ticks(true, Some(18), 21), 5);
834 }
835}