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        let secs = self.rules.llm.review_every_ticks.max(1)
107            * self.rules.schedule.tick_interval_seconds.max(1);
108        secs / 60
109    }
110
111    /// Session the agent should be in right now (from market hours, not stale state file).
112    pub fn effective_session(&self) -> &'static str {
113        if self.market_status.open {
114            "regular"
115        } else if self.rules.schedule.overnight.enabled {
116            "overnight"
117        } else {
118            "idle"
119        }
120    }
121
122    pub fn expected_tick_interval_secs(&self) -> u64 {
123        if self.market_status.open {
124            self.rules.schedule.tick_interval_seconds.max(5)
125        } else if self.rules.schedule.overnight.enabled {
126            self.rules.schedule.overnight.tick_interval_seconds.max(300)
127        } else {
128            self.rules.schedule.tick_interval_seconds.max(5)
129        }
130    }
131
132    pub fn last_tick_age_secs(&self) -> Option<i64> {
133        self.state
134            .last_tick
135            .map(|at| (chrono::Utc::now() - at).num_seconds())
136    }
137
138    pub fn tick_is_stale(&self) -> bool {
139        let interval = self.expected_tick_interval_secs() as i64;
140        let threshold = interval * 2 + 60;
141        match self.last_tick_age_secs() {
142            Some(age) => age > threshold,
143            None => true,
144        }
145    }
146}
147
148pub fn rules_summary_json(rules: &RulesConfig) -> Value {
149    json!({
150        "agent_id": rules.agent_id,
151        "watchlist": rules.watchlist,
152        "tick_interval_seconds": rules.schedule.tick_interval_seconds,
153        "overnight_enabled": rules.schedule.overnight.enabled,
154        "vertical_enabled": rules.strategies.vertical.enabled,
155        "iron_condor_enabled": rules.strategies.iron_condor.enabled,
156        "llm_enabled": rules.llm.enabled,
157        "selection_model": rules.llm.effective_selection_model(),
158        "monitor_model": rules.llm.effective_monitor_model(),
159        "review_every_ticks": rules.llm.review_every_ticks,
160        "max_trades_per_day": rules.risk.max_trades_per_day,
161        "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
162    })
163}
164
165pub fn tail_lines(path: &Path, n: usize) -> Vec<String> {
166    let Ok(content) = fs::read_to_string(path) else {
167        return Vec::new();
168    };
169    content
170        .lines()
171        .rev()
172        .take(n)
173        .map(str::to_string)
174        .collect::<Vec<_>>()
175        .into_iter()
176        .rev()
177        .collect()
178}