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(regular_tick_count: u64, last_llm_tick: Option<u64>, every: u64) -> bool {
84 let every = every.max(1);
85 match last_llm_tick {
86 None => true,
87 Some(last) => regular_tick_count.saturating_sub(last) >= every,
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 fn schedule_with_overnight() -> ScheduleConfig {
96 ScheduleConfig {
97 tick_interval_seconds: 120,
98 market_hours_only: true,
99 timezone: "America/New_York".into(),
100 overnight: OvernightConfig {
101 enabled: true,
102 tick_interval_seconds: 3600,
103 web_digest: true,
104 skip_llm_when_flat: true,
105 alert_on_risk_only: true,
106 },
107 }
108 }
109
110 #[test]
111 fn closed_with_overnight_is_overnight_session() {
112 let t = resolve_session(false, &schedule_with_overnight(), Some("regular"));
113 assert_eq!(t.session, AgentSession::Overnight);
114 assert_eq!(t.sleep_seconds, 3600);
115 }
116
117 #[test]
118 fn open_after_overnight_sets_just_opened() {
119 let t = resolve_session(true, &schedule_with_overnight(), Some("overnight"));
120 assert!(t.just_opened);
121 assert_eq!(t.session, AgentSession::RegularHours);
122 }
123
124 #[test]
125 fn closed_without_overnight_is_idle() {
126 let schedule = ScheduleConfig::default();
127 let t = resolve_session(false, &schedule, None);
128 assert_eq!(t.session, AgentSession::Idle);
129 }
130
131 #[test]
132 fn overnight_digest_respects_interval() {
133 let cfg = OvernightConfig {
134 enabled: true,
135 tick_interval_seconds: 3600,
136 web_digest: true,
137 skip_llm_when_flat: false,
138 alert_on_risk_only: true,
139 };
140 let mut state = AgentState::default();
141 state.open_positions.insert(
142 "IWM|2026-07-31".into(),
143 super::super::state::TrackedPosition {
144 position_id: "IWM|2026-07-31".into(),
145 account_hash: "x".into(),
146 underlying: "IWM".into(),
147 expiry: "2026-07-31".into(),
148 strategy: "vertical".into(),
149 opened_at: Utc::now(),
150 entry_credit: Some(0.25),
151 max_loss_usd: 175.0,
152 },
153 );
154 assert!(should_run_overnight_digest(&state, &cfg, Utc::now()));
155 state.last_overnight_digest_at = Some(Utc::now());
156 assert!(!should_run_overnight_digest(&state, &cfg, Utc::now()));
157 }
158}