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 #[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 #[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 #[serde(default)]
53 pub last_telegram_llm_at: Option<DateTime<Utc>>,
54 #[serde(default)]
55 pub last_telegram_llm_digest_key: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub sim: Option<crate::agent::sim::SimLedger>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub redeploy_signal: Option<RedeploySignal>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub recent_stop_loss_exits: Vec<StopLossExitRecord>,
65 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
67 pub last_entry_attempts: HashMap<String, DateTime<Utc>>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub entry_proceed_cache: Option<EntryProceedCache>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub last_regime: Option<Value>,
74 #[serde(default)]
76 pub sleeve_peak_equity_usd: f64,
77 #[serde(default)]
79 pub cumulative_realized_pnl_usd: f64,
80 #[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 #[serde(default = "default_one")]
117 pub contracts: u32,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub entry_params: Option<Value>,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub peak_profit_pct: Option<f64>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub entry_pop_pct: Option<f64>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub entry_short_delta: Option<f64>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub protective_order_id: Option<String>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub protective_order_status: Option<String>,
136 #[serde(default)]
138 pub protective_order_attempts: u32,
139 #[serde(default)]
141 pub rolls_used: u32,
142 #[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 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}