Skip to main content

schwab_cli/agent/
schedule.rs

1use chrono::{DateTime, Utc};
2
3use crate::rules::{OvernightConfig, ScheduleConfig};
4
5use super::state::AgentState;
6
7/// Agent operating mode for the current wake.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum AgentSession {
10    /// Option market open — full reconcile, marks, entries, monitor LLM.
11    RegularHours,
12    /// Market closed with overnight digest enabled — reconcile + optional web digest.
13    Overnight,
14    /// Market closed and overnight disabled — reconcile + hours check only.
15    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
34/// Resolve session from market hours flag and schedule config.
35pub 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}