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(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}