1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use anyhow::Result;
6use chrono::{DateTime, NaiveDate, Utc};
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9
10use crate::options::types::StrategyKind;
11
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct AgentState {
14 pub agent_id: String,
15 pub last_tick: Option<DateTime<Utc>>,
16 pub trades_today: u32,
17 pub trades_day: Option<NaiveDate>,
18 pub open_positions: HashMap<String, TrackedPosition>,
19 pub last_actions: Vec<AgentAction>,
20 #[serde(default)]
21 pub pending_order_ids: Vec<String>,
22 #[serde(default)]
23 pub pending_orders: Vec<PendingOrder>,
24 #[serde(default)]
25 pub tick_count: u64,
26 #[serde(default)]
27 pub last_llm_review_tick: Option<u64>,
28 #[serde(default)]
29 pub llm_review_count: u64,
30 #[serde(default)]
31 pub last_llm_summary: Option<Value>,
32 #[serde(default)]
33 pub last_session: Option<String>,
34 #[serde(default)]
35 pub regular_tick_count: u64,
36 #[serde(default)]
37 pub last_overnight_digest_at: Option<DateTime<Utc>>,
38 #[serde(default)]
39 pub open_playbook: Option<Value>,
40 #[serde(default)]
42 pub last_market_open: Option<bool>,
43 #[serde(default)]
44 pub last_auth_reminder_level: Option<String>,
45 #[serde(default)]
46 pub last_auth_reminder_at: Option<DateTime<Utc>>,
47 #[serde(default)]
49 pub last_telegram_llm_at: Option<DateTime<Utc>>,
50 #[serde(default)]
51 pub last_telegram_llm_digest_key: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub sim: Option<crate::agent::sim::SimLedger>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub redeploy_signal: Option<RedeploySignal>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RedeploySignal {
62 pub at: DateTime<Utc>,
63 pub reason: String,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub underlying: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct TrackedPosition {
70 pub position_id: String,
71 pub account_hash: String,
72 pub underlying: String,
73 pub expiry: String,
74 pub strategy: String,
75 pub opened_at: DateTime<Utc>,
76 pub entry_credit: Option<f64>,
77 pub max_loss_usd: f64,
78 #[serde(default = "default_one")]
80 pub contracts: u32,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub entry_params: Option<Value>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub peak_profit_pct: Option<f64>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub entry_pop_pct: Option<f64>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub entry_short_delta: Option<f64>,
93}
94
95pub fn update_peak_profit_pct(position: &mut TrackedPosition, profit_pct: f64) {
96 let peak = position.peak_profit_pct.unwrap_or(profit_pct);
97 position.peak_profit_pct = Some(peak.max(profit_pct));
98}
99
100pub fn is_thesis_exit_reason(reason: &str) -> bool {
101 reason.starts_with("thesis_")
102}
103
104impl Default for TrackedPosition {
105 fn default() -> Self {
106 Self {
107 position_id: String::new(),
108 account_hash: String::new(),
109 underlying: String::new(),
110 expiry: String::new(),
111 strategy: String::new(),
112 opened_at: Utc::now(),
113 entry_credit: None,
114 max_loss_usd: 0.0,
115 contracts: 1,
116 entry_params: None,
117 peak_profit_pct: None,
118 entry_pop_pct: None,
119 entry_short_delta: None,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
125#[serde(rename_all = "snake_case")]
126pub enum PendingOrderAction {
127 Entry,
128 Exit,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct PendingOrder {
133 pub order_id: String,
134 pub account_hash: String,
135 pub action: PendingOrderAction,
136 pub position_id: String,
137 pub reserved_risk_usd: f64,
138 pub submitted_at: DateTime<Utc>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub last_status: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub detail: Option<Value>,
143}
144
145fn default_one() -> u32 {
146 1
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct AgentAction {
151 pub at: DateTime<Utc>,
152 pub action: String,
153 pub detail: Value,
154}
155
156pub fn load_state(path: &Path) -> Result<AgentState> {
157 if !path.exists() {
158 return Ok(AgentState::default());
159 }
160 let content = fs::read_to_string(path)?;
161 Ok(serde_json::from_str(&content)?)
162}
163
164pub fn save_state(path: &Path, state: &AgentState) -> Result<()> {
165 if let Some(parent) = path.parent() {
166 fs::create_dir_all(parent)?;
167 }
168 let content = serde_json::to_string_pretty(state)?;
169 fs::write(path, content)?;
170 Ok(())
171}
172
173impl AgentState {
174 pub fn reset_daily_if_needed(&mut self, today: NaiveDate) {
175 if self.trades_day != Some(today) {
176 self.trades_today = 0;
177 self.trades_day = Some(today);
178 }
179 }
180
181 pub fn record_action(&mut self, action: &str, detail: Value) {
182 self.last_actions.push(AgentAction {
183 at: Utc::now(),
184 action: action.to_string(),
185 detail,
186 });
187 if self.last_actions.len() > 100 {
188 let drain = self.last_actions.len() - 100;
189 self.last_actions.drain(0..drain);
190 }
191 }
192
193 pub fn count_open_for_strategy(&self, account_hash: &str, strategy: StrategyKind) -> u32 {
194 self.open_positions
195 .values()
196 .filter(|p| p.account_hash == account_hash && p.strategy == strategy.as_str())
197 .count() as u32
198 }
199
200 pub fn pending_entry_count(&self) -> u32 {
201 self.pending_orders
202 .iter()
203 .filter(|p| p.action == PendingOrderAction::Entry)
204 .count() as u32
205 }
206
207 pub fn pending_count(&self) -> usize {
208 self.pending_orders.len().max(self.pending_order_ids.len())
209 }
210
211 pub fn trades_capacity_used(&self) -> u32 {
212 self.trades_today.saturating_add(self.pending_entry_count())
213 }
214
215 pub fn open_risk_usd(&self) -> f64 {
216 self.open_positions
217 .values()
218 .map(|p| p.max_loss_usd.max(0.0))
219 .sum()
220 }
221
222 pub fn pending_entry_risk_usd(&self) -> f64 {
223 self.pending_orders
224 .iter()
225 .filter(|p| p.action == PendingOrderAction::Entry)
226 .map(|p| p.reserved_risk_usd.max(0.0))
227 .sum()
228 }
229
230 pub fn reserved_risk_usd(&self) -> f64 {
231 self.open_risk_usd() + self.pending_entry_risk_usd()
232 }
233
234 pub fn has_pending_position(&self, position_id: &str) -> bool {
235 self.pending_orders
236 .iter()
237 .any(|p| p.position_id == position_id)
238 }
239
240 pub fn add_pending_order(&mut self, pending: PendingOrder) {
241 if !self
242 .pending_order_ids
243 .iter()
244 .any(|id| id == &pending.order_id)
245 {
246 self.pending_order_ids.push(pending.order_id.clone());
247 }
248 if let Some(existing) = self
249 .pending_orders
250 .iter_mut()
251 .find(|p| p.order_id == pending.order_id)
252 {
253 *existing = pending;
254 } else {
255 self.pending_orders.push(pending);
256 }
257 }
258
259 pub fn remove_pending_order(&mut self, order_id: &str) -> Option<PendingOrder> {
260 self.pending_order_ids.retain(|id| id != order_id);
261 let idx = self
262 .pending_orders
263 .iter()
264 .position(|p| p.order_id == order_id)?;
265 Some(self.pending_orders.remove(idx))
266 }
267
268 pub fn clear_legacy_pending_ids(&mut self) {
269 let structured: std::collections::HashSet<&str> = self
270 .pending_orders
271 .iter()
272 .map(|p| p.order_id.as_str())
273 .collect();
274 self.pending_order_ids
275 .retain(|id| structured.contains(id.as_str()));
276 }
277
278 pub fn total_contracts(&self) -> u32 {
279 self.open_positions
280 .values()
281 .map(|p| p.contracts.max(1))
282 .sum()
283 }
284}
285
286pub fn state_summary(state: &AgentState) -> Value {
287 json!({
288 "agent_id": state.agent_id,
289 "last_tick": state.last_tick,
290 "trades_today": state.trades_today,
291 "open_positions": state.open_positions.len(),
292 "tick_count": state.tick_count,
293 "last_llm_review_tick": state.last_llm_review_tick,
294 "last_llm_summary": state.last_llm_summary,
295 "last_session": state.last_session,
296 "regular_tick_count": state.regular_tick_count,
297 "last_overnight_digest_at": state.last_overnight_digest_at,
298 "open_playbook": state.open_playbook,
299 "last_market_open": state.last_market_open,
300 "last_auth_reminder_at": state.last_auth_reminder_at,
301 "pending_orders": state.pending_count(),
302 "reserved_risk_usd": state.reserved_risk_usd(),
303 "pending_orders_detail": state.pending_orders,
304 "recent_actions": state.last_actions.iter().rev().take(10).collect::<Vec<_>>(),
305 })
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn reserved_risk_includes_pending_entries_only() {
314 let mut state = AgentState::default();
315 state.open_positions.insert(
316 "pos".into(),
317 TrackedPosition {
318 position_id: "pos".into(),
319 account_hash: "acct".into(),
320 underlying: "IWM".into(),
321 expiry: "2026-07-31".into(),
322 strategy: "vertical".into(),
323 opened_at: Utc::now(),
324 entry_credit: Some(0.25),
325 max_loss_usd: 175.0,
326 contracts: 1,
327 entry_params: None,
328 ..Default::default()
329 },
330 );
331 state.add_pending_order(PendingOrder {
332 order_id: "entry-1".into(),
333 account_hash: "acct".into(),
334 action: PendingOrderAction::Entry,
335 position_id: "pending-entry".into(),
336 reserved_risk_usd: 170.0,
337 submitted_at: Utc::now(),
338 last_status: Some("WORKING".into()),
339 detail: None,
340 });
341 state.add_pending_order(PendingOrder {
342 order_id: "exit-1".into(),
343 account_hash: "acct".into(),
344 action: PendingOrderAction::Exit,
345 position_id: "pos".into(),
346 reserved_risk_usd: 0.0,
347 submitted_at: Utc::now(),
348 last_status: Some("WORKING".into()),
349 detail: None,
350 });
351
352 assert_eq!(state.pending_entry_count(), 1);
353 assert!((state.reserved_risk_usd() - 345.0).abs() < 0.01);
354 }
355}