Skip to main content

eli_cli/cmd/
picks.rs

1// ── sidecar model ────────────────────────────────────────────────────────────
2
3#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
4pub struct PickEntry {
5    pub symbol: String,
6    pub kind: String, // "ticker" | "odds"
7    #[serde(skip_serializing_if = "Option::is_none")]
8    pub price_at_report: Option<f64>,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub prob_at_report: Option<f64>,
11    pub logged_at: String,
12    // Live fields — populated at query time, not persisted
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub price_now: Option<f64>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub prob_now: Option<f64>,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub delta_pct: Option<f64>,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub delta_pp: Option<f64>,
21}
22
23#[derive(Debug, serde::Serialize, serde::Deserialize)]
24pub struct ReportPicks {
25    pub report_file: String,
26    pub logged_at: String,
27    pub picks: Vec<PickEntry>,
28}
29
30// ── command dispatch ──────────────────────────────────────────────────────────
31
32async fn cmd_picks(cmd: PicksCommand) -> Result<()> {
33    match cmd {
34        PicksCommand::Log(args) => cmd_picks_log(args).await,
35    }
36}
37
38async fn cmd_picks_log(args: PicksLogArgs) -> Result<()> {
39    let report_path = picks_expand_path(&args.report);
40    let report_file = report_path
41        .file_name()
42        .and_then(|n| n.to_str())
43        .unwrap_or("")
44        .to_string();
45
46    let sidecar_path = {
47        let name = report_path
48            .file_name()
49            .and_then(|n| n.to_str())
50            .unwrap_or("")
51            .to_string();
52        report_path.with_file_name(format!("{}.picks.json", name))
53    };
54
55    // Load existing sidecar to merge into
56    let mut existing: ReportPicks = if sidecar_path.exists() {
57        let bytes = std::fs::read(&sidecar_path).context("read sidecar")?;
58        serde_json::from_slice(&bytes).unwrap_or_else(|_| ReportPicks {
59            report_file: report_file.clone(),
60            logged_at: picks_now_iso8601(),
61            picks: vec![],
62        })
63    } else {
64        ReportPicks {
65            report_file: report_file.clone(),
66            logged_at: picks_now_iso8601(),
67            picks: vec![],
68        }
69    };
70
71    // Fetch current prices for tickers
72    let tickers: Vec<String> = args
73        .ticker
74        .iter()
75        .map(|t| t.trim().to_uppercase())
76        .filter(|t| !t.is_empty())
77        .collect();
78
79    let snapshot_prices = if !tickers.is_empty() {
80        picks_fetch_snapshot_prices(&tickers).await.unwrap_or_default()
81    } else {
82        std::collections::HashMap::new()
83    };
84
85    // Fetch current probabilities for markets
86    let markets: Vec<String> = args
87        .market
88        .iter()
89        .map(|m| m.trim().to_string())
90        .filter(|m| !m.is_empty())
91        .collect();
92
93    let mut market_probs: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
94    for slug in &markets {
95        if let Some(prob) = picks_fetch_odds_prob(slug).await {
96            market_probs.insert(slug.clone(), prob);
97        }
98    }
99
100    let now = picks_now_iso8601();
101
102    // Upsert ticker picks
103    for ticker in &tickers {
104        let price = snapshot_prices.get(ticker).copied();
105        if let Some(idx) = existing.picks.iter().position(|p| &p.symbol == ticker) {
106            existing.picks[idx].price_at_report = price;
107            existing.picks[idx].logged_at = now.clone();
108        } else {
109            existing.picks.push(PickEntry {
110                symbol: ticker.clone(),
111                kind: "ticker".into(),
112                price_at_report: price,
113                prob_at_report: None,
114                logged_at: now.clone(),
115                price_now: None,
116                prob_now: None,
117                delta_pct: None,
118                delta_pp: None,
119            });
120        }
121    }
122
123    // Upsert market picks
124    for slug in &markets {
125        let prob = market_probs.get(slug).copied();
126        if let Some(idx) = existing.picks.iter().position(|p| &p.symbol == slug) {
127            existing.picks[idx].prob_at_report = prob;
128            existing.picks[idx].logged_at = now.clone();
129        } else {
130            existing.picks.push(PickEntry {
131                symbol: slug.clone(),
132                kind: "odds".into(),
133                price_at_report: None,
134                prob_at_report: prob,
135                logged_at: now.clone(),
136                price_now: None,
137                prob_now: None,
138                delta_pct: None,
139                delta_pp: None,
140            });
141        }
142    }
143
144    let json = serde_json::to_string_pretty(&existing).context("serialize picks")?;
145    std::fs::write(&sidecar_path, json).context("write sidecar")?;
146
147    println!(
148        "{}",
149        serde_json::json!({
150            "ok": true,
151            "report": report_file,
152            "sidecar": sidecar_path.display().to_string(),
153            "picks_logged": existing.picks.len()
154        })
155    );
156    Ok(())
157}
158
159// ── helpers used by serve.rs too ─────────────────────────────────────────────
160
161pub fn picks_expand_path(s: &str) -> PathBuf {
162    if s.starts_with("~/") {
163        if let Ok(home) = std::env::var("HOME") {
164            return PathBuf::from(format!("{}{}", home, &s[1..]));
165        }
166    }
167    PathBuf::from(s)
168}
169
170fn picks_now_iso8601() -> String {
171    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
172}
173
174pub async fn picks_fetch_snapshot_prices(
175    tickers: &[String],
176) -> anyhow::Result<std::collections::HashMap<String, f64>> {
177    let exe = std::env::current_exe()?;
178    let output = tokio::process::Command::new(&exe)
179        .args([
180            "finance",
181            "timeseries",
182            "--tickers",
183            &tickers.join(","),
184            "--range",
185            "7d",
186            "--granularity",
187            "5m",
188        ])
189        .output()
190        .await?;
191    let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
192    let mut map = std::collections::HashMap::new();
193
194    // Timeseries output shape: { "series": [{ "ticker": "...", "candles": [{ "c": ... }] }] }.
195    // Use the last candle's close as the current price (replacement for the
196    // deleted finance snapshot subcommand).
197    if let Some(arr) = json.get("series").and_then(|v| v.as_array()) {
198        for series in arr {
199            let Some(ticker) = series.get("ticker").and_then(|v| v.as_str()) else {
200                continue;
201            };
202            let Some(candles) = series.get("candles").and_then(|v| v.as_array()) else {
203                continue;
204            };
205            let last_close = candles
206                .iter()
207                .rev()
208                .find_map(|c| c.get("c").and_then(|v| v.as_f64()).filter(|x| x.is_finite() && *x > 0.0));
209            if let Some(price) = last_close {
210                map.insert(ticker.to_string(), price);
211            }
212        }
213    }
214    Ok(map)
215}
216
217pub async fn picks_fetch_odds_prob(market: &str) -> Option<f64> {
218    let exe = std::env::current_exe().ok()?;
219    let output = tokio::process::Command::new(&exe)
220        .args(["finance", "odds", "--market", market])
221        .output()
222        .await
223        .ok()?;
224    let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
225    if let Some(arr) = json["markets"].as_array() {
226        if let Some(first) = arr.first() {
227            return first["probability_yes"]
228                .as_f64()
229                .or_else(|| first["yes_price"].as_f64().map(|v| v / 100.0));
230        }
231    }
232    json["probability_yes"]
233        .as_f64()
234        .or_else(|| json["yes_price"].as_f64().map(|v| v / 100.0))
235}
236
237pub async fn picks_load_with_refresh(
238    sidecar_path: &std::path::Path,
239    refresh: bool,
240) -> Option<ReportPicks> {
241    let bytes = tokio::fs::read(sidecar_path).await.ok()?;
242    let mut picks: ReportPicks = serde_json::from_slice(&bytes).ok()?;
243
244    if refresh {
245        let tickers: Vec<String> = picks
246            .picks
247            .iter()
248            .filter(|p| p.kind == "ticker")
249            .map(|p| p.symbol.clone())
250            .collect();
251
252        let prices = if !tickers.is_empty() {
253            picks_fetch_snapshot_prices(&tickers).await.unwrap_or_default()
254        } else {
255            std::collections::HashMap::new()
256        };
257
258        for pick in &mut picks.picks {
259            if pick.kind == "ticker" {
260                let cur = prices.get(&pick.symbol).copied();
261                pick.price_now = cur;
262                if let (Some(entry), Some(cur)) = (pick.price_at_report, cur) {
263                    pick.delta_pct = Some((cur - entry) / entry * 100.0);
264                }
265            } else if pick.kind == "odds" {
266                let cur = picks_fetch_odds_prob(&pick.symbol).await;
267                pick.prob_now = cur;
268                if let (Some(entry), Some(cur)) = (pick.prob_at_report, cur) {
269                    pick.delta_pp = Some((cur - entry) * 100.0);
270                }
271            }
272        }
273    }
274
275    Some(picks)
276}