schwab_cli/agent/
schedule.rs1use chrono::{DateTime, Utc};
2
3use crate::rules::{OvernightConfig, ScheduleConfig};
4
5use super::state::AgentState;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum AgentSession {
10 RegularHours,
12 Overnight,
14 Idle,
16}
17
18impl AgentSession {
19 pub fn as_str(self) -> &'static str {
20 match self {
21 Self::RegularHours => "regular",
22 Self::Overnight => "overnight",
23 Self::Idle => "idle",
24 }
25 }
26}
27
28pub struct SessionTransition {
29 pub session: AgentSession,
30 pub just_opened: bool,
31 pub sleep_seconds: u64,
32}
33
34pub fn resolve_session(
36 market_open: bool,
37 schedule: &ScheduleConfig,
38 previous_session: Option<&str>,
39) -> SessionTransition {
40 if market_open {
41 let just_opened = matches!(previous_session, Some("overnight") | Some("idle"));
42 return SessionTransition {
43 session: AgentSession::RegularHours,
44 just_opened,
45 sleep_seconds: schedule.tick_interval_seconds.max(5),
46 };
47 }
48
49 if schedule.overnight.enabled {
50 SessionTransition {
51 session: AgentSession::Overnight,
52 just_opened: false,
53 sleep_seconds: schedule.overnight.tick_interval_seconds.max(300),
54 }
55 } else {
56 SessionTransition {
57 session: AgentSession::Idle,
58 just_opened: false,
59 sleep_seconds: schedule.tick_interval_seconds.max(5),
60 }
61 }
62}
63
64pub fn should_run_overnight_digest(
65 state: &AgentState,
66 overnight: &OvernightConfig,
67 now: DateTime<Utc>,
68) -> bool {
69 if !overnight.web_digest {
70 return false;
71 }
72 if overnight.skip_llm_when_flat && state.open_positions.is_empty() {
73 return false;
74 }
75
76 let min_secs = overnight.tick_interval_seconds.max(300);
77 match state.last_overnight_digest_at {
78 None => true,
79 Some(at) => (now - at).num_seconds() >= min_secs as i64,
80 }
81}
82
83pub fn should_run_monitor_review(
84 regular_tick_count: u64,
85 last_llm_tick: Option<u64>,
86 every: u64,
87) -> bool {
88 let every = every.max(1);
89 match last_llm_tick {
90 None => true,
91 Some(last) => regular_tick_count.saturating_sub(last) >= every,
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 fn schedule_with_overnight() -> ScheduleConfig {
100 ScheduleConfig {
101 tick_interval_seconds: 120,
102 market_hours_only: true,
103 timezone: "America/New_York".into(),
104 overnight: OvernightConfig {
105 enabled: true,
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
114 #[test]
115 fn closed_with_overnight_is_overnight_session() {
116 let t = resolve_session(false, &schedule_with_overnight(), Some("regular"));
117 assert_eq!(t.session, AgentSession::Overnight);
118 assert_eq!(t.sleep_seconds, 3600);
119 }
120
121 #[test]
122 fn open_after_overnight_sets_just_opened() {
123 let t = resolve_session(true, &schedule_with_overnight(), Some("overnight"));
124 assert!(t.just_opened);
125 assert_eq!(t.session, AgentSession::RegularHours);
126 }
127
128 #[test]
129 fn closed_without_overnight_is_idle() {
130 let schedule = ScheduleConfig::default();
131 let t = resolve_session(false, &schedule, None);
132 assert_eq!(t.session, AgentSession::Idle);
133 }
134
135 #[test]
136 fn overnight_digest_respects_interval() {
137 let cfg = OvernightConfig {
138 enabled: true,
139 tick_interval_seconds: 3600,
140 web_digest: true,
141 skip_llm_when_flat: false,
142 alert_on_risk_only: true,
143 };
144 let mut state = AgentState::default();
145 state.open_positions.insert(
146 "IWM|2026-07-31".into(),
147 super::super::state::TrackedPosition {
148 position_id: "IWM|2026-07-31".into(),
149 account_hash: "x".into(),
150 underlying: "IWM".into(),
151 expiry: "2026-07-31".into(),
152 strategy: "vertical".into(),
153 opened_at: Utc::now(),
154 entry_credit: Some(0.25),
155 max_loss_usd: 175.0,
156 contracts: 1,
157 entry_params: None,
158 ..Default::default()
159 },
160 );
161 assert!(should_run_overnight_digest(&state, &cfg, Utc::now()));
162 state.last_overnight_digest_at = Some(Utc::now());
163 assert!(!should_run_overnight_digest(&state, &cfg, Utc::now()));
164 }
165}