schwab_cli/ui/
spread_feed.rs1use std::collections::HashMap;
4use std::path::Path;
5use std::sync::{Arc, RwLock};
6use std::time::Duration;
7
8use anyhow::Result;
9use chrono::Utc;
10use schwab_api::TraderApi;
11use schwab_market_data::MarketDataApi;
12
13use crate::agent::exits::{
14 evaluate_position_monitor, load_live_position_groups, mark_from_net_market_value,
15 option_group_from_tracked,
16};
17use crate::agent::paths::active_state_path;
18use crate::agent::state::load_state;
19use crate::rules::RulesConfig;
20use crate::ui::spread_live::{attach_exit_hint, SpreadLiveSnapshot, SpreadPositionMark};
21
22const MARK_REFRESH_SECS: u64 = 15;
23
24pub fn new_spread_snapshot() -> Arc<RwLock<SpreadLiveSnapshot>> {
25 Arc::new(RwLock::new(SpreadLiveSnapshot::default()))
26}
27
28pub fn spawn_spread_mark_feed(
29 rules_path: std::path::PathBuf,
30 simulate: bool,
31 live: Arc<RwLock<SpreadLiveSnapshot>>,
32 market: Arc<MarketDataApi>,
33 trader: Arc<TraderApi>,
34) -> tokio::task::JoinHandle<()> {
35 tokio::spawn(async move {
36 let rules = match RulesConfig::load(&rules_path) {
37 Ok(r) => r,
38 Err(err) => {
39 if let Ok(mut g) = live.write() {
40 g.last_error = Some(format!("rules load: {err:#}"));
41 }
42 return;
43 }
44 };
45 let mut interval = tokio::time::interval(Duration::from_secs(MARK_REFRESH_SECS));
46 interval.tick().await;
47 loop {
48 interval.tick().await;
49 if let Err(err) =
50 refresh_once(&rules_path, &rules, simulate, &market, &trader, &live).await
51 {
52 if let Ok(mut g) = live.write() {
53 g.last_error = Some(err.to_string());
54 }
55 }
56 }
57 })
58}
59
60async fn refresh_once(
61 rules_path: &Path,
62 rules: &RulesConfig,
63 simulate: bool,
64 market: &MarketDataApi,
65 trader: &TraderApi,
66 live: &Arc<RwLock<SpreadLiveSnapshot>>,
67) -> Result<()> {
68 let state_path = active_state_path(rules_path, simulate);
69 let state = load_state(&state_path)?;
70 if state.open_positions.is_empty() {
71 if let Ok(mut g) = live.write() {
72 g.marks.clear();
73 g.last_fetch = Some(Utc::now());
74 g.last_error = None;
75 }
76 return Ok(());
77 }
78
79 let today = Utc::now().date_naive();
80 let live_groups = if simulate {
81 HashMap::new()
82 } else {
83 load_live_position_groups(trader, rules).await.unwrap_or_default()
84 };
85
86 let mut marks = HashMap::new();
87 let fetch_started = Utc::now();
88
89 for (position_id, tracked) in &state.open_positions {
90 let entry_credit = tracked.entry_credit.filter(|c| *c > f64::EPSILON);
91 let group = if simulate {
92 option_group_from_tracked(tracked)
93 } else {
94 live_groups
95 .get(position_id)
96 .cloned()
97 .or_else(|| option_group_from_tracked(tracked))
98 };
99
100 let Some(group) = group else {
101 continue;
102 };
103
104 let monitor =
105 evaluate_position_monitor(market, &group, rules, today, Some(tracked)).await?;
106
107 let spread_mark = monitor
108 .exit
109 .as_ref()
110 .map(|e| e.mark.clone())
111 .or(monitor.mark)
112 .or_else(|| {
113 entry_credit.and_then(|entry| mark_from_net_market_value(&group, entry, today))
114 });
115
116 let Some(mut mark) = spread_mark.map(|m| SpreadPositionMark {
117 mark: m,
118 analytics: monitor.analytics.clone(),
119 imminent_exit: None,
120 mark_age_secs: None,
121 }) else {
122 continue;
123 };
124
125 if let Some(entry) = entry_credit {
126 attach_exit_hint(&mut mark, rules, entry);
127 }
128 mark.mark_age_secs = Some((Utc::now() - fetch_started).num_seconds().max(0));
129 marks.insert(position_id.clone(), mark);
130 }
131
132 if let Ok(mut g) = live.write() {
133 g.marks = marks;
134 g.last_fetch = Some(Utc::now());
135 g.last_error = None;
136 }
137 Ok(())
138}