Skip to main content

schwab_cli/agent/
runner.rs

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    // Exit evaluation and position monitoring (always runs when market is open)
325    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    // Entry scan (signals collected; execution after LLM review)
427    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    // LLM review — selection when candidates exist; monitor on schedule when positions open
515    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    // LLM-requested exits (high urgency only, when enabled)
625    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    // Execute pending entries unless LLM vetoed (dry-run never executes)
663    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
1163/// True when live marks show a mechanical exit threshold is near or breached.
1164fn 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
1207/// Every Nth LLM review uses web_model during selection phase.
1208fn 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
1511/// For put credit spreads, long strike is below short by approximately `width`.
1512fn 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
1537/// Pick put strike whose |delta| is closest to the middle of [delta_min, delta_max].
1538fn 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, &params)?;
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, &params));
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, &params)?;
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}