1use 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 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 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
105pub 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
144pub 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}