Skip to main content

schwab_cli/agent/
state.rs

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    /// Last EQO regular-session open flag from agent tick (Schwab hours).
41    #[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    /// Last LLM review pushed to Telegram (for digest dedup).
48    #[serde(default)]
49    pub last_telegram_llm_at: Option<DateTime<Utc>>,
50    #[serde(default)]
51    pub last_telegram_llm_digest_key: Option<String>,
52    /// Paper-trading ledger when running with --simulate (separate state file).
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub sim: Option<crate::agent::sim::SimLedger>,
55    /// Set after a thesis-driven exit — prioritizes redeploy scan on this underlying.
56    #[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    /// Spread quantity (each leg at Schwab should match this count).
79    #[serde(default = "default_one")]
80    pub contracts: u32,
81    /// Strategy params for sim marks / vertical reconstruction.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub entry_params: Option<Value>,
84    /// Peak unrealized profit % observed while open (thesis giveback exits).
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub peak_profit_pct: Option<f64>,
87    /// POP % at entry (from chain analytics).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub entry_pop_pct: Option<f64>,
90    /// |short_delta| at entry.
91    #[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}