Skip to main content

schwab_cli/trade_audio/
mod.rs

1//! Spoken trade-event cues — embedded MP3s played via macOS `afplay`.
2
3use std::path::{Path, PathBuf};
4#[cfg(target_os = "macos")]
5use std::process::{Command, Stdio};
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::OnceLock;
8
9use serde_json::Value;
10
11static DISABLED: AtomicBool = AtomicBool::new(false);
12static CACHE_DIR: OnceLock<PathBuf> = OnceLock::new();
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum TradeAudioEvent {
16    EntryOpened,
17    EntryWorking,
18    EntryDeferred,
19    EntryRejected,
20    EntryCancelled,
21    ExitProfit,
22    ExitStop,
23    ExitTime,
24    ExitEod,
25    ExitOvernight,
26    ExitDte,
27    ExitAdvisor,
28    ExitBracket,
29    AlertHalted,
30    AlertRisk,
31}
32
33const ALL_EVENTS: [TradeAudioEvent; 15] = [
34    TradeAudioEvent::EntryOpened,
35    TradeAudioEvent::EntryWorking,
36    TradeAudioEvent::EntryDeferred,
37    TradeAudioEvent::EntryRejected,
38    TradeAudioEvent::EntryCancelled,
39    TradeAudioEvent::ExitProfit,
40    TradeAudioEvent::ExitStop,
41    TradeAudioEvent::ExitTime,
42    TradeAudioEvent::ExitEod,
43    TradeAudioEvent::ExitOvernight,
44    TradeAudioEvent::ExitDte,
45    TradeAudioEvent::ExitAdvisor,
46    TradeAudioEvent::ExitBracket,
47    TradeAudioEvent::AlertHalted,
48    TradeAudioEvent::AlertRisk,
49];
50
51impl TradeAudioEvent {
52    fn asset(self) -> (&'static str, &'static [u8]) {
53        // Packaged under crates/schwab-cli/assets so cargo publish includes them.
54        match self {
55            Self::EntryOpened => ("entry_opened", include_bytes!("../../assets/trade-audio/entry_opened.mp3")),
56            Self::EntryWorking => ("entry_working", include_bytes!("../../assets/trade-audio/entry_working.mp3")),
57            Self::EntryDeferred => ("entry_deferred", include_bytes!("../../assets/trade-audio/entry_deferred.mp3")),
58            Self::EntryRejected => ("entry_rejected", include_bytes!("../../assets/trade-audio/entry_rejected.mp3")),
59            Self::EntryCancelled => ("entry_cancelled", include_bytes!("../../assets/trade-audio/entry_cancelled.mp3")),
60            Self::ExitProfit => ("exit_profit", include_bytes!("../../assets/trade-audio/exit_profit.mp3")),
61            Self::ExitStop => ("exit_stop", include_bytes!("../../assets/trade-audio/exit_stop.mp3")),
62            Self::ExitTime => ("exit_time", include_bytes!("../../assets/trade-audio/exit_time.mp3")),
63            Self::ExitEod => ("exit_eod", include_bytes!("../../assets/trade-audio/exit_eod.mp3")),
64            Self::ExitOvernight => ("exit_overnight", include_bytes!("../../assets/trade-audio/exit_overnight.mp3")),
65            Self::ExitDte => ("exit_dte", include_bytes!("../../assets/trade-audio/exit_dte.mp3")),
66            Self::ExitAdvisor => ("exit_advisor", include_bytes!("../../assets/trade-audio/exit_advisor.mp3")),
67            Self::ExitBracket => ("exit_bracket", include_bytes!("../../assets/trade-audio/exit_bracket.mp3")),
68            Self::AlertHalted => ("alert_halted", include_bytes!("../../assets/trade-audio/alert_halted.mp3")),
69            Self::AlertRisk => ("alert_risk", include_bytes!("../../assets/trade-audio/alert_risk.mp3")),
70        }
71    }
72}
73
74/// Configure audio (call once when an agent/watch session starts).
75pub fn init(no_audio: bool) {
76    DISABLED.store(no_audio, Ordering::Relaxed);
77    if no_audio {
78        return;
79    }
80    let _ = cache_dir();
81    std::thread::spawn(|| {
82        for event in ALL_EVENTS {
83            let (name, bytes) = event.asset();
84            let _ = ensure_cached(name, bytes);
85        }
86    });
87}
88
89pub fn speak(event: TradeAudioEvent) {
90    if DISABLED.load(Ordering::Relaxed) {
91        return;
92    }
93    let (name, bytes) = event.asset();
94    let path = ensure_cached(name, bytes);
95    play_path(&path);
96}
97
98pub fn speak_exit_reason(reason: &str) {
99    let event = match reason {
100        "profit_target" => TradeAudioEvent::ExitProfit,
101        "stop_loss" => TradeAudioEvent::ExitStop,
102        "time_stop" => TradeAudioEvent::ExitTime,
103        "eod_flatten" => TradeAudioEvent::ExitEod,
104        "overnight_flatten" => TradeAudioEvent::ExitOvernight,
105        "dte_close" => TradeAudioEvent::ExitDte,
106        "llm_recommendation" => TradeAudioEvent::ExitAdvisor,
107        "oco_filled" => TradeAudioEvent::ExitBracket,
108        _ => return,
109    };
110    speak(event);
111}
112
113/// Map options-agent Telegram action payloads to spoken cues.
114pub fn speak_from_action(kind: &str, detail: &Value) {
115    if let Some(fill) = detail.get("fill_status").and_then(|v| v.as_str()) {
116        speak_from_order_status(kind, fill, detail);
117        return;
118    }
119    if detail.get("exit").is_some() || kind.contains("EXIT") {
120        let reason = detail
121            .pointer("/signal/reason")
122            .and_then(|v| v.as_str())
123            .unwrap_or("");
124        speak_exit_reason(reason);
125    }
126}
127
128fn speak_from_order_status(kind: &str, fill_status: &str, detail: &Value) {
129    if fill_status.eq_ignore_ascii_case("SKIPPED") {
130        return;
131    }
132    let upper = fill_status.to_ascii_uppercase();
133    match upper.as_str() {
134        "FILLED" if kind.contains("ENTRY") => speak(TradeAudioEvent::EntryOpened),
135        "FILLED" if kind.contains("EXIT") => {
136            let reason = detail
137                .pointer("/signal/reason")
138                .and_then(|v| v.as_str())
139                .unwrap_or("");
140            speak_exit_reason(reason);
141        }
142        "WORKING" | "ACCEPTED" | "PENDING_ACTIVATION" | "QUEUED" => {
143            speak(TradeAudioEvent::EntryWorking);
144        }
145        "REJECTED" | "CANCELED" | "CANCELLED" | "EXPIRED" => speak(TradeAudioEvent::EntryRejected),
146        _ if kind.contains("REJECTED") => speak(TradeAudioEvent::EntryRejected),
147        _ => {}
148    }
149}
150
151fn cache_dir() -> PathBuf {
152    CACHE_DIR
153        .get_or_init(|| {
154            let dir = std::env::temp_dir().join("schwab-trade-audio");
155            let _ = std::fs::create_dir_all(&dir);
156            dir
157        })
158        .clone()
159}
160
161fn ensure_cached(name: &str, bytes: &[u8]) -> PathBuf {
162    let path = cache_dir().join(format!("{name}.mp3"));
163    let needs_write = match std::fs::metadata(&path) {
164        Ok(meta) => meta.len() != bytes.len() as u64,
165        Err(_) => true,
166    };
167    if needs_write {
168        let _ = std::fs::write(&path, bytes);
169    }
170    path
171}
172
173fn play_path(path: &Path) {
174    #[cfg(target_os = "macos")]
175    {
176        let _ = Command::new("afplay")
177            .arg(path)
178            .stdin(Stdio::null())
179            .stdout(Stdio::null())
180            .stderr(Stdio::null())
181            .spawn();
182    }
183    #[cfg(not(target_os = "macos"))]
184    {
185        let _ = path;
186        tracing::debug!(target: "trade_audio", "trade audio playback is macOS-only (afplay)");
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn exit_reason_maps_to_events() {
196        assert_eq!(
197            exit_reason_event("profit_target"),
198            Some(TradeAudioEvent::ExitProfit)
199        );
200        assert_eq!(exit_reason_event("dte_close"), Some(TradeAudioEvent::ExitDte));
201        assert_eq!(exit_reason_event("unknown"), None);
202    }
203
204    fn exit_reason_event(reason: &str) -> Option<TradeAudioEvent> {
205        match reason {
206            "profit_target" => Some(TradeAudioEvent::ExitProfit),
207            "stop_loss" => Some(TradeAudioEvent::ExitStop),
208            "time_stop" => Some(TradeAudioEvent::ExitTime),
209            "eod_flatten" => Some(TradeAudioEvent::ExitEod),
210            "overnight_flatten" => Some(TradeAudioEvent::ExitOvernight),
211            "dte_close" => Some(TradeAudioEvent::ExitDte),
212            "llm_recommendation" => Some(TradeAudioEvent::ExitAdvisor),
213            "oco_filled" => Some(TradeAudioEvent::ExitBracket),
214            _ => None,
215        }
216    }
217}