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