1use 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 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
74pub 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
113pub 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}