1use chrono::{DateTime, Utc};
4use serde_json::Value;
5
6use crate::agent::llm::{LlmReview, PositionReview};
7use crate::agent::state::AgentState;
8use crate::rules::TelegramNotifyConfig;
9
10pub fn llm_digest_key(review: &LlmReview) -> String {
12 let pos = review
13 .position_reviews
14 .first()
15 .map(|p| {
16 format!(
17 "{}:{}:{}",
18 short_position_label(&p.position_id),
19 p.recommendation.to_lowercase(),
20 p.urgency.to_lowercase()
21 )
22 })
23 .unwrap_or_else(|| "none".into());
24 format!(
25 "{}|{}|{}",
26 review.phase,
27 review.entry_recommendation.to_lowercase(),
28 pos
29 )
30}
31
32pub fn is_llm_urgent(review: &LlmReview) -> bool {
33 review.entry_recommendation.eq_ignore_ascii_case("proceed")
34 || !review.urgent_close_positions().is_empty()
35 || review
36 .position_reviews
37 .iter()
38 .any(|p| p.urgency.eq_ignore_ascii_case("high"))
39}
40
41pub fn should_send_llm_telegram(
43 review: &LlmReview,
44 config: &TelegramNotifyConfig,
45 state: &AgentState,
46 now: DateTime<Utc>,
47) -> bool {
48 let key = llm_digest_key(review);
49
50 if is_llm_urgent(review) {
51 if !config.llm_notify_urgent {
52 return false;
53 }
54 if state.last_telegram_llm_digest_key.as_deref() == Some(key.as_str()) {
55 if let Some(at) = state.last_telegram_llm_at {
56 if (now - at).num_minutes() < config.llm_urgent_cooldown_minutes as i64 {
57 return false;
58 }
59 }
60 }
61 return true;
62 }
63
64 if !config.llm_notify_digest || config.llm_digest_interval_minutes == 0 {
65 return false;
66 }
67 if state.last_telegram_llm_digest_key.as_deref() == Some(key.as_str()) {
68 return false;
69 }
70 if let Some(at) = state.last_telegram_llm_at {
71 if (now - at).num_minutes() < config.llm_digest_interval_minutes as i64 {
72 return false;
73 }
74 }
75 true
76}
77
78pub fn record_llm_telegram_sent(state: &mut AgentState, review: &LlmReview, now: DateTime<Utc>) {
79 state.last_telegram_llm_at = Some(now);
80 state.last_telegram_llm_digest_key = Some(llm_digest_key(review));
81}
82
83pub fn format_llm_review_telegram(review: &LlmReview, monitored: &[Value]) -> String {
85 let mut out = String::new();
86
87 let headline = match review.phase.as_str() {
88 "overnight_digest" => "Overnight check-in",
89 "selection" => "Options update",
90 _ => "Position check-in",
91 };
92 out.push_str(headline);
93 out.push_str("\n\n");
94
95 if let Some(snapshot) = monitored.first() {
96 out.push_str(&format_position_snapshot(snapshot));
97 out.push('\n');
98 }
99
100 for pos in &review.position_reviews {
101 out.push_str(&format_position_advice(pos));
102 out.push('\n');
103 }
104
105 out.push_str(&format_entry_advice(review));
106 out.push_str("\n\n");
107 out.push_str(&format_what_to_do(review));
108 out
109}
110
111pub fn format_overnight_telegram(review: &LlmReview) -> String {
112 let mut msg = format_llm_review_telegram(review, &[]);
113 if !review.risk_alerts.is_empty() {
114 msg.push_str("\n\nWatch overnight:");
115 for alert in &review.risk_alerts {
116 msg.push_str("\n• ");
117 msg.push_str(&plain_sentence(alert, 220));
118 }
119 }
120 msg
121}
122
123pub fn format_market_open_telegram(playbook: Option<&Value>, open_position_count: usize) -> String {
124 let Some(pb) = playbook else {
125 return if open_position_count > 0 {
126 format!(
127 "Market is open. Mechanical exits active on {open_position_count} open position(s)."
128 )
129 } else {
130 "Market is open. Scanning for entries.".into()
131 };
132 };
133 let mut out = "Market is open.\n\n".to_string();
134 if let Some(positions) = pb.get("positions").and_then(|v| v.as_array()) {
135 for pos in positions {
136 let id = pos
137 .get("position_id")
138 .and_then(|v| v.as_str())
139 .unwrap_or("position");
140 let rec = pos
141 .get("recommendation")
142 .and_then(|v| v.as_str())
143 .unwrap_or("watch");
144 out.push_str(&format!(
145 "Overnight note for {}: {}.\n",
146 short_position_label(id),
147 plain_rec_label(rec)
148 ));
149 }
150 }
151 if let Some(alerts) = pb.get("risk_alerts").and_then(|v| v.as_array()) {
152 if !alerts.is_empty() {
153 out.push_str("\nHeads up at the open:\n");
154 for alert in alerts.iter().take(3) {
155 if let Some(s) = alert.as_str() {
156 out.push_str("• ");
157 out.push_str(&plain_sentence(s, 200));
158 out.push('\n');
159 }
160 }
161 }
162 }
163 out.trim_end().to_string()
164}
165
166pub fn format_action_telegram(kind: &str, detail: &Value) -> Option<String> {
168 if kind.contains("ROLL") || detail.get("type").and_then(|v| v.as_str()) == Some("defensive_roll")
169 {
170 let underlying = detail
171 .get("underlying")
172 .and_then(|v| v.as_str())
173 .unwrap_or("?");
174 let closed = detail
175 .get("closed_id")
176 .and_then(|v| v.as_str())
177 .unwrap_or("?");
178 let new_id = detail.get("new_id").and_then(|v| v.as_str()).unwrap_or("?");
179 let net = detail.get("net").and_then(|v| v.as_f64());
180 let mut msg = format!(
181 "Defensive roll: {underlying}\nClosed → reopened farther OTM\n{closed} → {new_id}"
182 );
183 if let Some(n) = net {
184 msg.push_str(&format!("\nNet credit/debit: ${n:.2}"));
185 }
186 return Some(msg);
187 }
188 if let Some(fill) = detail.get("fill_status").and_then(|v| v.as_str()) {
189 return format_order_telegram(kind, fill, detail);
190 }
191 if detail.get("exit").is_some() || kind.contains("EXIT") {
192 let underlying = detail
193 .pointer("/signal/underlying")
194 .and_then(|v| v.as_str())
195 .unwrap_or("position");
196 let reason = detail
197 .pointer("/signal/reason")
198 .and_then(|v| v.as_str())
199 .unwrap_or("rule");
200 let reason_plain = match reason {
201 "profit_target" => "profit target hit",
202 "stop_loss" => "stop loss hit",
203 "dte_close" => "approaching expiration",
204 "defensive_roll" | "rolled" => "defensive roll",
205 r if r.starts_with("thesis_") => "thesis deterioration (mechanical exit)",
206 "llm_recommendation" => "advisor recommendation",
207 other => other,
208 };
209 return Some(format!("Position closed: {underlying}\nReason: {reason_plain}"));
210 }
211 None
212}
213
214fn format_order_telegram(kind: &str, fill_status: &str, detail: &Value) -> Option<String> {
215 if fill_status.eq_ignore_ascii_case("SKIPPED") {
216 return None;
217 }
218
219 let signal = detail.get("signal").unwrap_or(detail);
220 let underlying = signal
221 .pointer("/params/underlying")
222 .or_else(|| signal.get("underlying"))
223 .and_then(|v| v.as_str())
224 .unwrap_or("?");
225 let expiry = signal
226 .pointer("/params/expiry")
227 .and_then(|v| v.as_str())
228 .unwrap_or("");
229 let short = signal.pointer("/params/short_strike").and_then(|v| v.as_f64());
230 let long = signal.pointer("/params/long_strike").and_then(|v| v.as_f64());
231 let credit = signal
232 .get("estimated_credit")
233 .or_else(|| signal.pointer("/params/limit_credit"))
234 .and_then(|v| v.as_f64());
235 let contracts = signal
236 .pointer("/params/contracts")
237 .and_then(|v| v.as_f64())
238 .unwrap_or(1.0)
239 .round() as u32;
240
241 let strikes = match (short, long) {
242 (Some(s), Some(l)) => format!("${s:.0}/${l:.0}"),
243 _ => "spread".into(),
244 };
245
246 let status_line = match fill_status {
247 "FILLED" => "Trade filled",
248 "WORKING" | "ACCEPTED" | "PENDING_ACTIVATION" | "QUEUED" => "Limit order working",
249 "REJECTED" | "CANCELED" | "EXPIRED" => "Order not filled",
250 _ if kind.contains("REJECTED") => "Order not filled",
251 _ => "Order update",
252 };
253
254 let mut msg = format!(
255 "{status_line}\n{underlying} put spread {strikes}, exp {expiry}"
256 );
257 if let Some(c) = credit {
258 msg.push_str(&format!("\nCredit ~${c:.2}"));
259 }
260 if contracts > 1 {
261 msg.push_str(&format!("\n{contracts} contracts"));
262 }
263 if fill_status == "REJECTED" || fill_status == "CANCELED" {
264 if let Some(note) = detail.get("note").and_then(|v| v.as_str()) {
265 msg.push_str(&format!("\n{note}"));
266 }
267 }
268 Some(msg)
269}
270
271fn format_position_snapshot(pos: &Value) -> String {
272 let underlying = pos
273 .get("underlying")
274 .and_then(|v| v.as_str())
275 .unwrap_or("?");
276 let expiry = pos.get("expiry").and_then(|v| v.as_str()).unwrap_or("?");
277 let short = pos
278 .pointer("/market_context/short_strike")
279 .and_then(|v| v.as_f64());
280 let long = pos
281 .pointer("/market_context/long_strike")
282 .and_then(|v| v.as_f64());
283 let px = pos
284 .pointer("/market_context/underlying_price")
285 .and_then(|v| v.as_f64());
286 let profit = pos.get("profit_pct").and_then(|v| v.as_f64());
287 let dte = pos
288 .get("dte")
289 .and_then(|v| v.as_i64().or_else(|| v.as_u64().map(|u| u as i64)));
290
291 let strikes = match (short, long) {
292 (Some(s), Some(l)) => format!(" (${s:.0}/${l:.0})"),
293 _ => String::new(),
294 };
295 let mut line = format!("{underlying} spread{strikes}, exp {expiry}");
296 if let Some(p) = px {
297 line.push_str(&format!(" — {underlying} ${p:.2}"));
298 }
299 if let Some(pnl) = profit {
300 line.push_str(&format!(" — {pnl:+.0}% on paper"));
301 }
302 if let Some(d) = dte {
303 line.push_str(&format!(" — {d} days left"));
304 }
305 line
306}
307
308fn format_position_advice(pos: &PositionReview) -> String {
309 let label = short_position_label(&pos.position_id);
310 let action = plain_rec_label(&pos.recommendation);
311 let why = plain_sentence(&pos.reasoning, 240);
312 format!("{label}: {action}\n{why}")
313}
314
315fn format_entry_advice(review: &LlmReview) -> String {
316 let rec = review.entry_recommendation.to_lowercase();
317 let headline = match rec.as_str() {
318 "proceed" => "New trade: Yes — ready to open if rules allow",
319 "defer" => "New trade: Not now",
320 "skip" => "New trade: No",
321 _ => "New trade: Waiting",
322 };
323 let why = if review.entry_reasoning.trim().is_empty() {
324 "No candidate met our entry rules this round.".into()
325 } else {
326 plain_sentence(&review.entry_reasoning, 280)
327 };
328 format!("{headline}\n{why}")
329}
330
331fn format_what_to_do(review: &LlmReview) -> String {
332 if review.entry_recommendation.eq_ignore_ascii_case("proceed") {
333 return "What to do: Review is favorable — the agent may place a limit order if risk limits allow.".into();
334 }
335 if !review.urgent_close_positions().is_empty() {
336 return "What to do: Urgent — review the open position; a close may be warranted.".into();
337 }
338 if review
339 .position_reviews
340 .iter()
341 .any(|p| p.urgency.eq_ignore_ascii_case("high"))
342 {
343 return "What to do: Watch closely today — elevated risk on an open spread.".into();
344 }
345 if review
346 .position_reviews
347 .iter()
348 .any(|p| p.recommendation.eq_ignore_ascii_case("watch"))
349 {
350 return "What to do: No action required — keep an eye on the position; auto-exit rules are still in control.".into();
351 }
352 "What to do: Nothing right now — mechanical profit, stop, and expiration rules have not triggered.".into()
353}
354
355fn short_position_label(position_id: &str) -> String {
356 let parts: Vec<&str> = position_id.split('|').collect();
357 if parts.len() >= 3 {
358 return format!("{} {}", parts[1], parts[2]);
359 }
360 position_id.to_string()
361}
362
363fn plain_rec_label(rec: &str) -> &'static str {
364 match rec.to_lowercase().as_str() {
365 "hold" => "Hold",
366 "watch" => "Watch",
367 "close" => "Consider closing",
368 "enter" => "Candidate entry",
369 _ => "Review",
370 }
371}
372
373fn plain_sentence(text: &str, max_chars: usize) -> String {
374 let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
375 if collapsed.chars().count() <= max_chars {
376 return collapsed;
377 }
378 let mut out = String::new();
379 for word in collapsed.split_whitespace() {
380 if !out.is_empty() {
381 if out.chars().count() + 1 + word.chars().count() > max_chars.saturating_sub(1) {
382 out.push('…');
383 return out;
384 }
385 out.push(' ');
386 }
387 out.push_str(word);
388 }
389 out
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::agent::llm::LlmReview;
396
397 fn sample_review() -> LlmReview {
398 LlmReview {
399 phase: "selection".into(),
400 model: "test".into(),
401 used_web: false,
402 raw: serde_json::json!({}),
403 market_commentary: "long technical essay".into(),
404 web_insights: vec![],
405 position_reviews: vec![PositionReview {
406 position_id: "ACC|IWM|2026-07-31|vertical|P280L_P282S".into(),
407 recommendation: "hold".into(),
408 urgency: "low".into(),
409 reasoning: "Small paper loss; stop not hit.".into(),
410 }],
411 entry_recommendation: "defer".into(),
412 entry_reasoning: "Already have IWM exposure; wait for a better setup.".into(),
413 risk_alerts: vec!["concentration".into()],
414 }
415 }
416
417 #[test]
418 fn telegram_llm_is_plain_language() {
419 let msg = format_llm_review_telegram(&sample_review(), &[]);
420 assert!(msg.contains("Options update"));
421 assert!(msg.contains("New trade: Not now"));
422 assert!(msg.contains("What to do:"));
423 assert!(!msg.contains("market_commentary"));
424 assert!(!msg.contains("risk_alerts"));
425 }
426
427 #[test]
428 fn skipped_orders_not_telegrammed() {
429 let detail = serde_json::json!({
430 "fill_status": "SKIPPED",
431 "reason": "max_portfolio_risk_usd exceeded",
432 "signal": { "params": { "underlying": "IWM" } }
433 });
434 assert!(format_action_telegram("ORDER", &detail).is_none());
435 }
436
437 #[test]
438 fn filled_order_is_plain() {
439 let detail = serde_json::json!({
440 "fill_status": "FILLED",
441 "signal": {
442 "params": {
443 "underlying": "IWM",
444 "expiry": "2026-08-07",
445 "short_strike": 281.0,
446 "long_strike": 279.0,
447 "limit_credit": 0.27,
448 "contracts": 1.0
449 },
450 "estimated_credit": 0.27
451 }
452 });
453 let msg = format_action_telegram("ENTRY FILLED", &detail).unwrap();
454 assert!(msg.contains("Trade filled"));
455 assert!(msg.contains("IWM"));
456 assert!(!msg.contains("{"));
457 }
458
459 #[test]
460 fn defer_with_alerts_not_urgent() {
461 let review = sample_review();
462 assert!(!is_llm_urgent(&review));
463 }
464
465 #[test]
466 fn proceed_is_urgent() {
467 let mut review = sample_review();
468 review.entry_recommendation = "proceed".into();
469 assert!(is_llm_urgent(&review));
470 }
471
472 #[test]
473 fn digest_dedupes_same_message() {
474 let config = TelegramNotifyConfig::default();
475 let mut state = AgentState::default();
476 let review = sample_review();
477 let now = Utc::now();
478 assert!(should_send_llm_telegram(&review, &config, &state, now));
479 record_llm_telegram_sent(&mut state, &review, now);
480 assert!(!should_send_llm_telegram(&review, &config, &state, now));
481 }
482}