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