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;
11use crate::rules::{EntryPolicyConfig, RulesConfig};
12
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct AgentState {
15    pub agent_id: String,
16    pub last_tick: Option<DateTime<Utc>>,
17    pub trades_today: u32,
18    pub trades_day: Option<NaiveDate>,
19    /// Successful defensive rolls today (reset with `trades_day`).
20    #[serde(default)]
21    pub rolls_today: u32,
22    pub open_positions: HashMap<String, TrackedPosition>,
23    pub last_actions: Vec<AgentAction>,
24    #[serde(default)]
25    pub pending_order_ids: Vec<String>,
26    #[serde(default)]
27    pub pending_orders: Vec<PendingOrder>,
28    #[serde(default)]
29    pub tick_count: u64,
30    #[serde(default)]
31    pub last_llm_review_tick: Option<u64>,
32    #[serde(default)]
33    pub llm_review_count: u64,
34    #[serde(default)]
35    pub last_llm_summary: Option<Value>,
36    #[serde(default)]
37    pub last_session: Option<String>,
38    #[serde(default)]
39    pub regular_tick_count: u64,
40    #[serde(default)]
41    pub last_overnight_digest_at: Option<DateTime<Utc>>,
42    #[serde(default)]
43    pub open_playbook: Option<Value>,
44    /// Last EQO regular-session open flag from agent tick (Schwab hours).
45    #[serde(default)]
46    pub last_market_open: Option<bool>,
47    #[serde(default)]
48    pub last_auth_reminder_level: Option<String>,
49    #[serde(default)]
50    pub last_auth_reminder_at: Option<DateTime<Utc>>,
51    /// Last LLM review pushed to Telegram (for digest dedup).
52    #[serde(default)]
53    pub last_telegram_llm_at: Option<DateTime<Utc>>,
54    #[serde(default)]
55    pub last_telegram_llm_digest_key: Option<String>,
56    /// Paper-trading ledger when running with --simulate (separate state file).
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub sim: Option<crate::agent::sim::SimLedger>,
59    /// Set after a thesis-driven exit — optional redeploy cooldown on that underlying.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub redeploy_signal: Option<RedeploySignal>,
62    /// Recent stop-loss exits for re-entry cooldown.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub recent_stop_loss_exits: Vec<StopLossExitRecord>,
65    /// Last live entry attempt per candidate position_id (limits retry spam).
66    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
67    pub last_entry_attempts: HashMap<String, DateTime<Utc>>,
68    /// Cached LLM `proceed` for current candidate fingerprints.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub entry_proceed_cache: Option<EntryProceedCache>,
71    /// Last options regime snapshot (strategy selection).
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub last_regime: Option<Value>,
74    /// High-water mark for options sleeve equity (drawdown halt).
75    #[serde(default)]
76    pub sleeve_peak_equity_usd: f64,
77    /// Cumulative realized PnL for live (non-sim) closes. Sim uses `sim.realized_pnl_usd`.
78    #[serde(default)]
79    pub cumulative_realized_pnl_usd: f64,
80    /// When set, new entries are paused (exits continue). Cleared when condition recovers.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub trading_halted_reason: Option<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct StopLossExitRecord {
87    pub at: DateTime<Utc>,
88    pub underlying: String,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct EntryProceedCache {
93    pub at: DateTime<Utc>,
94    pub candidate_fingerprints: Vec<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct RedeploySignal {
99    pub at: DateTime<Utc>,
100    pub reason: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub underlying: Option<String>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct TrackedPosition {
107    pub position_id: String,
108    pub account_hash: String,
109    pub underlying: String,
110    pub expiry: String,
111    pub strategy: String,
112    pub opened_at: DateTime<Utc>,
113    pub entry_credit: Option<f64>,
114    pub max_loss_usd: f64,
115    /// Spread quantity (each leg at Schwab should match this count).
116    #[serde(default = "default_one")]
117    pub contracts: u32,
118    /// Strategy params for sim marks / vertical reconstruction.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub entry_params: Option<Value>,
121    /// Peak unrealized profit % observed while open (thesis giveback exits).
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub peak_profit_pct: Option<f64>,
124    /// POP % at entry (from chain analytics).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub entry_pop_pct: Option<f64>,
127    /// |short_delta| at entry.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub entry_short_delta: Option<f64>,
130    /// Broker order id for the resting GTC profit-target close order, if placed.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub protective_order_id: Option<String>,
133    /// Last known status of `protective_order_id` (e.g. "WORKING", "FILLED", "CANCELED").
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub protective_order_status: Option<String>,
136    /// Consecutive failed attempts to place the protective order (drives reconcile retry/backoff).
137    #[serde(default)]
138    pub protective_order_attempts: u32,
139    /// Successful defensive rolls applied to this lineage (carried onto replacement).
140    #[serde(default)]
141    pub rolls_used: u32,
142    /// When the last defensive roll opened this position (if any).
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub last_roll_at: Option<DateTime<Utc>>,
145}
146
147pub fn update_peak_profit_pct(position: &mut TrackedPosition, profit_pct: f64) {
148    let peak = position.peak_profit_pct.unwrap_or(profit_pct);
149    position.peak_profit_pct = Some(peak.max(profit_pct));
150}
151
152pub fn is_thesis_exit_reason(reason: &str) -> bool {
153    reason.starts_with("thesis_")
154}
155
156pub fn is_stop_loss_exit_reason(reason: &str) -> bool {
157    reason == "stop_loss"
158}
159
160pub fn record_stop_loss_exit(state: &mut AgentState, underlying: &str) {
161    state.recent_stop_loss_exits.push(StopLossExitRecord {
162        at: Utc::now(),
163        underlying: underlying.to_uppercase(),
164    });
165    prune_stop_loss_records(&mut state.recent_stop_loss_exits, 45);
166}
167
168fn prune_stop_loss_records(records: &mut Vec<StopLossExitRecord>, keep_days: i64) {
169    let cutoff = Utc::now() - chrono::Duration::days(keep_days);
170    records.retain(|r| r.at >= cutoff);
171}
172
173pub fn stop_loss_re_entry_blocked(
174    rules: &RulesConfig,
175    state: &AgentState,
176    underlying: &str,
177) -> Option<i64> {
178    let cfg = &rules.risk.re_entry_after_stop_loss;
179    if !cfg.enabled || cfg.cooldown_days == 0 {
180        return None;
181    }
182    let sym = underlying.to_uppercase();
183    let latest = state
184        .recent_stop_loss_exits
185        .iter()
186        .filter(|r| r.underlying.eq_ignore_ascii_case(&sym))
187        .map(|r| r.at)
188        .max()?;
189    let elapsed_days = Utc::now().signed_duration_since(latest).num_days();
190    let remaining = cfg.cooldown_days as i64 - elapsed_days;
191    if remaining > 0 {
192        Some(remaining)
193    } else {
194        None
195    }
196}
197
198pub fn entry_attempt_cooldown_active(
199    policy: &EntryPolicyConfig,
200    state: &AgentState,
201    position_id: &str,
202) -> Option<i64> {
203    if policy.entry_attempt_cooldown_minutes == 0 {
204        return None;
205    }
206    let at = state.last_entry_attempts.get(position_id)?;
207    let elapsed = Utc::now().signed_duration_since(*at).num_minutes();
208    let limit = policy.entry_attempt_cooldown_minutes as i64;
209    if elapsed < limit {
210        Some(limit - elapsed)
211    } else {
212        None
213    }
214}
215
216pub fn record_entry_attempt(state: &mut AgentState, position_id: &str) {
217    state
218        .last_entry_attempts
219        .insert(position_id.to_string(), Utc::now());
220}
221
222pub fn candidate_fingerprint(signal: &Value) -> Option<String> {
223    signal
224        .get("position_id")
225        .and_then(|v| v.as_str())
226        .map(str::to_string)
227}
228
229pub fn entry_proceed_cache_valid(
230    policy: &EntryPolicyConfig,
231    cache: &EntryProceedCache,
232    fingerprints: &[String],
233) -> bool {
234    if fingerprints.is_empty() {
235        return false;
236    }
237    if policy.proceed_cache_minutes == 0 {
238        return false;
239    }
240    let age = Utc::now().signed_duration_since(cache.at).num_minutes();
241    if age > policy.proceed_cache_minutes as i64 {
242        return false;
243    }
244    cache.candidate_fingerprints == fingerprints
245}
246
247impl Default for TrackedPosition {
248    fn default() -> Self {
249        Self {
250            position_id: String::new(),
251            account_hash: String::new(),
252            underlying: String::new(),
253            expiry: String::new(),
254            strategy: String::new(),
255            opened_at: Utc::now(),
256            entry_credit: None,
257            max_loss_usd: 0.0,
258            contracts: 1,
259            entry_params: None,
260            peak_profit_pct: None,
261            entry_pop_pct: None,
262            entry_short_delta: None,
263            protective_order_id: None,
264            protective_order_status: None,
265            protective_order_attempts: 0,
266            rolls_used: 0,
267            last_roll_at: None,
268        }
269    }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
273#[serde(rename_all = "snake_case")]
274pub enum PendingOrderAction {
275    Entry,
276    Exit,
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct PendingOrder {
281    pub order_id: String,
282    pub account_hash: String,
283    pub action: PendingOrderAction,
284    pub position_id: String,
285    pub reserved_risk_usd: f64,
286    pub submitted_at: DateTime<Utc>,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub last_status: Option<String>,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub detail: Option<Value>,
291}
292
293fn default_one() -> u32 {
294    1
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct AgentAction {
299    pub at: DateTime<Utc>,
300    pub action: String,
301    pub detail: Value,
302}
303
304pub fn load_state(path: &Path) -> Result<AgentState> {
305    if !path.exists() {
306        return Ok(AgentState::default());
307    }
308    let content = fs::read_to_string(path)?;
309    Ok(serde_json::from_str(&content)?)
310}
311
312pub fn save_state(path: &Path, state: &AgentState) -> Result<()> {
313    if let Some(parent) = path.parent() {
314        fs::create_dir_all(parent)?;
315    }
316    let content = serde_json::to_string_pretty(state)?;
317    fs::write(path, content)?;
318    Ok(())
319}
320
321impl AgentState {
322    pub fn reset_daily_if_needed(&mut self, today: NaiveDate) {
323        if self.trades_day != Some(today) {
324            self.trades_today = 0;
325            self.rolls_today = 0;
326            self.trades_day = Some(today);
327        }
328    }
329
330    pub fn record_action(&mut self, action: &str, detail: Value) {
331        self.last_actions.push(AgentAction {
332            at: Utc::now(),
333            action: action.to_string(),
334            detail,
335        });
336        if self.last_actions.len() > 100 {
337            let drain = self.last_actions.len() - 100;
338            self.last_actions.drain(0..drain);
339        }
340    }
341
342    pub fn count_open_for_strategy(&self, account_hash: &str, strategy: StrategyKind) -> u32 {
343        self.open_positions
344            .values()
345            .filter(|p| p.account_hash == account_hash && p.strategy == strategy.as_str())
346            .count() as u32
347    }
348
349    pub fn count_open_for_underlying(&self, account_hash: &str, underlying: &str) -> u32 {
350        self.open_positions
351            .values()
352            .filter(|p| {
353                p.account_hash == account_hash && p.underlying.eq_ignore_ascii_case(underlying)
354            })
355            .count() as u32
356    }
357
358    /// Open positions whose underlying is in `symbols` (case-insensitive).
359    pub fn count_open_in_symbol_set(&self, account_hash: &str, symbols: &[String]) -> u32 {
360        self.open_positions
361            .values()
362            .filter(|p| {
363                p.account_hash == account_hash
364                    && symbols
365                        .iter()
366                        .any(|s| s.eq_ignore_ascii_case(&p.underlying))
367            })
368            .count() as u32
369    }
370
371    pub fn pending_entry_count_in_symbol_set(&self, account_hash: &str, symbols: &[String]) -> u32 {
372        self.pending_orders
373            .iter()
374            .filter(|p| {
375                p.action == PendingOrderAction::Entry
376                    && p.account_hash == account_hash
377                    && position_id_underlying(&p.position_id).is_some_and(|u| {
378                        symbols.iter().any(|s| s.eq_ignore_ascii_case(u))
379                    })
380            })
381            .count() as u32
382    }
383
384    pub fn pending_entry_count_for_underlying(
385        &self,
386        account_hash: &str,
387        underlying: &str,
388    ) -> u32 {
389        self.pending_orders
390            .iter()
391            .filter(|p| {
392                p.action == PendingOrderAction::Entry
393                    && p.account_hash == account_hash
394                    && position_id_underlying(&p.position_id)
395                        .is_some_and(|u| u.eq_ignore_ascii_case(underlying))
396            })
397            .count() as u32
398    }
399
400    pub fn pending_entry_count(&self) -> u32 {
401        self.pending_orders
402            .iter()
403            .filter(|p| p.action == PendingOrderAction::Entry)
404            .count() as u32
405    }
406
407    pub fn pending_count(&self) -> usize {
408        self.pending_orders.len().max(self.pending_order_ids.len())
409    }
410
411    pub fn trades_capacity_used(&self) -> u32 {
412        self.trades_today.saturating_add(self.pending_entry_count())
413    }
414
415    pub fn open_risk_usd(&self) -> f64 {
416        self.open_positions
417            .values()
418            .map(|p| p.max_loss_usd.max(0.0))
419            .sum()
420    }
421
422    pub fn pending_entry_risk_usd(&self) -> f64 {
423        self.pending_orders
424            .iter()
425            .filter(|p| p.action == PendingOrderAction::Entry)
426            .map(|p| p.reserved_risk_usd.max(0.0))
427            .sum()
428    }
429
430    pub fn reserved_risk_usd(&self) -> f64 {
431        self.open_risk_usd() + self.pending_entry_risk_usd()
432    }
433
434    pub fn has_pending_position(&self, position_id: &str) -> bool {
435        self.pending_orders
436            .iter()
437            .any(|p| p.position_id == position_id)
438    }
439
440    pub fn add_pending_order(&mut self, pending: PendingOrder) {
441        if !self
442            .pending_order_ids
443            .iter()
444            .any(|id| id == &pending.order_id)
445        {
446            self.pending_order_ids.push(pending.order_id.clone());
447        }
448        if let Some(existing) = self
449            .pending_orders
450            .iter_mut()
451            .find(|p| p.order_id == pending.order_id)
452        {
453            *existing = pending;
454        } else {
455            self.pending_orders.push(pending);
456        }
457    }
458
459    pub fn remove_pending_order(&mut self, order_id: &str) -> Option<PendingOrder> {
460        self.pending_order_ids.retain(|id| id != order_id);
461        let idx = self
462            .pending_orders
463            .iter()
464            .position(|p| p.order_id == order_id)?;
465        Some(self.pending_orders.remove(idx))
466    }
467
468    pub fn clear_legacy_pending_ids(&mut self) {
469        let structured: std::collections::HashSet<&str> = self
470            .pending_orders
471            .iter()
472            .map(|p| p.order_id.as_str())
473            .collect();
474        self.pending_order_ids
475            .retain(|id| structured.contains(id.as_str()));
476    }
477
478    pub fn total_contracts(&self) -> u32 {
479        self.open_positions
480            .values()
481            .map(|p| p.contracts.max(1))
482            .sum()
483    }
484}
485
486pub fn state_summary(state: &AgentState) -> Value {
487    json!({
488        "agent_id": state.agent_id,
489        "last_tick": state.last_tick,
490        "trades_today": state.trades_today,
491        "open_positions": state.open_positions.len(),
492        "tick_count": state.tick_count,
493        "last_llm_review_tick": state.last_llm_review_tick,
494        "last_llm_summary": state.last_llm_summary,
495        "last_session": state.last_session,
496        "regular_tick_count": state.regular_tick_count,
497        "last_overnight_digest_at": state.last_overnight_digest_at,
498        "open_playbook": state.open_playbook,
499        "last_market_open": state.last_market_open,
500        "last_auth_reminder_at": state.last_auth_reminder_at,
501        "pending_orders": state.pending_count(),
502        "reserved_risk_usd": state.reserved_risk_usd(),
503        "pending_orders_detail": state.pending_orders,
504        "recent_actions": state.last_actions.iter().rev().take(10).collect::<Vec<_>>(),
505    })
506}
507
508fn position_id_underlying(position_id: &str) -> Option<&str> {
509    position_id.split('|').nth(1)
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515    use crate::rules::{ReEntryAfterStopLoss, RiskConfig, RulesConfig};
516
517    #[test]
518    fn reserved_risk_includes_pending_entries_only() {
519        let mut state = AgentState::default();
520        state.open_positions.insert(
521            "pos".into(),
522            TrackedPosition {
523                position_id: "pos".into(),
524                account_hash: "acct".into(),
525                underlying: "IWM".into(),
526                expiry: "2026-07-31".into(),
527                strategy: "vertical".into(),
528                opened_at: Utc::now(),
529                entry_credit: Some(0.25),
530                max_loss_usd: 175.0,
531                contracts: 1,
532                entry_params: None,
533                ..Default::default()
534            },
535        );
536        state.add_pending_order(PendingOrder {
537            order_id: "entry-1".into(),
538            account_hash: "acct".into(),
539            action: PendingOrderAction::Entry,
540            position_id: "pending-entry".into(),
541            reserved_risk_usd: 170.0,
542            submitted_at: Utc::now(),
543            last_status: Some("WORKING".into()),
544            detail: None,
545        });
546        state.add_pending_order(PendingOrder {
547            order_id: "exit-1".into(),
548            account_hash: "acct".into(),
549            action: PendingOrderAction::Exit,
550            position_id: "pos".into(),
551            reserved_risk_usd: 0.0,
552            submitted_at: Utc::now(),
553            last_status: Some("WORKING".into()),
554            detail: None,
555        });
556
557        assert_eq!(state.pending_entry_count(), 1);
558        assert!((state.reserved_risk_usd() - 345.0).abs() < 0.01);
559    }
560
561    #[test]
562    fn stop_loss_re_entry_blocked_within_cooldown() {
563        let mut rules = RulesConfig {
564            version: 1,
565            agent_id: "t".into(),
566            accounts: vec![],
567            schedule: Default::default(),
568            strategies: Default::default(),
569            watchlist: vec![],
570            entry_policy: Default::default(),
571            entry_rules: Default::default(),
572            exit_rules: Default::default(),
573            risk: RiskConfig {
574                re_entry_after_stop_loss: ReEntryAfterStopLoss {
575                    enabled: true,
576                    cooldown_days: 5,
577                },
578                ..Default::default()
579            },
580            regime: Default::default(),
581            execution: Default::default(),
582            llm: Default::default(),
583            notify: Default::default(),
584            simulation: None,
585        };
586        let mut state = AgentState::default();
587        record_stop_loss_exit(&mut state, "IWM");
588        assert!(stop_loss_re_entry_blocked(&rules, &state, "IWM").is_some());
589        rules.risk.re_entry_after_stop_loss.enabled = false;
590        assert!(stop_loss_re_entry_blocked(&rules, &state, "IWM").is_none());
591    }
592}