Skip to main content

schwab_cli/ui/
context.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex};
4
5use anyhow::Result;
6use serde_json::{json, Value};
7
8use crate::agent::state::AgentState;
9use crate::agent::{
10    daemon_status, default_state_path, load_agent_state, load_state, state_summary, DaemonStatus,
11};
12use crate::auth_reminder::{load_auth_reminder, AuthReminder};
13use crate::market_hours::ResolvedMarketStatus;
14use crate::rules::RulesConfig;
15use crate::ui::market_status::{self, MarketSnapshot};
16
17const LOG_TAIL_LINES: usize = 30;
18
19#[derive(Debug, Clone)]
20pub struct DashboardContext {
21    pub rules_path: PathBuf,
22    pub rules: RulesConfig,
23    pub state: AgentState,
24    pub state_path: PathBuf,
25    pub daemon: DaemonStatus,
26    pub log_tail: Vec<String>,
27    pub market_status: ResolvedMarketStatus,
28    pub auth_reminder: Option<AuthReminder>,
29}
30
31impl DashboardContext {
32    pub fn load(rules_path: &Path) -> Result<Self> {
33        Self::load_with_snapshot(rules_path, None)
34    }
35
36    pub fn load_with_snapshot(
37        rules_path: &Path,
38        live_market: Option<&MarketSnapshot>,
39    ) -> Result<Self> {
40        let rules = RulesConfig::load(rules_path)?;
41        let state_path = default_state_path(rules_path);
42        let state = if state_path.exists() {
43            load_state(&state_path)?
44        } else {
45            load_agent_state(rules_path, &rules.agent_id)
46        };
47        let daemon = daemon_status(rules_path);
48        let log_tail = tail_lines(&daemon.log_file, LOG_TAIL_LINES);
49        let market_status = market_status::resolve_market_status(rules_path, &state, live_market);
50        let auth_reminder = load_auth_reminder();
51
52        Ok(Self {
53            rules_path: rules_path.to_path_buf(),
54            rules,
55            state,
56            state_path,
57            daemon,
58            log_tail,
59            market_status,
60            auth_reminder,
61        })
62    }
63
64    pub fn load_with_shared_snapshot(
65        rules_path: &Path,
66        live_market: &Arc<Mutex<MarketSnapshot>>,
67    ) -> Result<Self> {
68        let snapshot = live_market.lock().ok().map(|g| g.clone());
69        Self::load_with_snapshot(rules_path, snapshot.as_ref())
70    }
71
72    pub fn to_json(&self) -> Value {
73        json!({
74            "rules_path": self.rules_path,
75            "state_path": self.state_path,
76            "daemon": {
77                "running": self.daemon.running,
78                "pid": self.daemon.pid,
79                "pid_file": self.daemon.pid_file,
80                "log_file": self.daemon.log_file,
81            },
82            "rules_summary": rules_summary_json(&self.rules),
83            "state": state_summary(&self.state),
84            "open_positions_detail": self.state.open_positions.values().collect::<Vec<_>>(),
85            "log_tail": self.log_tail,
86            "market_status": {
87                "open": self.market_status.open,
88                "source": format!("{:?}", self.market_status.source),
89            },
90            "auth_reminder": self.auth_reminder.as_ref().map(|r| json!({
91                "level": r.level.as_str(),
92                "message": r.message,
93                "obtained_at": r.obtained_at,
94                "access_expires_in_seconds": r.access_expires_in_seconds,
95                "refresh_expires_in_seconds": r.refresh_expires_in_seconds,
96                "detail": r.detail_line(),
97            })),
98        })
99    }
100
101    pub fn portfolio_risk_usd(&self) -> f64 {
102        self.state.reserved_risk_usd()
103    }
104
105    pub fn monitor_interval_minutes(&self) -> u64 {
106        self.rules.llm.monitor_interval_minutes(
107            self.rules.schedule.tick_interval_seconds,
108            self.min_open_position_dte(),
109            self.rules.exit_rules.dte_close,
110        )
111    }
112
113    pub fn min_open_position_dte(&self) -> Option<i64> {
114        let today = chrono::Local::now().date_naive();
115        self.state
116            .open_positions
117            .values()
118            .filter_map(|p| {
119                chrono::NaiveDate::parse_from_str(&p.expiry, "%Y-%m-%d")
120                    .ok()
121                    .map(|exp| crate::options::days_to_expiry(exp, today))
122            })
123            .min()
124    }
125
126    pub fn has_open_positions(&self) -> bool {
127        !self.state.open_positions.is_empty()
128    }
129
130    /// Session the agent should be in right now (from market hours, not stale state file).
131    pub fn effective_session(&self) -> &'static str {
132        if self.market_status.open {
133            "regular"
134        } else if self.rules.schedule.overnight.enabled {
135            "overnight"
136        } else {
137            "idle"
138        }
139    }
140
141    pub fn expected_tick_interval_secs(&self) -> u64 {
142        if self.market_status.open {
143            self.rules.schedule.tick_interval_seconds.max(5)
144        } else if self.rules.schedule.overnight.enabled {
145            self.rules.schedule.overnight.tick_interval_seconds.max(300)
146        } else {
147            self.rules.schedule.tick_interval_seconds.max(5)
148        }
149    }
150
151    pub fn last_tick_age_secs(&self) -> Option<i64> {
152        self.state
153            .last_tick
154            .map(|at| (chrono::Utc::now() - at).num_seconds())
155    }
156
157    pub fn tick_is_stale(&self) -> bool {
158        let interval = self.expected_tick_interval_secs() as i64;
159        let threshold = interval * 2 + 60;
160        match self.last_tick_age_secs() {
161            Some(age) => age > threshold,
162            None => true,
163        }
164    }
165}
166
167pub fn rules_summary_json(rules: &RulesConfig) -> Value {
168    json!({
169        "agent_id": rules.agent_id,
170        "watchlist": rules.watchlist_items(),
171        "tick_interval_seconds": rules.schedule.tick_interval_seconds,
172        "overnight_enabled": rules.schedule.overnight.enabled,
173        "vertical_enabled": rules.strategies.vertical.enabled,
174        "iron_condor_enabled": rules.strategies.iron_condor.enabled,
175        "llm_enabled": rules.llm.enabled,
176        "selection_model": rules.llm.effective_selection_model(),
177        "monitor_model": rules.llm.effective_monitor_model(),
178        "review_every_ticks": rules.llm.review_every_ticks,
179        "max_trades_per_day": rules.risk.max_trades_per_day,
180        "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
181    })
182}
183
184pub fn tail_lines(path: &Path, n: usize) -> Vec<String> {
185    let Ok(content) = fs::read_to_string(path) else {
186        return Vec::new();
187    };
188    content
189        .lines()
190        .rev()
191        .take(n)
192        .map(str::to_string)
193        .collect::<Vec<_>>()
194        .into_iter()
195        .rev()
196        .collect()
197}