Skip to main content

schwab_cli/ui/
spread_feed.rs

1//! Background Schwab spread mark refresh for options watch TUI.
2
3use 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}