1use std::sync::Arc;
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use chrono::{Local, NaiveDate, Utc};
6use schwab_api::TraderApi;
7use schwab_market_data::endpoints::chains::ChainQuery;
8use schwab_market_data::MarketDataApi;
9use serde_json::{json, Value};
10
11use crate::auth_reminder::{
12 assess_refresh_token, maybe_notify_auth_reminder, notify_auth_required,
13};
14use crate::config::RuntimeConfig;
15use crate::notify::TelegramNotifier;
16use crate::options::positions::build_close_order_for_group_with_limit;
17use crate::options::{
18 build_order_for_strategy, candidate_position_id, days_to_expiry, ensure_option_buying_power,
19 group_option_legs, list_option_positions, parse_expiry, IronCondorParams, StrategyKind,
20 VerticalParams,
21};
22use crate::order_status::{
23 is_failure_status, is_terminal_status, order_status, wait_for_order, wait_result_json,
24 WaitCondition, WaitOptions,
25};
26use crate::rules::{LlmPhase, RulesConfig, VerticalEntryRules};
27use crate::safety::{execute_trading_order, require_trading_approval};
28use crate::trade_audio::{self, TradeAudioEvent};
29
30use super::exits::{
31 candidate_fails_thesis_gates, evaluate_position_monitor, exit_signal_json_for_account,
32 find_tracked_position, option_group_from_tracked, reconcile_open_positions, stable_position_key,
33};
34use super::llm::OpenRouterClient;
35use super::market_context::{market_context_summary_for_llm, vertical_entry_market_context};
36use super::spread_analytics::{analytics_from_json, entry_analytics_pass};
37use super::paths::{active_state_path, load_agent_state, load_sim_agent_state};
38use super::sim::{ensure_ledger, record_sim_entry, record_sim_exit};
39use super::schedule::{self, AgentSession};
40use super::state::{
41 is_thesis_exit_reason, save_state, update_peak_profit_pct, AgentState, PendingOrder,
42 PendingOrderAction, RedeploySignal, TrackedPosition,
43};
44use super::telegram_format::{
45 format_action_telegram, format_llm_review_telegram, format_market_open_telegram,
46 format_overnight_telegram, record_llm_telegram_sent, should_send_llm_telegram,
47};
48use crate::ui::agent_health::{is_fatal_auth_error, SharedAgentHealth};
49
50const TICK_ERROR_BACKOFF_SECS: u64 = 60;
51const MAX_ENTRY_QUOTE_WIDTH_RATIO: f64 = 1.0;
52const EXIT_LIMIT_SLIPPAGE: f64 = 0.05;
53
54#[derive(Debug, Clone, serde::Serialize)]
55pub struct TickResult {
56 pub session: String,
57 pub at_open: bool,
58 pub next_sleep_seconds: u64,
59 pub signals: Vec<Value>,
60 pub actions: Vec<Value>,
61 pub skipped: Vec<String>,
62 pub monitored_positions: Vec<Value>,
63 pub llm_review: Option<Value>,
64}
65
66pub async fn run_agent_loop(
67 runtime: &RuntimeConfig,
68 rules_path: &std::path::Path,
69 once: bool,
70 watch_health: Option<SharedAgentHealth>,
71) -> Result<()> {
72 let rules = RulesConfig::load(rules_path)?;
73 let state_path = active_state_path(rules_path, runtime.simulate);
74 let mut state = if runtime.simulate {
75 load_sim_agent_state(rules_path, &rules.agent_id)
76 } else {
77 load_agent_state(rules_path, &rules.agent_id)
78 };
79 state.agent_id = rules.agent_id.clone();
80
81 trade_audio::init(runtime.no_audio);
82
83 if rules.execution.require_preview && !runtime.safety.require_preview_before_place {
84 anyhow::bail!(
85 "rules require preview before order placement, but safety.json has require_preview_before_place=false"
86 );
87 }
88
89 if !runtime.dry_run && !runtime.simulate {
90 crate::safety::require_trading_approval(
91 runtime,
92 "agent run",
93 &format!("Run options agent `{}`", rules.agent_id),
94 )?;
95 }
96
97 let trader = runtime.build_api()?;
98 let market = runtime.build_market_api()?;
99 let telegram = TelegramNotifier::from_env(&rules.notify.telegram)
100 .ok()
101 .flatten();
102 let llm_client = if rules.llm.enabled {
103 match OpenRouterClient::from_env() {
104 Ok(client) => Some(client),
105 Err(e) => {
106 let msg = format!(
107 "LLM disabled for this run: {e:#} (set OPENROUTER_API_KEY or llm.enabled: false)"
108 );
109 let _ = super::paths::append_agent_log(rules_path, &msg);
110 if let Some(h) = watch_health.as_ref() {
111 if let Ok(mut g) = h.lock() {
112 g.record_error(&msg);
113 }
114 }
115 None
116 }
117 }
118 } else {
119 None
120 };
121
122 let mut consecutive_errors = 0u32;
123 let mut last_logged_error: Option<String> = None;
124
125 loop {
126 match tick_once(
127 runtime,
128 rules_path,
129 &rules,
130 &trader,
131 &market,
132 &mut state,
133 llm_client.as_ref(),
134 telegram.as_ref(),
135 )
136 .await
137 {
138 Ok(result) => {
139 consecutive_errors = 0;
140 state.last_tick = Some(Utc::now());
141 save_state(&state_path, &state)?;
142
143 if let Some(h) = watch_health.as_ref() {
144 if let Ok(mut g) = h.lock() {
145 g.record_tick();
146 }
147 }
148
149 let tick_payload = json!({
150 "agent_id": rules.agent_id,
151 "session": result.session,
152 "at_open": result.at_open,
153 "next_sleep_seconds": result.next_sleep_seconds,
154 "signals": result.signals,
155 "actions": result.actions,
156 "skipped": result.skipped,
157 "monitored_positions": result.monitored_positions,
158 "llm_review": result.llm_review,
159 "dry_run": runtime.dry_run,
160 "simulate": runtime.simulate,
161 });
162
163 if runtime.suppress_tick_output {
164 let summary = format!(
165 "{} session={} signals={} actions={} skipped={}",
166 Utc::now().format("%Y-%m-%d %H:%M:%S"),
167 result.session,
168 result.signals.len(),
169 result.actions.len(),
170 result.skipped.len()
171 );
172 let _ = super::paths::append_agent_log(rules_path, &summary);
173 } else {
174 runtime.emit(crate::output::ResponseEnvelope::ok(
175 if once { "agent run once" } else { "agent tick" },
176 tick_payload,
177 ));
178 }
179
180 notify_tick(telegram.as_ref(), &rules, &result, runtime.dry_run).await;
181
182 if once {
183 break;
184 }
185
186 tokio::time::sleep(std::time::Duration::from_secs(result.next_sleep_seconds)).await;
187 }
188 Err(e) => {
189 consecutive_errors += 1;
190 let err_str = format!("{e:#}");
191
192 if is_fatal_auth_error(&err_str) {
193 let msg = "agent stopped: Schwab login required (refresh token invalid). Run: schwab auth login";
194 if last_logged_error.as_deref() != Some(msg) {
195 let _ = super::paths::append_agent_log(rules_path, msg);
196 }
197 if let Some(h) = watch_health.as_ref() {
198 if let Ok(mut g) = h.lock() {
199 g.record_error(msg);
200 }
201 }
202 notify_auth_required(telegram.as_ref(), msg).await;
203 if once {
204 return Err(e);
205 }
206 break;
207 }
208
209 let msg = format!("tick error (#{consecutive_errors}): {err_str}");
210 if last_logged_error.as_deref() != Some(msg.as_str()) {
211 let _ = super::paths::append_agent_log(rules_path, &msg);
212 last_logged_error = Some(msg.clone());
213 }
214 if let Some(h) = watch_health.as_ref() {
215 if let Ok(mut g) = h.lock() {
216 g.record_error(&msg);
217 }
218 }
219 if once {
220 return Err(e);
221 }
222 let backoff = TICK_ERROR_BACKOFF_SECS
223 .saturating_mul(consecutive_errors.min(5) as u64)
224 .max(30);
225 tokio::time::sleep(Duration::from_secs(backoff)).await;
226 }
227 }
228 }
229
230 if let Some(h) = watch_health.as_ref() {
231 if let Ok(mut g) = h.lock() {
232 g.loop_running = false;
233 }
234 }
235
236 Ok(())
237}
238
239#[allow(clippy::too_many_arguments)]
240pub async fn tick_once(
241 runtime: &RuntimeConfig,
242 rules_path: &std::path::Path,
243 rules: &RulesConfig,
244 trader: &Arc<TraderApi>,
245 market: &Arc<MarketDataApi>,
246 state: &mut AgentState,
247 llm_client: Option<&OpenRouterClient>,
248 telegram: Option<&TelegramNotifier>,
249) -> Result<TickResult> {
250 let today = Local::now().date_naive();
251 state.reset_daily_if_needed(today);
252 state.tick_count += 1;
253
254 check_auth_reminder(state, telegram).await;
255 maybe_clear_stale_redeploy(state);
256
257 let mut result = TickResult {
258 session: "unknown".into(),
259 at_open: false,
260 next_sleep_seconds: rules.schedule.tick_interval_seconds.max(5),
261 signals: vec![],
262 actions: vec![],
263 skipped: vec![],
264 monitored_positions: vec![],
265 llm_review: None,
266 };
267
268 if runtime.simulate {
269 let _ = ensure_ledger(state, rules);
270 result
271 .skipped
272 .push("simulate — using paper state (no Schwab reconcile)".into());
273 } else {
274 reconcile_open_positions(trader, state, rules).await?;
275 poll_pending_orders(trader, state, rules, &mut result).await?;
276 }
277
278 let (market_open, hours) = fetch_option_market_status(market).await?;
279 state.last_market_open = Some(market_open);
280 if let Some(ref h) = hours {
281 let _ = crate::ui::market_status::save_market_hours_cache(rules_path, h);
282 }
283 let transition =
284 schedule::resolve_session(market_open, &rules.schedule, state.last_session.as_deref());
285 result.session = transition.session.as_str().to_string();
286 result.next_sleep_seconds = transition.sleep_seconds;
287 state.last_session = Some(result.session.clone());
288
289 match transition.session {
290 AgentSession::Idle => {
291 result.skipped.push("market closed (option hours)".into());
292 return Ok(result);
293 }
294 AgentSession::Overnight => {
295 return tick_overnight(runtime, rules, state, llm_client, telegram, &mut result).await;
296 }
297 AgentSession::RegularHours => {
298 if transition.just_opened {
299 result.at_open = true;
300 result
301 .skipped
302 .push("market open — full evaluation (mechanical exits + live marks)".into());
303 }
304 state.regular_tick_count += 1;
305 }
306 }
307
308 let prior_open_playbook = if result.at_open {
309 let pb = state.open_playbook.take();
310 notify_at_open(
311 telegram,
312 pb.as_ref(),
313 state.open_positions.len(),
314 )
315 .await;
316 pb
317 } else {
318 None
319 };
320
321 let entries_paused = state.trades_capacity_used() >= rules.risk.max_trades_per_day;
322 let blocked_events_active = !rules.risk.blocked_events.is_empty();
323
324 if runtime.simulate {
326 let position_ids: Vec<String> = state.open_positions.keys().cloned().collect();
327 for position_id in position_ids {
328 let Some(tracked) = state.open_positions.get(&position_id).cloned() else {
329 continue;
330 };
331 let Some(group) = option_group_from_tracked(&tracked) else {
332 result.skipped.push(format!(
333 "simulate exit skip {position_id}: missing entry_params"
334 ));
335 continue;
336 };
337 let monitor =
338 evaluate_position_monitor(market, &group, rules, today, Some(&tracked)).await?;
339 if let Some(profit) = monitor.mark.as_ref().map(|m| m.profit_pct) {
340 if let Some(p) = state.open_positions.get_mut(&position_id) {
341 update_peak_profit_pct(p, profit);
342 }
343 }
344 if !group.legs.is_empty() {
345 result.monitored_positions.push(monitor.snapshot);
346 }
347 if let Some(eval) = monitor.exit {
348 if is_thesis_exit_reason(&eval.reason) {
349 state.redeploy_signal = Some(RedeploySignal {
350 at: Utc::now(),
351 reason: eval.reason.clone(),
352 underlying: Some(tracked.underlying.clone()),
353 });
354 }
355 let exit = exit_signal_json_for_account(&tracked.account_hash, &group, &eval);
356 result.signals.push(exit.clone());
357 if !runtime.dry_run {
358 match record_sim_exit(
359 rules_path,
360 state,
361 rules,
362 &position_id,
363 &eval.reason,
364 &eval.mark,
365 &exit,
366 ) {
367 Ok(action) => {
368 result.actions.push(action);
369 notify_action(telegram, "SIM EXIT", &exit).await;
370 }
371 Err(err) => {
372 result
373 .skipped
374 .push(format!("sim exit failed {position_id}: {err:#}"));
375 }
376 }
377 }
378 }
379 }
380 } else {
381 for account in rules.enabled_accounts() {
382 let legs = list_option_positions(trader, Some(&account.hash)).await?;
383 let groups = group_option_legs(&legs);
384 for group in &groups {
385 let tracked = find_tracked_position(state, &account.hash, group);
386 let monitor = evaluate_position_monitor(market, group, rules, today, tracked).await?;
387 let position_id = stable_position_key(&account.hash, group);
388 if let Some(profit) = monitor.mark.as_ref().map(|m| m.profit_pct) {
389 if let Some(p) = state.open_positions.get_mut(&position_id) {
390 update_peak_profit_pct(p, profit);
391 }
392 }
393
394 if !group.legs.is_empty() {
395 result.monitored_positions.push(monitor.snapshot);
396 }
397
398 if let Some(eval) = monitor.exit {
399 if is_thesis_exit_reason(&eval.reason) {
400 state.redeploy_signal = Some(RedeploySignal {
401 at: Utc::now(),
402 reason: eval.reason.clone(),
403 underlying: Some(group.underlying.clone()),
404 });
405 }
406 let exit = exit_signal_json_for_account(&account.hash, group, &eval);
407 result.signals.push(exit.clone());
408 if state.has_pending_position(&position_id) {
409 result
410 .skipped
411 .push(format!("exit already pending for {position_id}"));
412 } else if !runtime.dry_run {
413 if let Ok(action) =
414 execute_exit(runtime, trader, &account.hash, rules, group, &exit, state)
415 .await
416 {
417 result.actions.push(action);
418 notify_action(telegram, "EXIT", &exit).await;
419 }
420 }
421 }
422 }
423 }
424 }
425
426 let mut pending_entries: Vec<(String, StrategyKind, Value)> = Vec::new();
428 if entries_paused {
429 result.skipped.push(format!(
430 "new entries paused — max_trades_per_day ({}) reached or reserved by pending entries",
431 rules.risk.max_trades_per_day
432 ));
433 } else if blocked_events_active {
434 result.skipped.push(format!(
435 "new entries paused — blocked_events active: {}",
436 rules.risk.blocked_events.join(", ")
437 ));
438 } else if !any_entry_slots_available(rules, state) {
439 result
440 .skipped
441 .push("entry scan skipped — all enabled accounts at max_open_positions".into());
442 } else {
443 let scan_watchlist = watchlist_for_scan(rules, state);
444 if let Some(sig) = &state.redeploy_signal {
445 if let Some(u) = &sig.underlying {
446 if redeploy_cooldown_active(rules, sig) {
447 let remaining = rules
448 .exit_rules
449 .thesis
450 .redeploy_cooldown_minutes
451 .unwrap_or(0) as i64
452 - Utc::now().signed_duration_since(sig.at).num_minutes();
453 result.skipped.push(format!(
454 "redeploy cooldown — skip {} for ~{}m after {}",
455 u.to_uppercase(),
456 remaining.max(0),
457 sig.reason
458 ));
459 }
460 }
461 }
462 for account in rules.enabled_accounts() {
463 for underlying in &scan_watchlist {
464 let sym = underlying.to_uppercase();
465 if !rules.risk.allowed_underlyings.is_empty()
466 && !rules
467 .risk
468 .allowed_underlyings
469 .iter()
470 .any(|u| u.eq_ignore_ascii_case(&sym))
471 {
472 continue;
473 }
474
475 if rules.strategies.vertical.enabled {
476 match evaluate_vertical_entry(market, rules, &sym, today, state, &account.hash)
477 .await
478 {
479 Ok(Some(signal)) => {
480 pending_entries.push((
481 account.hash.clone(),
482 StrategyKind::Vertical,
483 signal,
484 ));
485 }
486 Ok(None) => {}
487 Err(e) => result.skipped.push(format!("{sym} vertical: {e:#}")),
488 }
489 }
490
491 if rules.strategies.iron_condor.enabled {
492 match evaluate_condor_entry(market, rules, &sym, today, state, &account.hash)
493 .await
494 {
495 Ok(Some(signal)) => {
496 pending_entries.push((
497 account.hash.clone(),
498 StrategyKind::IronCondor,
499 signal,
500 ));
501 }
502 Ok(None) => {}
503 Err(e) => result.skipped.push(format!("{sym} iron_condor: {e:#}")),
504 }
505 }
506 }
507 }
508 }
509
510 for (_, _, signal) in &pending_entries {
511 result.signals.push(signal.clone());
512 }
513
514 let mut llm_veto_entries = false;
516 let mut llm_close_ids: Vec<String> = Vec::new();
517 let has_candidates = !pending_entries.is_empty();
518 let has_positions = !result.monitored_positions.is_empty();
519 let mechanical_alert =
520 mechanical_alert_from_monitored(&result.monitored_positions, rules.exit_rules.dte_close);
521
522 if let Some(client) = llm_client {
523 if let Some(phase) = resolve_llm_phase(
524 rules,
525 state,
526 has_candidates,
527 has_positions,
528 result.at_open,
529 mechanical_alert,
530 ) {
531 let use_web =
532 should_use_web_research(rules, state) && matches!(phase, LlmPhase::Selection);
533
534 let open_playbook_for_llm = match phase {
535 LlmPhase::Monitor if result.at_open => prior_open_playbook.as_ref(),
536 _ => None,
537 };
538
539 let context = json!({
540 "agent_id": rules.agent_id,
541 "tick": state.regular_tick_count,
542 "date": today.to_string(),
543 "phase": match phase {
544 LlmPhase::Selection => "selection",
545 LlmPhase::Monitor => "monitor",
546 LlmPhase::OvernightDigest => "overnight_digest",
547 },
548 "at_open": result.at_open,
549 "mechanical_alert": mechanical_alert,
550 "market": market_context_summary_for_llm(),
551 "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
552 "open_positions": result.monitored_positions,
553 "open_playbook": open_playbook_for_llm,
554 "candidate_entries": pending_entries.iter().map(|(_, _, s)| s).collect::<Vec<_>>(),
555 "recent_signals": result.signals,
556 "watchlist": rules.watchlist,
557 "risk": {
558 "max_trades_per_day": rules.risk.max_trades_per_day,
559 "trades_today": state.trades_today,
560 "max_risk_per_trade_usd": rules.risk.max_risk_per_trade_usd,
561 "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
562 },
563 });
564
565 match client.review(&rules.llm, phase, &context, use_web).await {
566 Ok(review) => {
567 let review_json = review.to_json();
568 result.llm_review = Some(review_json.clone());
569 state.last_llm_review_tick = Some(state.regular_tick_count);
570 state.llm_review_count += 1;
571 state.last_llm_summary = Some(review_json.clone());
572 state.record_action("llm_review", review_json.clone());
573
574 if rules.llm.veto_entries
575 && matches!(phase, LlmPhase::Selection)
576 && review.should_veto_entries()
577 {
578 llm_veto_entries = true;
579 result
580 .skipped
581 .push(format!("LLM veto entries: {}", review.entry_reasoning));
582 }
583
584 if rules.llm.allow_llm_exits {
585 for pos in review.urgent_close_positions() {
586 llm_close_ids.push(pos.position_id.clone());
587 }
588 }
589
590 let notify = should_send_llm_telegram(
591 &review,
592 &rules.notify.telegram,
593 state,
594 Utc::now(),
595 );
596 if notify {
597 notify_llm(
598 telegram,
599 &review,
600 &result.monitored_positions,
601 state,
602 )
603 .await;
604 }
605 }
606 Err(e) => {
607 result.skipped.push(format!("LLM review failed: {e:#}"));
608 if rules.llm.veto_entries && matches!(phase, LlmPhase::Selection) {
609 llm_veto_entries = true;
610 result
611 .skipped
612 .push("LLM selection failed closed — entries deferred".into());
613 }
614 }
615 }
616 }
617 } else if rules.llm.enabled && rules.llm.veto_entries && has_candidates {
618 llm_veto_entries = true;
619 result
620 .skipped
621 .push("LLM selection unavailable — entries deferred".into());
622 }
623
624 if !llm_close_ids.is_empty() && !runtime.dry_run && !runtime.simulate {
626 for account in rules.enabled_accounts() {
627 let legs = list_option_positions(trader, Some(&account.hash)).await?;
628 let groups = group_option_legs(&legs);
629 for group in &groups {
630 let position_id = stable_position_key(&account.hash, group);
631 if !llm_close_ids
632 .iter()
633 .any(|id| id == &position_id || id == &group.id)
634 {
635 continue;
636 }
637 if state.has_pending_position(&position_id) {
638 result
639 .skipped
640 .push(format!("LLM exit already pending for {position_id}"));
641 continue;
642 }
643 let exit = json!({
644 "type": "exit",
645 "reason": "llm_recommendation",
646 "position_id": position_id,
647 "legacy_position_id": group.id,
648 "underlying": group.underlying,
649 "expiry": group.expiry,
650 });
651 result.signals.push(exit.clone());
652 if let Ok(action) =
653 execute_exit(runtime, trader, &account.hash, rules, group, &exit, state).await
654 {
655 result.actions.push(action);
656 notify_action(telegram, "LLM EXIT", &exit).await;
657 }
658 }
659 }
660 }
661
662 if !llm_veto_entries && !runtime.dry_run {
664 if runtime.simulate {
665 for (account_hash, kind, signal) in pending_entries {
666 match record_sim_entry(rules_path, state, rules, &account_hash, kind, &signal) {
667 Ok(detail) => {
668 if detail
669 .get("fill_status")
670 .and_then(|v| v.as_str())
671 == Some("FILLED")
672 {
673 result.actions.push(detail.clone());
674 notify_action(telegram, "SIM ENTRY", &detail).await;
675 } else {
676 result.skipped.push(format!(
677 "sim entry skipped: {}",
678 detail
679 .get("reason")
680 .and_then(|v| v.as_str())
681 .unwrap_or("unknown")
682 ));
683 }
684 }
685 Err(err) => result.skipped.push(format!("sim entry failed: {err:#}")),
686 }
687 }
688 } else {
689 for (account_hash, kind, signal) in pending_entries {
690 if let Ok(Some(a)) =
691 maybe_execute_entry(runtime, trader, &account_hash, kind, &signal, rules, state)
692 .await
693 {
694 result.actions.push(a.clone());
695 let label = a
696 .pointer("/fill_status")
697 .and_then(|v| v.as_str())
698 .unwrap_or("UNKNOWN");
699 match label {
700 "FILLED" => notify_action(telegram, "ENTRY FILLED", &a).await,
701 "WORKING" | "ACCEPTED" | "PENDING_ACTIVATION" | "QUEUED" => {
702 notify_action(telegram, "ORDER WORKING (limit)", &a).await
703 }
704 other if is_failure_status(other) => {
705 notify_action(telegram, "ORDER REJECTED", &a).await
706 }
707 _ => notify_action(telegram, "ORDER", &a).await,
708 }
709 }
710 }
711 }
712 } else if llm_veto_entries && !pending_entries.is_empty() && !runtime.dry_run {
713 trade_audio::speak(TradeAudioEvent::EntryDeferred);
714 }
715
716 Ok(result)
717}
718
719fn should_run_llm_review(rules: &RulesConfig, state: &AgentState, has_positions: bool) -> bool {
720 let every = rules.llm.effective_llm_review_ticks(
721 has_positions,
722 min_open_position_dte(state),
723 rules.exit_rules.dte_close,
724 );
725 schedule::should_run_monitor_review(
726 state.regular_tick_count,
727 state.last_llm_review_tick,
728 every,
729 )
730}
731
732fn min_open_position_dte(state: &AgentState) -> Option<i64> {
733 let today = Local::now().date_naive();
734 state
735 .open_positions
736 .values()
737 .filter_map(|p| {
738 chrono::NaiveDate::parse_from_str(&p.expiry, "%Y-%m-%d")
739 .ok()
740 .map(|exp| days_to_expiry(exp, today))
741 })
742 .min()
743}
744
745async fn tick_overnight(
746 _runtime: &RuntimeConfig,
747 rules: &RulesConfig,
748 state: &mut AgentState,
749 llm_client: Option<&OpenRouterClient>,
750 telegram: Option<&TelegramNotifier>,
751 result: &mut TickResult,
752) -> Result<TickResult> {
753 let today = Local::now().date_naive();
754 result.monitored_positions = overnight_position_snapshots(state);
755
756 if result.monitored_positions.is_empty() {
757 result.skipped.push("overnight — no open positions".into());
758 } else {
759 result.skipped.push(format!(
760 "overnight — monitoring {} open position(s) (no live marks)",
761 result.monitored_positions.len()
762 ));
763 }
764
765 let digest_due =
766 schedule::should_run_overnight_digest(state, &rules.schedule.overnight, Utc::now());
767
768 if !digest_due {
769 result.skipped.push(format!(
770 "overnight digest next in ~{} min",
771 rules.schedule.overnight.tick_interval_seconds / 60
772 ));
773 return Ok(result.clone());
774 }
775
776 if !rules.llm.enabled {
777 result
778 .skipped
779 .push("overnight digest skipped (llm.enabled false)".into());
780 return Ok(result.clone());
781 }
782
783 let Some(client) = llm_client else {
784 result
785 .skipped
786 .push("overnight digest skipped (no LLM client)".into());
787 return Ok(result.clone());
788 };
789
790 let context = json!({
791 "agent_id": rules.agent_id,
792 "date": today.to_string(),
793 "phase": "overnight_digest",
794 "market_closed": true,
795 "open_positions": result.monitored_positions,
796 "prior_open_playbook": state.open_playbook,
797 "watchlist": rules.watchlist,
798 "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
799 "note": "Build open playbook for next session. No chain data. new_entries must be skip.",
800 });
801
802 match client
803 .review(&rules.llm, LlmPhase::OvernightDigest, &context, true)
804 .await
805 {
806 Ok(review) => {
807 let review_json = review.to_json();
808 result.llm_review = Some(review_json.clone());
809 state.last_overnight_digest_at = Some(Utc::now());
810 state.open_playbook = Some(json!({
811 "updated_at": Utc::now(),
812 "market_commentary": review.market_commentary,
813 "positions": review.position_reviews,
814 "risk_alerts": review.risk_alerts,
815 "open_actions": review.entry_reasoning,
816 }));
817 state.last_llm_summary = Some(review_json.clone());
818 state.record_action("overnight_digest", review_json);
819
820 let should_notify = if rules.schedule.overnight.alert_on_risk_only {
821 !review.risk_alerts.is_empty() || is_llm_urgent_for_overnight(&review)
822 } else {
823 should_send_llm_telegram(&review, &rules.notify.telegram, state, Utc::now())
824 };
825 if should_notify {
826 notify_overnight_alert(telegram, &review, state).await;
827 }
828 }
829 Err(e) => result
830 .skipped
831 .push(format!("overnight digest failed: {e:#}")),
832 }
833
834 Ok(result.clone())
835}
836
837fn overnight_position_snapshots(state: &AgentState) -> Vec<Value> {
838 state
839 .open_positions
840 .values()
841 .map(|p| {
842 json!({
843 "position_id": p.position_id,
844 "underlying": p.underlying,
845 "expiry": p.expiry,
846 "strategy": p.strategy,
847 "contracts": p.contracts.max(1),
848 "entry_credit": p.entry_credit,
849 "max_loss_usd": p.max_loss_usd,
850 "status": "overnight (reconciled, no live marks)",
851 })
852 })
853 .collect()
854}
855
856async fn check_auth_reminder(state: &mut AgentState, telegram: Option<&TelegramNotifier>) {
857 let Ok(config) = schwab_api::ClientConfig::from_env() else {
858 return;
859 };
860 let oauth = schwab_api::OAuthClient::new(config);
861 let Ok(Some(tokens)) = oauth.status().await else {
862 return;
863 };
864 let reminder = assess_refresh_token(&tokens);
865 maybe_notify_auth_reminder(telegram, state, &reminder).await;
866}
867
868async fn poll_pending_orders(
869 trader: &Arc<TraderApi>,
870 state: &mut AgentState,
871 rules: &RulesConfig,
872 result: &mut TickResult,
873) -> Result<()> {
874 let pending = state.pending_orders.clone();
875 for pending_order in pending {
876 let order = match trader
877 .orders()
878 .get(&pending_order.account_hash, &pending_order.order_id)
879 .await
880 {
881 Ok(order) => order,
882 Err(e) => {
883 result.skipped.push(format!(
884 "pending order {} status unavailable: {e:#}",
885 pending_order.order_id
886 ));
887 continue;
888 }
889 };
890 let status = order_status(&order).unwrap_or_else(|| "UNKNOWN".into());
891 if let Some(stored) = state
892 .pending_orders
893 .iter_mut()
894 .find(|p| p.order_id == pending_order.order_id)
895 {
896 stored.last_status = Some(status.clone());
897 }
898
899 match pending_order.action {
900 PendingOrderAction::Entry => {
901 if status == "FILLED" {
902 state.remove_pending_order(&pending_order.order_id);
903 if let Some(detail) = pending_order.detail.as_ref() {
904 track_filled_entry_from_pending(state, detail, &pending_order);
905 }
906 state.trades_today = state.trades_today.saturating_add(1);
907 state.record_action(
908 "entry_filled",
909 json!({
910 "order_id": pending_order.order_id,
911 "position_id": pending_order.position_id,
912 "status": status,
913 }),
914 );
915 trade_audio::speak(TradeAudioEvent::EntryOpened);
916 } else if is_failure_status(&status) || is_terminal_status(&status) {
917 state.remove_pending_order(&pending_order.order_id);
918 state.record_action(
919 "entry_terminal",
920 json!({
921 "order_id": pending_order.order_id,
922 "position_id": pending_order.position_id,
923 "status": status,
924 }),
925 );
926 } else if pending_is_stale(&pending_order, rules) {
927 match trader
928 .orders()
929 .cancel(&pending_order.account_hash, &pending_order.order_id)
930 .await
931 {
932 Ok(cancel) => {
933 state.remove_pending_order(&pending_order.order_id);
934 state.record_action(
935 "entry_cancelled_stale",
936 json!({
937 "order_id": pending_order.order_id,
938 "position_id": pending_order.position_id,
939 "status": status,
940 "cancel": {
941 "status": cancel.status,
942 "location": cancel.location,
943 },
944 }),
945 );
946 trade_audio::speak(TradeAudioEvent::EntryCancelled);
947 }
948 Err(e) => result.skipped.push(format!(
949 "stale entry order {} cancel failed: {e:#}",
950 pending_order.order_id
951 )),
952 }
953 }
954 }
955 PendingOrderAction::Exit => {
956 if status == "FILLED" {
957 state.remove_pending_order(&pending_order.order_id);
958 state.open_positions.remove(&pending_order.position_id);
959 state.record_action(
960 "exit_filled",
961 json!({
962 "order_id": pending_order.order_id,
963 "position_id": pending_order.position_id,
964 "status": status,
965 }),
966 );
967 let reason = pending_order
968 .detail
969 .as_ref()
970 .and_then(|d| d.pointer("/signal/reason"))
971 .and_then(|v| v.as_str())
972 .unwrap_or("");
973 trade_audio::speak_exit_reason(reason);
974 } else if is_failure_status(&status) || is_terminal_status(&status) {
975 state.remove_pending_order(&pending_order.order_id);
976 state.record_action(
977 "exit_terminal_position_kept",
978 json!({
979 "order_id": pending_order.order_id,
980 "position_id": pending_order.position_id,
981 "status": status,
982 }),
983 );
984 }
985 }
986 }
987 }
988 state.clear_legacy_pending_ids();
989 Ok(())
990}
991
992fn pending_is_stale(pending: &PendingOrder, rules: &RulesConfig) -> bool {
993 let timeout = rules.execution.fill_timeout_seconds.max(1) as i64;
994 (Utc::now() - pending.submitted_at).num_seconds() >= timeout
995}
996
997fn track_filled_entry_from_pending(state: &mut AgentState, detail: &Value, pending: &PendingOrder) {
998 let Some(signal) = detail.get("signal") else {
999 return;
1000 };
1001 let Some(params) = signal.get("params") else {
1002 return;
1003 };
1004 let underlying = params
1005 .get("underlying")
1006 .and_then(|v| v.as_str())
1007 .unwrap_or("")
1008 .to_string();
1009 let expiry = params
1010 .get("expiry")
1011 .and_then(|v| v.as_str())
1012 .unwrap_or("")
1013 .to_string();
1014 let strategy = signal
1015 .get("strategy")
1016 .and_then(|v| v.as_str())
1017 .unwrap_or("vertical")
1018 .to_string();
1019 let contracts = params
1020 .get("contracts")
1021 .and_then(|v| v.as_f64())
1022 .unwrap_or(1.0)
1023 .round()
1024 .max(1.0) as u32;
1025 let entry_credit = signal.get("estimated_credit").and_then(|v| v.as_f64());
1026
1027 state
1028 .open_positions
1029 .entry(pending.position_id.clone())
1030 .or_insert_with(|| TrackedPosition {
1031 position_id: pending.position_id.clone(),
1032 account_hash: pending.account_hash.clone(),
1033 underlying,
1034 expiry,
1035 strategy,
1036 opened_at: Utc::now(),
1037 entry_credit,
1038 max_loss_usd: pending.reserved_risk_usd,
1039 contracts,
1040 entry_params: None,
1041 peak_profit_pct: None,
1042 entry_pop_pct: signal
1043 .pointer("/market_context/spread_pop_pct")
1044 .and_then(|v| v.as_f64()),
1045 entry_short_delta: signal
1046 .pointer("/market_context/short_delta")
1047 .and_then(|v| v.as_f64())
1048 .map(f64::abs),
1049 });
1050}
1051
1052fn watchlist_for_scan(rules: &RulesConfig, state: &AgentState) -> Vec<String> {
1053 let mut wl = rules.watchlist.clone();
1054 if let Some(sig) = &state.redeploy_signal {
1055 if let Some(u) = &sig.underlying {
1056 let key = u.to_uppercase();
1057 if redeploy_cooldown_active(rules, sig) {
1058 wl.retain(|s| !s.eq_ignore_ascii_case(&key));
1059 } else {
1060 wl.retain(|s| !s.eq_ignore_ascii_case(&key));
1061 wl.insert(0, key);
1062 }
1063 }
1064 }
1065 wl
1066}
1067
1068fn redeploy_cooldown_active(rules: &RulesConfig, sig: &RedeploySignal) -> bool {
1069 let cooldown = rules
1070 .exit_rules
1071 .thesis
1072 .redeploy_cooldown_minutes
1073 .unwrap_or(0);
1074 if cooldown == 0 {
1075 return false;
1076 }
1077 Utc::now().signed_duration_since(sig.at).num_minutes() < cooldown as i64
1078}
1079
1080fn maybe_clear_stale_redeploy(state: &mut AgentState) {
1081 let Some(sig) = &state.redeploy_signal else {
1082 return;
1083 };
1084 if Utc::now().signed_duration_since(sig.at).num_hours() >= 4 {
1085 state.redeploy_signal = None;
1086 }
1087}
1088
1089fn clear_redeploy_after_entry(state: &mut AgentState) {
1090 state.redeploy_signal = None;
1091}
1092
1093async fn notify_at_open(
1094 telegram: Option<&TelegramNotifier>,
1095 playbook: Option<&Value>,
1096 open_position_count: usize,
1097) {
1098 let Some(tg) = telegram else { return };
1099 if !tg.wants_actions() {
1100 return;
1101 }
1102 let body = format_market_open_telegram(playbook, open_position_count);
1103 let _ = tg.send(&body).await;
1104}
1105
1106async fn notify_overnight_alert(
1107 telegram: Option<&TelegramNotifier>,
1108 review: &super::llm::LlmReview,
1109 state: &mut AgentState,
1110) {
1111 let Some(tg) = telegram else { return };
1112 if !tg.wants_actions() {
1113 return;
1114 }
1115 let now = Utc::now();
1116 let _ = tg
1117 .send(&format_overnight_telegram(review))
1118 .await;
1119 record_llm_telegram_sent(state, review, now);
1120}
1121
1122fn is_llm_urgent_for_overnight(review: &super::llm::LlmReview) -> bool {
1123 super::telegram_format::is_llm_urgent(review)
1124 || review
1125 .position_reviews
1126 .iter()
1127 .any(|p| p.urgency.eq_ignore_ascii_case("high"))
1128}
1129
1130async fn fetch_option_market_status(market: &MarketDataApi) -> Result<(bool, Option<Value>)> {
1131 let hours = market.markets().hours("option", None).await?;
1132 let open = crate::market_hours::option_market_open_from_hours(&hours, chrono::Utc::now())
1133 .unwrap_or(false);
1134 Ok((open, Some(hours)))
1135}
1136
1137fn resolve_llm_phase(
1138 rules: &RulesConfig,
1139 state: &AgentState,
1140 has_candidates: bool,
1141 has_positions: bool,
1142 at_open: bool,
1143 mechanical_alert: bool,
1144) -> Option<LlmPhase> {
1145 if !rules.llm.enabled {
1146 return None;
1147 }
1148 if !has_candidates && !has_positions {
1149 return None;
1150 }
1151 if at_open && !has_candidates && !mechanical_alert {
1152 return None;
1153 }
1154 if !should_run_llm_review(rules, state, has_positions) {
1155 return None;
1156 }
1157 if has_candidates {
1158 return Some(LlmPhase::Selection);
1159 }
1160 Some(LlmPhase::Monitor)
1161}
1162
1163fn mechanical_alert_from_monitored(monitored_positions: &[Value], dte_close: u32) -> bool {
1165 monitored_positions.iter().any(|pos| {
1166 if let Some(rules) = pos.get("mechanical_rules") {
1167 if rules
1168 .get("stop_triggered")
1169 .and_then(|v| v.as_bool())
1170 .unwrap_or(false)
1171 {
1172 return true;
1173 }
1174 if rules
1175 .get("profit_target_triggered")
1176 .and_then(|v| v.as_bool())
1177 .unwrap_or(false)
1178 {
1179 return true;
1180 }
1181 }
1182 pos.get("dte")
1183 .and_then(|v| v.as_i64())
1184 .is_some_and(|dte| dte <= dte_close as i64)
1185 })
1186}
1187
1188fn any_entry_slots_available(rules: &RulesConfig, state: &AgentState) -> bool {
1189 let pending = state.pending_entry_count();
1190 for account in rules.enabled_accounts() {
1191 if rules.strategies.vertical.enabled
1192 && state.count_open_for_strategy(&account.hash, StrategyKind::Vertical) + pending
1193 < rules.entry_rules.vertical.max_open_positions
1194 {
1195 return true;
1196 }
1197 if rules.strategies.iron_condor.enabled
1198 && state.count_open_for_strategy(&account.hash, StrategyKind::IronCondor) + pending
1199 < rules.entry_rules.iron_condor.max_open_positions
1200 {
1201 return true;
1202 }
1203 }
1204 false
1205}
1206
1207fn should_use_web_research(rules: &RulesConfig, state: &AgentState) -> bool {
1209 if rules.llm.web_research_every_reviews == 0 {
1210 return false;
1211 }
1212 let next_review = state.llm_review_count + 1;
1213 next_review % rules.llm.web_research_every_reviews.max(1) == 0
1214}
1215
1216async fn notify_tick(
1217 telegram: Option<&TelegramNotifier>,
1218 rules: &RulesConfig,
1219 result: &TickResult,
1220 dry_run: bool,
1221) {
1222 let Some(tg) = telegram else { return };
1223 if !tg.wants_tick_summary() {
1224 return;
1225 }
1226 let prefix = if dry_run { "[DRY RUN] " } else { "" };
1227 let msg = format!(
1228 "{prefix}Agent `{}` tick\nsignals: {}\nactions: {}\nskipped: {}",
1229 rules.agent_id,
1230 result.signals.len(),
1231 result.actions.len(),
1232 result.skipped.len()
1233 );
1234 let _ = tg.send(&msg).await;
1235}
1236
1237async fn notify_action(telegram: Option<&TelegramNotifier>, kind: &str, detail: &Value) {
1238 trade_audio::speak_from_action(kind, detail);
1239 let Some(tg) = telegram else { return };
1240 if !tg.wants_actions() {
1241 return;
1242 }
1243 let Some(msg) = format_action_telegram(kind, detail) else {
1244 return;
1245 };
1246 let _ = tg.send(&msg).await;
1247}
1248
1249async fn notify_llm(
1250 telegram: Option<&TelegramNotifier>,
1251 review: &super::llm::LlmReview,
1252 monitored: &[Value],
1253 state: &mut AgentState,
1254) {
1255 let Some(tg) = telegram else { return };
1256 if !tg.wants_actions() {
1257 return;
1258 }
1259 let now = Utc::now();
1260 let _ = tg
1261 .send(&format_llm_review_telegram(review, monitored))
1262 .await;
1263 record_llm_telegram_sent(state, review, now);
1264}
1265
1266async fn evaluate_vertical_entry(
1267 market: &MarketDataApi,
1268 rules: &RulesConfig,
1269 underlying: &str,
1270 today: NaiveDate,
1271 state: &AgentState,
1272 account_hash: &str,
1273) -> Result<Option<Value>> {
1274 let entry = &rules.entry_rules.vertical;
1275 let open_count = state.count_open_for_strategy(account_hash, StrategyKind::Vertical);
1276 if open_count + state.pending_entry_count() >= entry.max_open_positions {
1277 return Ok(None);
1278 }
1279
1280 let chain = market
1281 .chains()
1282 .get(&ChainQuery {
1283 symbol: underlying,
1284 contract_type: Some("PUT"),
1285 strike_count: Some(50),
1286 include_underlying_quote: Some(true),
1287 ..Default::default()
1288 })
1289 .await?;
1290
1291 let (expiry, put_map) =
1292 pick_expiry_map(&chain, "putExpDateMap", entry.dte_min, entry.dte_max, today)?;
1293 let underlying_price = chain
1294 .pointer("/underlying/last")
1295 .or_else(|| chain.pointer("/underlyingPrice"))
1296 .and_then(|v| v.as_f64())
1297 .unwrap_or(0.0);
1298
1299 if underlying_price <= 0.0 {
1300 return Ok(None);
1301 }
1302
1303 let short_strike =
1304 pick_strike_by_delta(&put_map, entry.short_delta_min, entry.short_delta_max, true)
1305 .or_else(|| pick_otm_strike(&put_map, underlying_price, 0.10, true).ok())
1306 .context("no suitable short strike")?;
1307 let long_strike = pick_wing_strike(&put_map, short_strike, entry.max_width, true)?;
1308 let credit = estimate_spread_credit(&put_map, short_strike, long_strike)?;
1309 if credit < entry.min_credit {
1310 return Ok(None);
1311 }
1312 let width = (short_strike - long_strike).abs();
1313 if !entry_quality_ok(&put_map, short_strike, long_strike, width, credit, entry) {
1314 return Ok(None);
1315 }
1316
1317 let market_context = vertical_entry_market_context(
1318 &chain,
1319 underlying,
1320 expiry,
1321 today,
1322 &put_map,
1323 short_strike,
1324 long_strike,
1325 width,
1326 credit,
1327 entry.max_contracts_per_trade as f64,
1328 );
1329
1330 let analytics = analytics_from_json(market_context.get("analytics").unwrap_or(&json!({})));
1331 if let Some(ref a) = analytics {
1332 if !entry_analytics_pass(entry, a) {
1333 return Ok(None);
1334 }
1335 if candidate_fails_thesis_gates(rules, a).is_some() {
1336 return Ok(None);
1337 }
1338 }
1339
1340 let candidate_id = candidate_position_id(
1341 account_hash,
1342 underlying,
1343 &expiry.to_string(),
1344 StrategyKind::Vertical.as_str(),
1345 vec![('P', short_strike, "S"), ('P', long_strike, "L")],
1346 );
1347 if state.open_positions.contains_key(&candidate_id)
1348 || state.has_pending_position(&candidate_id)
1349 || has_legacy_duplicate(state, account_hash, underlying, &expiry.to_string())
1350 {
1351 return Ok(None);
1352 }
1353
1354 let params = VerticalParams {
1355 underlying: underlying.to_string(),
1356 expiry: expiry.to_string(),
1357 spread_type: entry.r#type.clone(),
1358 short_strike,
1359 long_strike,
1360 contracts: entry.max_contracts_per_trade as f64,
1361 limit_credit: Some(credit),
1362 limit_debit: None,
1363 duration: None,
1364 session: None,
1365 };
1366
1367 Ok(Some(json!({
1368 "type": "entry",
1369 "strategy": "vertical",
1370 "account_hash": account_hash,
1371 "position_id": candidate_id,
1372 "params": params,
1373 "estimated_credit": credit,
1374 "market_context": market_context,
1375 })))
1376}
1377
1378async fn evaluate_condor_entry(
1379 market: &MarketDataApi,
1380 rules: &RulesConfig,
1381 underlying: &str,
1382 today: NaiveDate,
1383 state: &AgentState,
1384 account_hash: &str,
1385) -> Result<Option<Value>> {
1386 let entry = &rules.entry_rules.iron_condor;
1387 let open_count = state.count_open_for_strategy(account_hash, StrategyKind::IronCondor);
1388 if open_count + state.pending_entry_count() >= entry.max_open_positions {
1389 return Ok(None);
1390 }
1391
1392 let chain = market
1393 .chains()
1394 .get(&ChainQuery {
1395 symbol: underlying,
1396 contract_type: Some("ALL"),
1397 strike_count: Some(40),
1398 include_underlying_quote: Some(true),
1399 ..Default::default()
1400 })
1401 .await?;
1402
1403 let underlying_price = chain
1404 .pointer("/underlying/last")
1405 .or_else(|| chain.pointer("/underlyingPrice"))
1406 .and_then(|v| v.as_f64())
1407 .unwrap_or(0.0);
1408 if underlying_price <= 0.0 {
1409 return Ok(None);
1410 }
1411
1412 let (expiry, put_map) =
1413 pick_expiry_map(&chain, "putExpDateMap", entry.dte_min, entry.dte_max, today)?;
1414 let (_, call_map) = pick_expiry_map(
1415 &chain,
1416 "callExpDateMap",
1417 entry.dte_min,
1418 entry.dte_max,
1419 today,
1420 )?;
1421
1422 let put_short = pick_otm_strike(&put_map, underlying_price, entry.short_delta, true)?;
1423 let put_long = put_short - entry.wing_width;
1424 let call_short = pick_otm_strike(&call_map, underlying_price, entry.short_delta, false)?;
1425 let call_long = call_short + entry.wing_width;
1426
1427 let put_credit = estimate_spread_credit(&put_map, put_short, put_long)?;
1428 let call_credit = estimate_spread_credit(&call_map, call_short, call_long)?;
1429 let total_credit = put_credit + call_credit;
1430 if total_credit < entry.min_credit {
1431 return Ok(None);
1432 }
1433 let candidate_id = candidate_position_id(
1434 account_hash,
1435 underlying,
1436 &expiry.to_string(),
1437 StrategyKind::IronCondor.as_str(),
1438 vec![
1439 ('P', put_short, "S"),
1440 ('P', put_long, "L"),
1441 ('C', call_short, "S"),
1442 ('C', call_long, "L"),
1443 ],
1444 );
1445 if state.open_positions.contains_key(&candidate_id)
1446 || state.has_pending_position(&candidate_id)
1447 || has_legacy_duplicate(state, account_hash, underlying, &expiry.to_string())
1448 {
1449 return Ok(None);
1450 }
1451
1452 let params = IronCondorParams {
1453 underlying: underlying.to_string(),
1454 expiry: expiry.to_string(),
1455 put_short,
1456 put_long,
1457 call_short,
1458 call_long,
1459 contracts: entry.max_contracts_per_trade as f64,
1460 limit_credit: total_credit,
1461 duration: None,
1462 session: None,
1463 };
1464
1465 Ok(Some(json!({
1466 "type": "entry",
1467 "strategy": "iron_condor",
1468 "account_hash": account_hash,
1469 "position_id": candidate_id,
1470 "params": params,
1471 "estimated_credit": total_credit,
1472 })))
1473}
1474
1475fn pick_expiry_map(
1476 chain: &Value,
1477 map_key: &str,
1478 dte_min: u32,
1479 dte_max: u32,
1480 today: NaiveDate,
1481) -> Result<(NaiveDate, Value)> {
1482 let map = chain
1483 .get(map_key)
1484 .context("chain missing exp date map")?
1485 .as_object()
1486 .context("exp date map not an object")?;
1487
1488 for key in map.keys() {
1489 let date_part = key.split(':').next().unwrap_or(key);
1490 if let Ok(expiry) = parse_expiry(date_part) {
1491 let dte = days_to_expiry(expiry, today);
1492 if dte >= dte_min as i64 && dte <= dte_max as i64 {
1493 if let Some(strikes) = map.get(key) {
1494 return Ok((expiry, strikes.clone()));
1495 }
1496 }
1497 }
1498 }
1499 anyhow::bail!("no expiry found in DTE window {dte_min}-{dte_max}")
1500}
1501
1502fn pick_otm_strike(strike_map: &Value, underlying: f64, otm_pct: f64, puts: bool) -> Result<f64> {
1503 let target = if puts {
1504 underlying * (1.0 - otm_pct)
1505 } else {
1506 underlying * (1.0 + otm_pct)
1507 };
1508 pick_nearest_strike(strike_map, target)
1509}
1510
1511fn pick_wing_strike(strike_map: &Value, short_strike: f64, width: f64, puts: bool) -> Result<f64> {
1513 let target = if puts {
1514 short_strike - width
1515 } else {
1516 short_strike + width
1517 };
1518 pick_nearest_strike(strike_map, target)
1519}
1520
1521fn pick_nearest_strike(strike_map: &Value, target: f64) -> Result<f64> {
1522 let obj = strike_map.as_object().context("strike map not object")?;
1523 let candidates: Vec<f64> = obj.keys().filter_map(|k| k.parse::<f64>().ok()).collect();
1524 if candidates.is_empty() {
1525 anyhow::bail!("no strikes in chain");
1526 }
1527 candidates
1528 .into_iter()
1529 .min_by(|a, b| {
1530 ((*a - target).abs())
1531 .partial_cmp(&(*b - target).abs())
1532 .unwrap()
1533 })
1534 .context("no strike candidates")
1535}
1536
1537fn pick_strike_by_delta(
1539 strike_map: &Value,
1540 delta_min: f64,
1541 delta_max: f64,
1542 puts: bool,
1543) -> Option<f64> {
1544 let obj = strike_map.as_object()?;
1545 let target = (delta_min + delta_max) / 2.0;
1546 let mut best: Option<(f64, f64)> = None;
1547 for (key, contracts) in obj {
1548 let strike = key.parse::<f64>().ok()?;
1549 let delta = contracts.as_array()?.first()?.get("delta")?.as_f64()?;
1550 let abs_delta = delta.abs();
1551 if puts && delta > 0.0 {
1552 continue;
1553 }
1554 let dist = (abs_delta - target).abs();
1555 if best.is_none() || dist < best.unwrap().1 {
1556 best = Some((strike, dist));
1557 }
1558 }
1559 best.map(|(s, _)| s)
1560}
1561
1562fn estimate_spread_credit(put_map: &Value, short: f64, long: f64) -> Result<f64> {
1563 let short_bid = strike_quote_field(put_map, short, "bid")?;
1564 let long_ask = strike_quote_field(put_map, long, "ask")?;
1565 Ok((short_bid - long_ask).max(0.0))
1566}
1567
1568fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
1569 for key in strike_key_candidates(strike) {
1570 if let Some(val) = strike_map
1571 .get(&key)
1572 .and_then(|contracts| contracts.as_array()?.first())
1573 .and_then(|c| c.get(field))
1574 .and_then(|v| v.as_f64())
1575 {
1576 return Ok(val);
1577 }
1578 }
1579 anyhow::bail!("missing {field} for strike {strike}")
1580}
1581
1582fn strike_key_candidates(strike: f64) -> Vec<String> {
1583 vec![
1584 format!("{strike:.1}"),
1585 format!("{strike:.0}"),
1586 strike.to_string(),
1587 ]
1588}
1589
1590fn entry_quality_ok(
1591 strike_map: &Value,
1592 short_strike: f64,
1593 long_strike: f64,
1594 width: f64,
1595 credit: f64,
1596 entry: &VerticalEntryRules,
1597) -> bool {
1598 if width <= f64::EPSILON || credit <= f64::EPSILON {
1599 return false;
1600 }
1601 let min_ctw = entry.min_credit_to_width_pct.unwrap_or(12.5);
1602 let credit_to_width_pct = (credit / width) * 100.0;
1603 if credit_to_width_pct < min_ctw {
1604 return false;
1605 }
1606 let short_quote_width = quote_width(strike_map, short_strike).unwrap_or(f64::INFINITY);
1607 let long_quote_width = quote_width(strike_map, long_strike).unwrap_or(f64::INFINITY);
1608 (short_quote_width + long_quote_width) <= credit * MAX_ENTRY_QUOTE_WIDTH_RATIO
1609}
1610
1611fn quote_width(strike_map: &Value, strike: f64) -> Option<f64> {
1612 let bid = strike_quote_field(strike_map, strike, "bid").ok()?;
1613 let ask = strike_quote_field(strike_map, strike, "ask").ok()?;
1614 if bid < 0.0 || ask <= 0.0 || ask < bid {
1615 return None;
1616 }
1617 Some(ask - bid)
1618}
1619
1620fn has_legacy_duplicate(
1621 state: &AgentState,
1622 account_hash: &str,
1623 underlying: &str,
1624 expiry: &str,
1625) -> bool {
1626 state
1627 .open_positions
1628 .values()
1629 .any(|p| p.account_hash == account_hash && p.underlying == underlying && p.expiry == expiry)
1630}
1631
1632fn candidate_id_from_params(account_hash: &str, kind: StrategyKind, params: &Value) -> String {
1633 let underlying = params
1634 .get("underlying")
1635 .and_then(|v| v.as_str())
1636 .unwrap_or("");
1637 let expiry = params.get("expiry").and_then(|v| v.as_str()).unwrap_or("");
1638 match kind {
1639 StrategyKind::Vertical => {
1640 let spread_type = params
1641 .get("type")
1642 .and_then(|v| v.as_str())
1643 .unwrap_or("put_credit");
1644 let put_call = if spread_type.starts_with("call") {
1645 'C'
1646 } else {
1647 'P'
1648 };
1649 let short = params
1650 .get("short_strike")
1651 .and_then(|v| v.as_f64())
1652 .unwrap_or(0.0);
1653 let long = params
1654 .get("long_strike")
1655 .and_then(|v| v.as_f64())
1656 .unwrap_or(0.0);
1657 candidate_position_id(
1658 account_hash,
1659 underlying,
1660 expiry,
1661 kind.as_str(),
1662 vec![(put_call, short, "S"), (put_call, long, "L")],
1663 )
1664 }
1665 StrategyKind::IronCondor => candidate_position_id(
1666 account_hash,
1667 underlying,
1668 expiry,
1669 kind.as_str(),
1670 vec![
1671 (
1672 'P',
1673 params
1674 .get("put_short")
1675 .and_then(|v| v.as_f64())
1676 .unwrap_or(0.0),
1677 "S",
1678 ),
1679 (
1680 'P',
1681 params
1682 .get("put_long")
1683 .and_then(|v| v.as_f64())
1684 .unwrap_or(0.0),
1685 "L",
1686 ),
1687 (
1688 'C',
1689 params
1690 .get("call_short")
1691 .and_then(|v| v.as_f64())
1692 .unwrap_or(0.0),
1693 "S",
1694 ),
1695 (
1696 'C',
1697 params
1698 .get("call_long")
1699 .and_then(|v| v.as_f64())
1700 .unwrap_or(0.0),
1701 "L",
1702 ),
1703 ],
1704 ),
1705 }
1706}
1707
1708async fn maybe_execute_entry(
1709 runtime: &RuntimeConfig,
1710 trader: &Arc<TraderApi>,
1711 account_hash: &str,
1712 kind: StrategyKind,
1713 signal: &Value,
1714 rules: &RulesConfig,
1715 state: &mut AgentState,
1716) -> Result<Option<Value>> {
1717 if runtime.dry_run {
1718 return Ok(None);
1719 }
1720
1721 if state.trades_capacity_used() >= rules.risk.max_trades_per_day {
1722 return Ok(Some(json!({
1723 "fill_status": "SKIPPED",
1724 "reason": "max_trades_per_day reached or reserved by pending entries",
1725 "trades_today": state.trades_today,
1726 "pending_entries": state.pending_entry_count(),
1727 "max_trades_per_day": rules.risk.max_trades_per_day,
1728 "signal": signal,
1729 })));
1730 }
1731
1732 let params = signal
1733 .get("params")
1734 .cloned()
1735 .context("signal missing params")?;
1736 let margin = crate::options::validate::estimate_order_margin(&json!({}), kind, ¶ms)?;
1737 if margin > rules.risk.max_risk_per_trade_usd {
1738 return Ok(Some(json!({
1739 "fill_status": "SKIPPED",
1740 "reason": "max_risk_per_trade_usd exceeded",
1741 "required_margin_usd": margin,
1742 "max_risk_per_trade_usd": rules.risk.max_risk_per_trade_usd,
1743 "signal": signal,
1744 })));
1745 }
1746 let reserved = state.reserved_risk_usd();
1747 if reserved + margin > rules.risk.max_portfolio_risk_usd {
1748 return Ok(Some(json!({
1749 "fill_status": "SKIPPED",
1750 "reason": "max_portfolio_risk_usd exceeded",
1751 "reserved_risk_usd": reserved,
1752 "new_order_margin_usd": margin,
1753 "projected_reserved_risk_usd": reserved + margin,
1754 "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
1755 "signal": signal,
1756 })));
1757 }
1758 let position_id = signal
1759 .get("position_id")
1760 .and_then(|v| v.as_str())
1761 .map(str::to_string)
1762 .unwrap_or_else(|| candidate_id_from_params(account_hash, kind, ¶ms));
1763 if state.open_positions.contains_key(&position_id) || state.has_pending_position(&position_id) {
1764 return Ok(Some(json!({
1765 "fill_status": "SKIPPED",
1766 "reason": "position already open or pending",
1767 "position_id": position_id,
1768 "signal": signal,
1769 })));
1770 }
1771
1772 require_trading_approval(
1773 runtime,
1774 "agent entry",
1775 &format!("Open {kind:?} on {account_hash}"),
1776 )?;
1777
1778 ensure_option_buying_power(trader, account_hash, margin).await?;
1779 let order = build_order_for_strategy(kind, ¶ms)?;
1780 runtime.safety.validate_order(&order, None, None)?;
1781
1782 let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
1783
1784 let order_id = place
1785 .get("order_id")
1786 .and_then(|v| v.as_str())
1787 .map(str::to_string);
1788
1789 let wait_result = if let Some(ref order_id) = order_id {
1790 let condition = if rules.execution.wait_for_fill {
1791 WaitCondition::Terminal
1792 } else {
1793 WaitCondition::Accepted
1794 };
1795 Some(
1796 wait_for_order(
1797 trader,
1798 account_hash,
1799 order_id,
1800 WaitOptions {
1801 condition,
1802 timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
1803 interval: std::time::Duration::from_secs(5),
1804 proceed_on_partial_fill: false,
1805 requested_quantity: None,
1806 },
1807 )
1808 .await?,
1809 )
1810 } else {
1811 None
1812 };
1813
1814 let fill_status = wait_result
1815 .as_ref()
1816 .and_then(|w| w.final_status.as_deref())
1817 .unwrap_or("ACCEPTED");
1818
1819 if is_failure_status(fill_status) {
1820 let detail = json!({
1821 "signal": signal,
1822 "place": place,
1823 "wait": wait_result.as_ref().map(wait_result_json),
1824 "fill_status": fill_status,
1825 });
1826 state.record_action("entry_rejected", detail.clone());
1827 return Ok(Some(detail));
1828 }
1829
1830 if fill_status != "FILLED" && rules.execution.wait_for_fill {
1831 if let Some(order_id) = order_id.as_ref() {
1832 state.add_pending_order(PendingOrder {
1833 order_id: order_id.clone(),
1834 account_hash: account_hash.to_string(),
1835 action: PendingOrderAction::Entry,
1836 position_id: position_id.clone(),
1837 reserved_risk_usd: margin,
1838 submitted_at: Utc::now(),
1839 last_status: Some(fill_status.to_string()),
1840 detail: Some(json!({
1841 "signal": signal,
1842 "place": place.clone(),
1843 "wait": wait_result.as_ref().map(wait_result_json),
1844 })),
1845 });
1846 }
1847 let detail = json!({
1848 "signal": signal,
1849 "place": place,
1850 "wait": wait_result.as_ref().map(wait_result_json),
1851 "fill_status": fill_status,
1852 "position_id": position_id,
1853 "reserved_risk_usd": margin,
1854 "note": "Limit order working; risk and trade capacity reserved until terminal status",
1855 });
1856 state.record_action("entry_working", detail.clone());
1857 return Ok(Some(detail));
1858 }
1859
1860 state.trades_today += 1;
1861 let underlying = params
1862 .get("underlying")
1863 .and_then(|v| v.as_str())
1864 .unwrap_or("")
1865 .to_string();
1866 let expiry = params
1867 .get("expiry")
1868 .and_then(|v| v.as_str())
1869 .unwrap_or("")
1870 .to_string();
1871 let order_contracts = params
1872 .get("contracts")
1873 .and_then(|v| v.as_f64())
1874 .unwrap_or(1.0)
1875 .round()
1876 .max(1.0) as u32;
1877 let new_credit = signal.get("estimated_credit").and_then(|v| v.as_f64());
1878
1879 if let Some(existing) = state.open_positions.get_mut(&position_id) {
1880 let prev_contracts = existing.contracts.max(1);
1881 existing.contracts = prev_contracts + order_contracts;
1882 existing.max_loss_usd += margin;
1883 if let Some(credit) = new_credit {
1884 let blended = existing.entry_credit.unwrap_or(credit) * prev_contracts as f64
1885 + credit * order_contracts as f64;
1886 existing.entry_credit = Some(blended / existing.contracts as f64);
1887 }
1888 } else {
1889 state.open_positions.insert(
1890 position_id.clone(),
1891 TrackedPosition {
1892 position_id: position_id.clone(),
1893 account_hash: account_hash.to_string(),
1894 underlying,
1895 expiry,
1896 strategy: kind.as_str().to_string(),
1897 opened_at: Utc::now(),
1898 entry_credit: new_credit,
1899 max_loss_usd: margin,
1900 contracts: order_contracts,
1901 entry_params: None,
1902 peak_profit_pct: None,
1903 entry_pop_pct: signal
1904 .pointer("/market_context/spread_pop_pct")
1905 .and_then(|v| v.as_f64()),
1906 entry_short_delta: signal
1907 .pointer("/market_context/short_delta")
1908 .and_then(|v| v.as_f64())
1909 .map(f64::abs),
1910 },
1911 );
1912 }
1913 clear_redeploy_after_entry(state);
1914 state.record_action("entry", signal.clone());
1915
1916 Ok(Some(json!({
1917 "entry": place,
1918 "signal": signal,
1919 "wait": wait_result.as_ref().map(wait_result_json),
1920 "position_id": position_id,
1921 "fill_status": fill_status,
1922 })))
1923}
1924
1925async fn execute_exit(
1926 runtime: &RuntimeConfig,
1927 trader: &Arc<TraderApi>,
1928 account_hash: &str,
1929 rules: &RulesConfig,
1930 group: &crate::options::OptionPositionGroup,
1931 signal: &Value,
1932 state: &mut AgentState,
1933) -> Result<Value> {
1934 require_trading_approval(
1935 runtime,
1936 "agent exit",
1937 &format!("Close position {}", group.id),
1938 )?;
1939
1940 let position_id = stable_position_key(account_hash, group);
1941 if state.has_pending_position(&position_id) {
1942 return Ok(json!({
1943 "fill_status": "SKIPPED",
1944 "reason": "exit already pending",
1945 "position_id": position_id,
1946 "signal": signal,
1947 }));
1948 }
1949
1950 let close_limit = close_limit_from_signal(signal)
1951 .or_else(|| close_limit_from_group_mark(group))
1952 .context("could not derive close limit price for spread exit")?;
1953 let order = build_close_order_for_group_with_limit(group, Some(close_limit))?;
1954 runtime.safety.validate_order(&order, None, None)?;
1955 let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
1956
1957 let mut wait_json = None;
1958 let mut fill_status = "ACCEPTED".to_string();
1959 let order_id = place
1960 .get("order_id")
1961 .and_then(|v| v.as_str())
1962 .map(str::to_string);
1963
1964 if rules.execution.wait_for_fill {
1965 if let Some(order_id) = order_id.as_ref() {
1966 let wait = wait_for_order(
1967 trader,
1968 account_hash,
1969 order_id,
1970 WaitOptions {
1971 condition: WaitCondition::Filled,
1972 timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
1973 interval: std::time::Duration::from_secs(5),
1974 proceed_on_partial_fill: false,
1975 requested_quantity: None,
1976 },
1977 )
1978 .await;
1979 match wait {
1980 Ok(wait) => {
1981 fill_status = wait
1982 .final_status
1983 .as_deref()
1984 .unwrap_or("UNKNOWN")
1985 .to_string();
1986 wait_json = Some(wait_result_json(&wait));
1987 }
1988 Err(e) => {
1989 fill_status = "WAIT_ERROR".into();
1990 wait_json = Some(json!({ "error": e.to_string() }));
1991 }
1992 }
1993 }
1994 }
1995
1996 let detail = json!({
1997 "exit": place,
1998 "signal": signal,
1999 "position_id": position_id,
2000 "limit_price": close_limit,
2001 "wait": wait_json,
2002 "fill_status": fill_status.clone(),
2003 });
2004
2005 if fill_status == "FILLED" || !rules.execution.wait_for_fill {
2006 state.open_positions.remove(&position_id);
2007 state.open_positions.remove(&group.id);
2008 state.record_action("exit", signal.clone());
2009 } else {
2010 if let Some(order_id) = order_id {
2011 state.add_pending_order(PendingOrder {
2012 order_id,
2013 account_hash: account_hash.to_string(),
2014 action: PendingOrderAction::Exit,
2015 position_id,
2016 reserved_risk_usd: 0.0,
2017 submitted_at: Utc::now(),
2018 last_status: Some(fill_status),
2019 detail: Some(detail.clone()),
2020 });
2021 }
2022 state.record_action("exit_working_position_kept", detail.clone());
2023 }
2024
2025 Ok(detail)
2026}
2027
2028fn close_limit_from_signal(signal: &Value) -> Option<f64> {
2029 let debit = signal
2030 .pointer("/mark/debit_to_close")
2031 .and_then(|v| v.as_f64())?;
2032 Some((debit + EXIT_LIMIT_SLIPPAGE).max(0.01))
2033}
2034
2035fn close_limit_from_group_mark(group: &crate::options::OptionPositionGroup) -> Option<f64> {
2036 let contracts = crate::options::spread_contract_count(group) as f64;
2037 if contracts <= 0.0 {
2038 return None;
2039 }
2040 Some((group.net_market_value.abs() / contracts / 100.0 + EXIT_LIMIT_SLIPPAGE).max(0.01))
2041}
2042
2043#[cfg(test)]
2044mod llm_schedule_tests {
2045 use super::*;
2046 use crate::agent::llm::LlmReview;
2047 use crate::agent::state::{AgentState, TrackedPosition};
2048 use crate::rules::{
2049 AccountType, EntryRules, ExecutionConfig, ExitRules, LlmConfig, NotifyConfig, RiskConfig,
2050 RulesAccount, RulesConfig, ScheduleConfig, StrategiesToggle, StrategyEnabled,
2051 VerticalEntryRules,
2052 };
2053
2054 fn test_rules(max_open: u32) -> RulesConfig {
2055 RulesConfig {
2056 version: 1,
2057 agent_id: "test".into(),
2058 accounts: vec![RulesAccount {
2059 hash: "ACC".into(),
2060 label: Some("test".into()),
2061 r#type: AccountType::Ira,
2062 enabled: true,
2063 }],
2064 schedule: ScheduleConfig {
2065 tick_interval_seconds: 120,
2066 ..Default::default()
2067 },
2068 strategies: StrategiesToggle {
2069 vertical: StrategyEnabled { enabled: true },
2070 iron_condor: StrategyEnabled { enabled: false },
2071 },
2072 watchlist: vec!["IWM".into()],
2073 entry_rules: EntryRules {
2074 vertical: VerticalEntryRules {
2075 max_open_positions: max_open,
2076 ..Default::default()
2077 },
2078 ..Default::default()
2079 },
2080 exit_rules: ExitRules {
2081 dte_close: 21,
2082 ..Default::default()
2083 },
2084 risk: RiskConfig::default(),
2085 execution: ExecutionConfig::default(),
2086 llm: LlmConfig {
2087 enabled: true,
2088 review_every_ticks: 15,
2089 monitor_review_every_ticks: Some(30),
2090 ..Default::default()
2091 },
2092 notify: NotifyConfig::default(),
2093 simulation: None,
2094 }
2095 }
2096
2097 fn state_with_open_position() -> AgentState {
2098 let mut state = AgentState::default();
2099 state.open_positions.insert(
2100 "pos".into(),
2101 TrackedPosition {
2102 position_id: "pos".into(),
2103 account_hash: "ACC".into(),
2104 underlying: "IWM".into(),
2105 expiry: "2026-07-31".into(),
2106 strategy: "vertical".into(),
2107 opened_at: chrono::Utc::now(),
2108 entry_credit: Some(0.30),
2109 max_loss_usd: 170.0,
2110 contracts: 1,
2111 entry_params: None,
2112 peak_profit_pct: None,
2113 entry_pop_pct: None,
2114 entry_short_delta: None,
2115 },
2116 );
2117 state
2118 }
2119
2120 #[test]
2121 fn selection_llm_is_throttled_when_candidates_exist() {
2122 let rules = test_rules(2);
2123 let mut state = state_with_open_position();
2124 state.regular_tick_count = 100;
2125 state.last_llm_review_tick = Some(95);
2126
2127 assert!(resolve_llm_phase(&rules, &state, true, true, false, false).is_none());
2128
2129 state.regular_tick_count = 125;
2130 assert!(matches!(
2131 resolve_llm_phase(&rules, &state, true, true, false, false),
2132 Some(LlmPhase::Selection)
2133 ));
2134 }
2135
2136 #[test]
2137 fn monitor_runs_when_no_candidates_and_due() {
2138 let rules = test_rules(2);
2139 let mut state = state_with_open_position();
2140 state.regular_tick_count = 125;
2141 state.last_llm_review_tick = Some(95);
2142
2143 assert!(matches!(
2144 resolve_llm_phase(&rules, &state, false, true, false, false),
2145 Some(LlmPhase::Monitor)
2146 ));
2147 }
2148
2149 #[test]
2150 fn monitor_skipped_at_open_without_candidates_or_mechanical_alert() {
2151 let rules = test_rules(2);
2152 let mut state = state_with_open_position();
2153 state.regular_tick_count = 125;
2154 state.last_llm_review_tick = Some(95);
2155
2156 assert!(resolve_llm_phase(&rules, &state, false, true, true, false).is_none());
2157 }
2158
2159 #[test]
2160 fn monitor_runs_at_open_when_mechanical_alert() {
2161 let rules = test_rules(2);
2162 let mut state = state_with_open_position();
2163 state.regular_tick_count = 125;
2164 state.last_llm_review_tick = Some(95);
2165
2166 assert!(matches!(
2167 resolve_llm_phase(&rules, &state, false, true, true, true),
2168 Some(LlmPhase::Monitor)
2169 ));
2170 }
2171
2172 #[test]
2173 fn mechanical_alert_detects_stop_triggered() {
2174 let monitored = vec![json!({
2175 "mechanical_rules": { "stop_triggered": true }
2176 })];
2177 assert!(mechanical_alert_from_monitored(&monitored, 21));
2178 }
2179
2180 #[test]
2181 fn entry_scan_skipped_when_all_accounts_at_capacity() {
2182 let rules = test_rules(1);
2183 let state = state_with_open_position();
2184 assert!(!any_entry_slots_available(&rules, &state));
2185 }
2186
2187 #[test]
2188 fn redeploy_cooldown_removes_underlying_from_scan() {
2189 let mut rules = test_rules(2);
2190 rules.exit_rules.thesis.redeploy_cooldown_minutes = Some(120);
2191 let mut state = AgentState::default();
2192 state.redeploy_signal = Some(RedeploySignal {
2193 at: Utc::now(),
2194 reason: "thesis_near_strike".into(),
2195 underlying: Some("IWM".into()),
2196 });
2197 let wl = watchlist_for_scan(&rules, &state);
2198 assert!(!wl.iter().any(|s| s.eq_ignore_ascii_case("IWM")));
2199
2200 state.redeploy_signal = Some(RedeploySignal {
2201 at: Utc::now() - chrono::Duration::minutes(121),
2202 reason: "thesis_near_strike".into(),
2203 underlying: Some("IWM".into()),
2204 });
2205 let wl = watchlist_for_scan(&rules, &state);
2206 assert_eq!(wl.first().map(String::as_str), Some("IWM"));
2207 }
2208
2209 #[test]
2210 fn selection_telegram_only_on_proceed_or_urgent_close() {
2211 use crate::agent::telegram_format::is_llm_urgent;
2212
2213 let defer = LlmReview {
2214 phase: "selection".into(),
2215 model: "test".into(),
2216 used_web: false,
2217 raw: serde_json::json!({}),
2218 market_commentary: "ok".into(),
2219 web_insights: vec![],
2220 position_reviews: vec![],
2221 entry_recommendation: "defer".into(),
2222 entry_reasoning: "wait".into(),
2223 risk_alerts: vec!["noise".into()],
2224 };
2225 assert!(!is_llm_urgent(&defer));
2226
2227 let proceed = LlmReview {
2228 entry_recommendation: "proceed".into(),
2229 ..defer.clone()
2230 };
2231 assert!(is_llm_urgent(&proceed));
2232 }
2233}