Skip to main content

schwab_cli/agent/
telegram_format.rs

1//! Plain-language Telegram messages (no raw JSON).
2
3use chrono::{DateTime, Utc};
4use serde_json::Value;
5
6use crate::agent::llm::{LlmReview, PositionReview};
7use crate::agent::state::AgentState;
8use crate::rules::TelegramNotifyConfig;
9
10/// Fingerprint for deduplicating routine LLM Telegram updates.
11pub 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
41/// Whether to send an LLM review to Telegram (urgent immediately; routine on digest cadence).
42pub 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
83/// Broker-style status update from an LLM review.
84pub 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
166/// Returns `None` when the action should not be pushed to Telegram (internal/skip).
167pub 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}