Skip to main content

schwab_cli/trade_audio/
mod.rs

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