1#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
4pub struct PickEntry {
5 pub symbol: String,
6 pub kind: String, #[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 #[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
30async 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 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 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 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 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 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
159pub 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 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}