#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct PickEntry {
pub symbol: String,
pub kind: String, #[serde(skip_serializing_if = "Option::is_none")]
pub price_at_report: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prob_at_report: Option<f64>,
pub logged_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub price_now: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prob_now: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_pct: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta_pp: Option<f64>,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ReportPicks {
pub report_file: String,
pub logged_at: String,
pub picks: Vec<PickEntry>,
}
async fn cmd_picks(cmd: PicksCommand) -> Result<()> {
match cmd {
PicksCommand::Log(args) => cmd_picks_log(args).await,
}
}
async fn cmd_picks_log(args: PicksLogArgs) -> Result<()> {
let report_path = picks_expand_path(&args.report);
let report_file = report_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let sidecar_path = {
let name = report_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
report_path.with_file_name(format!("{}.picks.json", name))
};
let mut existing: ReportPicks = if sidecar_path.exists() {
let bytes = std::fs::read(&sidecar_path).context("read sidecar")?;
serde_json::from_slice(&bytes).unwrap_or_else(|_| ReportPicks {
report_file: report_file.clone(),
logged_at: picks_now_iso8601(),
picks: vec![],
})
} else {
ReportPicks {
report_file: report_file.clone(),
logged_at: picks_now_iso8601(),
picks: vec![],
}
};
let tickers: Vec<String> = args
.ticker
.iter()
.map(|t| t.trim().to_uppercase())
.filter(|t| !t.is_empty())
.collect();
let snapshot_prices = if !tickers.is_empty() {
picks_fetch_snapshot_prices(&tickers).await.unwrap_or_default()
} else {
std::collections::HashMap::new()
};
let markets: Vec<String> = args
.market
.iter()
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
.collect();
let mut market_probs: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
for slug in &markets {
if let Some(prob) = picks_fetch_odds_prob(slug).await {
market_probs.insert(slug.clone(), prob);
}
}
let now = picks_now_iso8601();
for ticker in &tickers {
let price = snapshot_prices.get(ticker).copied();
if let Some(idx) = existing.picks.iter().position(|p| &p.symbol == ticker) {
existing.picks[idx].price_at_report = price;
existing.picks[idx].logged_at = now.clone();
} else {
existing.picks.push(PickEntry {
symbol: ticker.clone(),
kind: "ticker".into(),
price_at_report: price,
prob_at_report: None,
logged_at: now.clone(),
price_now: None,
prob_now: None,
delta_pct: None,
delta_pp: None,
});
}
}
for slug in &markets {
let prob = market_probs.get(slug).copied();
if let Some(idx) = existing.picks.iter().position(|p| &p.symbol == slug) {
existing.picks[idx].prob_at_report = prob;
existing.picks[idx].logged_at = now.clone();
} else {
existing.picks.push(PickEntry {
symbol: slug.clone(),
kind: "odds".into(),
price_at_report: None,
prob_at_report: prob,
logged_at: now.clone(),
price_now: None,
prob_now: None,
delta_pct: None,
delta_pp: None,
});
}
}
let json = serde_json::to_string_pretty(&existing).context("serialize picks")?;
std::fs::write(&sidecar_path, json).context("write sidecar")?;
println!(
"{}",
serde_json::json!({
"ok": true,
"report": report_file,
"sidecar": sidecar_path.display().to_string(),
"picks_logged": existing.picks.len()
})
);
Ok(())
}
pub fn picks_expand_path(s: &str) -> PathBuf {
if s.starts_with("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(format!("{}{}", home, &s[1..]));
}
}
PathBuf::from(s)
}
fn picks_now_iso8601() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
pub async fn picks_fetch_snapshot_prices(
tickers: &[String],
) -> anyhow::Result<std::collections::HashMap<String, f64>> {
let exe = std::env::current_exe()?;
let output = tokio::process::Command::new(&exe)
.args([
"finance",
"timeseries",
"--tickers",
&tickers.join(","),
"--range",
"7d",
"--granularity",
"5m",
])
.output()
.await?;
let json: serde_json::Value = serde_json::from_slice(&output.stdout)?;
let mut map = std::collections::HashMap::new();
if let Some(arr) = json.get("series").and_then(|v| v.as_array()) {
for series in arr {
let Some(ticker) = series.get("ticker").and_then(|v| v.as_str()) else {
continue;
};
let Some(candles) = series.get("candles").and_then(|v| v.as_array()) else {
continue;
};
let last_close = candles
.iter()
.rev()
.find_map(|c| c.get("c").and_then(|v| v.as_f64()).filter(|x| x.is_finite() && *x > 0.0));
if let Some(price) = last_close {
map.insert(ticker.to_string(), price);
}
}
}
Ok(map)
}
pub async fn picks_fetch_odds_prob(market: &str) -> Option<f64> {
let exe = std::env::current_exe().ok()?;
let output = tokio::process::Command::new(&exe)
.args(["finance", "odds", "--market", market])
.output()
.await
.ok()?;
let json: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
if let Some(arr) = json["markets"].as_array() {
if let Some(first) = arr.first() {
return first["probability_yes"]
.as_f64()
.or_else(|| first["yes_price"].as_f64().map(|v| v / 100.0));
}
}
json["probability_yes"]
.as_f64()
.or_else(|| json["yes_price"].as_f64().map(|v| v / 100.0))
}
pub async fn picks_load_with_refresh(
sidecar_path: &std::path::Path,
refresh: bool,
) -> Option<ReportPicks> {
let bytes = tokio::fs::read(sidecar_path).await.ok()?;
let mut picks: ReportPicks = serde_json::from_slice(&bytes).ok()?;
if refresh {
let tickers: Vec<String> = picks
.picks
.iter()
.filter(|p| p.kind == "ticker")
.map(|p| p.symbol.clone())
.collect();
let prices = if !tickers.is_empty() {
picks_fetch_snapshot_prices(&tickers).await.unwrap_or_default()
} else {
std::collections::HashMap::new()
};
for pick in &mut picks.picks {
if pick.kind == "ticker" {
let cur = prices.get(&pick.symbol).copied();
pick.price_now = cur;
if let (Some(entry), Some(cur)) = (pick.price_at_report, cur) {
pick.delta_pct = Some((cur - entry) / entry * 100.0);
}
} else if pick.kind == "odds" {
let cur = picks_fetch_odds_prob(&pick.symbol).await;
pick.prob_now = cur;
if let (Some(entry), Some(cur)) = (pick.prob_at_report, cur) {
pick.delta_pp = Some((cur - entry) * 100.0);
}
}
}
}
Some(picks)
}