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::{
27    EntryScanMode, LlmPhase, RulesConfig, VerticalEntryRules, WatchlistItemConfig, WatchlistRole,
28};
29use crate::safety::{execute_trading_order, require_trading_approval};
30use crate::trade_audio::{self, TradeAudioEvent};
31
32use super::exits::{
33    candidate_fails_thesis_gates, evaluate_position_monitor, exit_signal_json_for_account,
34    find_tracked_position, option_group_from_tracked, reconcile_open_positions, stable_position_key,
35    ExitEvaluation,
36};
37use super::journal;
38use super::llm::OpenRouterClient;
39use super::market_context::{market_context_summary_for_llm, vertical_entry_market_context};
40use super::regime::{detect_options_regime, OptionsRegimeSnapshot};
41use super::risk::{drawdown_to_json, record_live_realized_pnl, update_drawdown};
42use super::roll::{
43    original_width_from_params, roll_biased_entry_rules, roll_eligible, roll_money_ok, roll_net,
44    spread_type_from_tracked, RollEligibility,
45};
46use super::spread_analytics::{analytics_from_json, entry_analytics_pass, passes_min_iv_rv_ratio};
47use super::volatility::{fetch_realized_vol_pct, iv_rv_ratio};
48use super::paths::{active_state_path, load_agent_state, load_sim_agent_state};
49use super::protective;
50use super::resilience;
51use super::sim::{ensure_ledger, record_sim_entry, record_sim_exit};
52use super::schedule::{self, AgentSession};
53use super::state::{
54    candidate_fingerprint, entry_attempt_cooldown_active, entry_proceed_cache_valid,
55    is_stop_loss_exit_reason, is_thesis_exit_reason, record_entry_attempt, record_stop_loss_exit,
56    save_state, stop_loss_re_entry_blocked, update_peak_profit_pct, AgentState, EntryProceedCache,
57    PendingOrder, PendingOrderAction, RedeploySignal, TrackedPosition,
58};
59use super::telegram_format::{
60    format_action_telegram, format_llm_review_telegram, format_market_open_telegram,
61    format_overnight_telegram, record_llm_telegram_sent, should_send_llm_telegram,
62};
63use crate::ui::agent_health::SharedAgentHealth;
64
65const MAX_ENTRY_QUOTE_WIDTH_RATIO: f64 = 1.0;
66const EXIT_LIMIT_SLIPPAGE: f64 = 0.05;
67
68#[derive(Debug, Clone, serde::Serialize)]
69pub struct TickResult {
70    pub session: String,
71    pub at_open: bool,
72    pub next_sleep_seconds: u64,
73    pub signals: Vec<Value>,
74    pub actions: Vec<Value>,
75    pub skipped: Vec<String>,
76    pub monitored_positions: Vec<Value>,
77    pub llm_review: Option<Value>,
78    #[serde(default)]
79    pub monitoring: Value,
80}
81
82pub async fn run_agent_loop(
83    runtime: &RuntimeConfig,
84    rules_path: &std::path::Path,
85    once: bool,
86    watch_health: Option<SharedAgentHealth>,
87) -> Result<()> {
88    let rules = RulesConfig::load(rules_path)?;
89    let state_path = active_state_path(rules_path, runtime.simulate);
90    let mut state = if runtime.simulate {
91        load_sim_agent_state(rules_path, &rules.agent_id)
92    } else {
93        load_agent_state(rules_path, &rules.agent_id)
94    };
95    state.agent_id = rules.agent_id.clone();
96
97    trade_audio::init(runtime.no_audio);
98
99    if rules.execution.require_preview && !runtime.safety.require_preview_before_place {
100        anyhow::bail!(
101            "rules require preview before order placement, but safety.json has require_preview_before_place=false"
102        );
103    }
104
105    if !runtime.dry_run && !runtime.simulate {
106        crate::safety::require_trading_approval(
107            runtime,
108            "agent run",
109            &format!("Run options agent `{}`", rules.agent_id),
110        )?;
111    }
112
113    let trader = runtime.build_api()?;
114    let market = runtime.build_market_api()?;
115    let telegram = TelegramNotifier::from_env(&rules.notify.telegram)
116        .ok()
117        .flatten();
118    let llm_client = if rules.llm.enabled {
119        match OpenRouterClient::from_env() {
120            Ok(client) => Some(client),
121            Err(e) => {
122                let msg = format!(
123                    "LLM disabled for this run: {e:#} (set OPENROUTER_API_KEY or llm.enabled: false)"
124                );
125                let _ = super::paths::append_agent_log(rules_path, &msg);
126                if let Some(h) = watch_health.as_ref() {
127                    if let Ok(mut g) = h.lock() {
128                        g.record_error(&msg);
129                    }
130                }
131                None
132            }
133        }
134    } else {
135        None
136    };
137
138    let mut consecutive_errors = 0u32;
139    let mut last_logged_error: Option<String> = None;
140    let mut auth_notified = false;
141
142    loop {
143        // Soft re-auth probe so a fresh `schwab auth login` is picked up mid-run.
144        if let Err(err) = trader.client().oauth().ensure_access_token().await {
145            tracing::debug!("access token probe: {err}");
146        }
147
148        match tick_once(
149            runtime,
150            rules_path,
151            &rules,
152            &trader,
153            &market,
154            &mut state,
155            llm_client.as_ref(),
156            telegram.as_ref(),
157        )
158        .await
159        {
160            Ok(result) => {
161                if consecutive_errors > 0 {
162                    let msg = format!(
163                        "agent recovered after {consecutive_errors} failure(s)"
164                    );
165                    let _ = super::paths::append_agent_log(rules_path, &msg);
166                    if let Some(tg) = telegram.as_ref() {
167                        if tg.wants_actions() {
168                            let _ = tg
169                                .send(&format!(
170                                    "schwab [{}]\n✓ AGENT RECOVERED\nafter {consecutive_errors} failure(s) — exits armed again",
171                                    rules.agent_id
172                                ))
173                                .await;
174                        }
175                    }
176                }
177                consecutive_errors = 0;
178                auth_notified = false;
179                state.last_tick = Some(Utc::now());
180                save_state(&state_path, &state)?;
181
182                if let Some(h) = watch_health.as_ref() {
183                    if let Ok(mut g) = h.lock() {
184                        g.record_tick();
185                    }
186                }
187
188                let tick_payload = json!({
189                    "agent_id": rules.agent_id,
190                    "session": result.session,
191                    "at_open": result.at_open,
192                    "next_sleep_seconds": result.next_sleep_seconds,
193                    "signals": result.signals,
194                    "actions": result.actions,
195                    "skipped": result.skipped,
196                    "monitored_positions": result.monitored_positions,
197                    "llm_review": result.llm_review,
198                    "dry_run": runtime.dry_run,
199                    "simulate": runtime.simulate,
200                });
201
202                if runtime.suppress_tick_output {
203                    let summary = format!(
204                        "{} session={} signals={} actions={} skipped={}",
205                        Utc::now().format("%Y-%m-%d %H:%M:%S"),
206                        result.session,
207                        result.signals.len(),
208                        result.actions.len(),
209                        result.skipped.len()
210                    );
211                    let _ = super::paths::append_agent_log(rules_path, &summary);
212                } else {
213                    runtime.emit(crate::output::ResponseEnvelope::ok(
214                        if once { "agent run once" } else { "agent tick" },
215                        tick_payload,
216                    ));
217                }
218
219                notify_tick(telegram.as_ref(), &rules, &result, runtime.dry_run).await;
220
221                if once {
222                    break;
223                }
224
225                tokio::time::sleep(std::time::Duration::from_secs(result.next_sleep_seconds)).await;
226            }
227            Err(e) => {
228                consecutive_errors += 1;
229                let err_str = format!("{e:#}");
230                // 3-tier classification (Recoverable / AuthFatal / Unexpected) so a real code
231                // bug and a transient network blip get differentiated backoff and alerting,
232                // instead of both falling into a single generic "not auth" bucket.
233                let class = resilience::classify_agent_error(&e);
234                let class_label = resilience::class_label(class);
235
236                if class == resilience::AgentErrorClass::AuthFatal {
237                    let msg = "agent degraded: Schwab login required (refresh token invalid). Run: schwab auth login — retrying";
238                    if last_logged_error.as_deref() != Some(msg) {
239                        let _ = super::paths::append_agent_log(rules_path, msg);
240                        last_logged_error = Some(msg.to_string());
241                    }
242                    if let Some(h) = watch_health.as_ref() {
243                        if let Ok(mut g) = h.lock() {
244                            g.record_error(msg);
245                        }
246                    }
247                    if !auth_notified {
248                        notify_auth_required(telegram.as_ref(), msg).await;
249                        auth_notified = true;
250                    }
251                    if once {
252                        return Err(e);
253                    }
254                    let backoff = resilience::backoff_seconds(class, consecutive_errors);
255                    tokio::time::sleep(Duration::from_secs(backoff)).await;
256                    continue;
257                }
258
259                let msg = format!("tick error ({class_label}, #{consecutive_errors}): {err_str}");
260                if last_logged_error.as_deref() != Some(msg.as_str()) {
261                    let _ = super::paths::append_agent_log(rules_path, &msg);
262                    last_logged_error = Some(msg.clone());
263                }
264                if let Some(h) = watch_health.as_ref() {
265                    if let Ok(mut g) = h.lock() {
266                        g.record_error(&msg);
267                    }
268                }
269                // Avoid Telegram spam: alert on the first failure, then every 10th, so both
270                // Recoverable and Unexpected errors surface without flooding chat.
271                if consecutive_errors == 1 || consecutive_errors % 10 == 0 {
272                    if let Some(tg) = telegram.as_ref() {
273                        if tg.wants_actions() {
274                            let short: String = err_str.chars().take(280).collect();
275                            let _ = tg
276                                .send(&format!(
277                                    "schwab [{}]\n⚠ AGENT DEGRADED ({class_label}) ×{consecutive_errors}\n{short}\nAgent staying up; backing off and retrying",
278                                    rules.agent_id
279                                ))
280                                .await;
281                        }
282                    }
283                }
284                if once {
285                    return Err(e);
286                }
287                let backoff = resilience::backoff_seconds(class, consecutive_errors);
288                tokio::time::sleep(Duration::from_secs(backoff)).await;
289            }
290        }
291    }
292
293    if let Some(h) = watch_health.as_ref() {
294        if let Ok(mut g) = h.lock() {
295            g.loop_running = false;
296        }
297    }
298
299    Ok(())
300}
301
302#[allow(clippy::too_many_arguments)]
303pub async fn tick_once(
304    runtime: &RuntimeConfig,
305    rules_path: &std::path::Path,
306    rules: &RulesConfig,
307    trader: &Arc<TraderApi>,
308    market: &Arc<MarketDataApi>,
309    state: &mut AgentState,
310    llm_client: Option<&OpenRouterClient>,
311    telegram: Option<&TelegramNotifier>,
312) -> Result<TickResult> {
313    let today = Local::now().date_naive();
314    state.reset_daily_if_needed(today);
315    state.tick_count += 1;
316
317    check_auth_reminder(state, telegram).await;
318    maybe_clear_stale_redeploy(state);
319
320    let mut result = TickResult {
321        session: "unknown".into(),
322        at_open: false,
323        next_sleep_seconds: rules.schedule.tick_interval_seconds.max(5),
324        signals: vec![],
325        actions: vec![],
326        skipped: vec![],
327        monitored_positions: vec![],
328        llm_review: None,
329        monitoring: Value::Null,
330    };
331
332    if runtime.simulate {
333        let _ = ensure_ledger(state, rules);
334        result
335            .skipped
336            .push("simulate — using paper state (no Schwab reconcile)".into());
337    } else {
338        reconcile_open_positions(trader, state, rules).await?;
339        poll_pending_orders(trader, state, rules, &mut result).await?;
340        let protective_summary =
341            protective::reconcile_protective_orders(runtime, trader, rules, state).await;
342        result.monitoring = json!({
343            "unprotected_count": protective_summary.unprotected_count,
344            "protective_orders_placed_this_tick": protective_summary.placed,
345            "protective_orders_failed_this_tick": protective_summary.failed,
346        });
347    }
348
349    let (market_open, hours) = fetch_option_market_status(market).await?;
350    state.last_market_open = Some(market_open);
351    if let Some(ref h) = hours {
352        let _ = crate::ui::market_status::save_market_hours_cache(rules_path, h);
353    }
354    let transition =
355        schedule::resolve_session(market_open, &rules.schedule, state.last_session.as_deref());
356    result.session = transition.session.as_str().to_string();
357    result.next_sleep_seconds = transition.sleep_seconds;
358    state.last_session = Some(result.session.clone());
359
360    match transition.session {
361        AgentSession::Idle => {
362            result.skipped.push("market closed (option hours)".into());
363            return Ok(result);
364        }
365        AgentSession::Overnight => {
366            return tick_overnight(runtime, rules, state, llm_client, telegram, &mut result).await;
367        }
368        AgentSession::RegularHours => {
369            if transition.just_opened {
370                result.at_open = true;
371                result
372                    .skipped
373                    .push("market open — full evaluation (mechanical exits + live marks)".into());
374            }
375            state.regular_tick_count += 1;
376        }
377    }
378
379    let prior_open_playbook = if result.at_open {
380        let pb = state.open_playbook.take();
381        notify_at_open(
382            telegram,
383            pb.as_ref(),
384            state.open_positions.len(),
385        )
386        .await;
387        pb
388    } else {
389        None
390    };
391
392    let entries_paused = rules.risk.max_trades_per_day > 0
393        && state.trades_capacity_used() >= rules.risk.max_trades_per_day;
394    let blocked_events_active = !rules.risk.blocked_events.is_empty();
395    let active_blocked_dates = rules.risk.active_blocked_date_labels(today);
396    let blocked_dates_active = !active_blocked_dates.is_empty();
397    let calendar_or_manual_block = blocked_events_active || blocked_dates_active;
398
399    if let Some(obj) = result.monitoring.as_object_mut() {
400        obj.insert(
401            "blocked_dates_active".into(),
402            json!(active_blocked_dates),
403        );
404        obj.insert(
405            "blocked_events_active".into(),
406            json!(blocked_events_active),
407        );
408    } else {
409        result.monitoring = json!({
410            "blocked_dates_active": active_blocked_dates,
411            "blocked_events_active": blocked_events_active,
412        });
413    }
414
415    let mut regime_snap: Option<OptionsRegimeSnapshot> = None;
416    if rules.regime.enabled && !entries_paused && !calendar_or_manual_block {
417        match detect_options_regime(market, &rules.regime).await {
418            Ok(snap) => {
419                state.last_regime = Some(snap.to_json());
420                if snap.pause_entries {
421                    result.skipped.push(format!(
422                        "regime pause — {} (preferred={})",
423                        snap.class, snap.preferred_strategy
424                    ));
425                }
426                regime_snap = Some(snap);
427            }
428            Err(e) => {
429                result
430                    .skipped
431                    .push(format!("regime detect failed (continuing with defaults): {e:#}"));
432            }
433        }
434    }
435
436    // Exit evaluation and position monitoring (always runs when market is open)
437    if runtime.simulate {
438        let position_ids: Vec<String> = state.open_positions.keys().cloned().collect();
439        for position_id in position_ids {
440            let Some(tracked) = state.open_positions.get(&position_id).cloned() else {
441                continue;
442            };
443            let Some(group) = option_group_from_tracked(&tracked) else {
444                result.skipped.push(format!(
445                    "simulate exit skip {position_id}: missing entry_params"
446                ));
447                continue;
448            };
449            let monitor =
450                evaluate_position_monitor(market, &group, rules, today, Some(&tracked)).await?;
451            if let Some(profit) = monitor.mark.as_ref().map(|m| m.profit_pct) {
452                if let Some(p) = state.open_positions.get_mut(&position_id) {
453                    update_peak_profit_pct(p, profit);
454                }
455            }
456            if !group.legs.is_empty() {
457                result.monitored_positions.push(monitor.snapshot);
458            }
459                if let Some(eval) = monitor.exit {
460                    if is_thesis_exit_reason(&eval.reason) {
461                        state.redeploy_signal = Some(RedeploySignal {
462                            at: Utc::now(),
463                            reason: eval.reason.clone(),
464                            underlying: Some(tracked.underlying.clone()),
465                        });
466                    }
467
468                    let mut handled_as_roll = false;
469                    if is_stop_loss_exit_reason(&eval.reason) && !runtime.dry_run {
470                        match try_defensive_roll(
471                            runtime,
472                            trader,
473                            market,
474                            rules_path,
475                            rules,
476                            state,
477                            today,
478                            &tracked.account_hash,
479                            &position_id,
480                            &tracked,
481                            &group,
482                            &eval,
483                            monitor.analytics.as_ref().and_then(|a| a.short_otm_pct),
484                            true,
485                            telegram,
486                        )
487                        .await
488                        {
489                            Ok(DefensiveRollOutcome::Success(detail)) => {
490                                handled_as_roll = true;
491                                result.signals.push(detail.clone());
492                                result.actions.push(detail.clone());
493                                result.skipped.push(format!("rolled {position_id}"));
494                                notify_action(telegram, "DEFENSIVE ROLL", &detail).await;
495                            }
496                            Ok(DefensiveRollOutcome::NotAttempted { reason }) => {
497                                result.skipped.push(format!(
498                                    "roll skipped {position_id}: {reason} — stop_loss"
499                                ));
500                            }
501                            Ok(DefensiveRollOutcome::StopCompleted { detail, reason }) => {
502                                handled_as_roll = true;
503                                record_stop_loss_exit(state, &tracked.underlying);
504                                state.redeploy_signal = None;
505                                state.entry_proceed_cache = None;
506                                result.skipped.push(format!(
507                                    "roll aborted after close {position_id}: {reason} — stop_loss"
508                                ));
509                                if let Some(d) = detail {
510                                    result.actions.push(d);
511                                }
512                                let exit =
513                                    exit_signal_json_for_account(&tracked.account_hash, &group, &eval);
514                                result.signals.push(exit.clone());
515                                notify_action(telegram, "SIM EXIT", &exit).await;
516                            }
517                            Err(err) => {
518                                result.skipped.push(format!(
519                                    "roll error {position_id}: {err:#} — stop_loss"
520                                ));
521                            }
522                        }
523                    }
524
525                    if !handled_as_roll {
526                        if is_stop_loss_exit_reason(&eval.reason) {
527                            record_stop_loss_exit(state, &tracked.underlying);
528                            state.redeploy_signal = None;
529                            state.entry_proceed_cache = None;
530                        }
531                        let exit =
532                            exit_signal_json_for_account(&tracked.account_hash, &group, &eval);
533                        result.signals.push(exit.clone());
534                        if !runtime.dry_run {
535                            match record_sim_exit(
536                                rules_path,
537                                state,
538                                rules,
539                                &position_id,
540                                &eval.reason,
541                                &eval.mark,
542                                &exit,
543                            ) {
544                                Ok(action) => {
545                                    result.actions.push(action);
546                                    notify_action(telegram, "SIM EXIT", &exit).await;
547                                }
548                                Err(err) => {
549                                    result
550                                        .skipped
551                                        .push(format!("sim exit failed {position_id}: {err:#}"));
552                                }
553                            }
554                        }
555                    }
556                }
557        }
558    } else {
559        for account in rules.enabled_accounts() {
560            let legs = list_option_positions(trader, Some(&account.hash)).await?;
561            let groups = group_option_legs(&legs);
562            for group in &groups {
563                let tracked_owned = find_tracked_position(state, &account.hash, group).cloned();
564                let monitor = evaluate_position_monitor(
565                    market,
566                    group,
567                    rules,
568                    today,
569                    tracked_owned.as_ref(),
570                )
571                .await?;
572                let position_id = stable_position_key(&account.hash, group);
573                if let Some(profit) = monitor.mark.as_ref().map(|m| m.profit_pct) {
574                    if let Some(p) = state.open_positions.get_mut(&position_id) {
575                        update_peak_profit_pct(p, profit);
576                    }
577                }
578
579                if !group.legs.is_empty() {
580                    result.monitored_positions.push(monitor.snapshot);
581                }
582
583                if let Some(eval) = monitor.exit {
584                    if is_thesis_exit_reason(&eval.reason) {
585                        state.redeploy_signal = Some(RedeploySignal {
586                            at: Utc::now(),
587                            reason: eval.reason.clone(),
588                            underlying: Some(group.underlying.clone()),
589                        });
590                    }
591
592                    let mut handled_as_roll = false;
593                    if is_stop_loss_exit_reason(&eval.reason)
594                        && !runtime.dry_run
595                        && tracked_owned.is_some()
596                        && !state.has_pending_position(&position_id)
597                    {
598                        let tracked_ref = tracked_owned.as_ref().expect("checked is_some");
599                        match try_defensive_roll(
600                            runtime,
601                            trader,
602                            market,
603                            rules_path,
604                            rules,
605                            state,
606                            today,
607                            &account.hash,
608                            &position_id,
609                            tracked_ref,
610                            group,
611                            &eval,
612                            monitor.analytics.as_ref().and_then(|a| a.short_otm_pct),
613                            false,
614                            telegram,
615                        )
616                        .await
617                        {
618                            Ok(DefensiveRollOutcome::Success(detail)) => {
619                                handled_as_roll = true;
620                                result.signals.push(detail.clone());
621                                result.actions.push(detail.clone());
622                                result.skipped.push(format!("rolled {position_id}"));
623                                notify_action(telegram, "DEFENSIVE ROLL", &detail).await;
624                            }
625                            Ok(DefensiveRollOutcome::NotAttempted { reason }) => {
626                                result.skipped.push(format!(
627                                    "roll skipped {position_id}: {reason} — stop_loss"
628                                ));
629                            }
630                            Ok(DefensiveRollOutcome::StopCompleted { detail, reason }) => {
631                                handled_as_roll = true;
632                                record_stop_loss_exit(state, &group.underlying);
633                                state.redeploy_signal = None;
634                                state.entry_proceed_cache = None;
635                                result.skipped.push(format!(
636                                    "roll aborted after close {position_id}: {reason} — stop_loss"
637                                ));
638                                if let Some(d) = detail {
639                                    result.actions.push(d);
640                                }
641                                let exit =
642                                    exit_signal_json_for_account(&account.hash, group, &eval);
643                                result.signals.push(exit.clone());
644                                notify_action(telegram, "EXIT", &exit).await;
645                            }
646                            Err(err) => {
647                                result.skipped.push(format!(
648                                    "roll error {position_id}: {err:#} — stop_loss"
649                                ));
650                            }
651                        }
652                    }
653
654                    if !handled_as_roll {
655                        if is_stop_loss_exit_reason(&eval.reason) {
656                            record_stop_loss_exit(state, &group.underlying);
657                            state.redeploy_signal = None;
658                            state.entry_proceed_cache = None;
659                        }
660                        let exit = exit_signal_json_for_account(&account.hash, group, &eval);
661                        result.signals.push(exit.clone());
662                        if state.has_pending_position(&position_id) {
663                            result
664                                .skipped
665                                .push(format!("exit already pending for {position_id}"));
666                        } else if !runtime.dry_run {
667                            if let Ok(action) = execute_exit(
668                                runtime,
669                                trader,
670                                &account.hash,
671                                rules,
672                                group,
673                                &exit,
674                                state,
675                            )
676                            .await
677                            {
678                                result.actions.push(action);
679                                notify_action(telegram, "EXIT", &exit).await;
680                            }
681                        }
682                    }
683                }
684            }
685        }
686    }
687
688    // Entry scan (signals collected; execution after LLM review)
689    let halted_before = state.trading_halted_reason.clone();
690    let drawdown = update_drawdown(state, rules, &result.monitored_positions);
691    if let Some(obj) = result.monitoring.as_object_mut() {
692        obj.insert("drawdown".into(), drawdown_to_json(&drawdown));
693        obj.insert(
694            "trading_halted_reason".into(),
695            json!(state.trading_halted_reason),
696        );
697    } else {
698        result.monitoring = json!({
699            "drawdown": drawdown_to_json(&drawdown),
700            "trading_halted_reason": state.trading_halted_reason,
701        });
702    }
703    if state.trading_halted_reason != halted_before {
704        if let Some(reason) = &state.trading_halted_reason {
705            notify_trading_halted(telegram, rules, reason).await;
706        } else if halted_before.is_some() {
707            notify_trading_recovered(telegram, rules).await;
708        }
709    }
710
711    let mut pending_entries: Vec<(String, StrategyKind, Value)> = Vec::new();
712    let regime_pause = regime_snap.as_ref().is_some_and(|s| s.pause_entries);
713    let preferred = regime_snap
714        .as_ref()
715        .map(|s| s.preferred_strategy.as_str())
716        .unwrap_or("put_credit");
717    let trading_halted = state.trading_halted_reason.is_some();
718
719    if entries_paused {
720        result.skipped.push(format!(
721            "new entries paused — max_trades_per_day ({}) reached or reserved by pending entries \
722             (soft churn cap; hard gates are max_open_positions + portfolio/trade risk)",
723            rules.risk.max_trades_per_day
724        ));
725    } else if trading_halted {
726        result.skipped.push(format!(
727            "new entries paused — {}",
728            state
729                .trading_halted_reason
730                .as_deref()
731                .unwrap_or("trading halted")
732        ));
733    } else if blocked_events_active {
734        result.skipped.push(format!(
735            "new entries paused — blocked_events active: {}",
736            rules.risk.blocked_events.join(", ")
737        ));
738    } else if blocked_dates_active {
739        result.skipped.push(format!(
740            "new entries paused — blocked_dates active: {}",
741            active_blocked_dates.join(", ")
742        ));
743    } else if regime_pause || preferred.eq_ignore_ascii_case("pause") {
744        result.skipped.push(format!(
745            "new entries paused — hostile/pause regime ({})",
746            regime_snap
747                .as_ref()
748                .map(|s| s.class.as_str())
749                .unwrap_or("pause")
750        ));
751    } else if !any_entry_slots_available(rules, state) {
752        result
753            .skipped
754            .push("entry scan skipped — all enabled accounts at max_open_positions".into());
755    } else {
756        let scan_watchlist = watchlist_for_scan(rules, state);
757        if let Some(sig) = &state.redeploy_signal {
758            if let Some(u) = &sig.underlying {
759                if redeploy_cooldown_active(rules, sig) {
760                    let remaining = rules
761                        .exit_rules
762                        .thesis
763                        .redeploy_cooldown_minutes
764                        .unwrap_or(0) as i64
765                        - Utc::now().signed_duration_since(sig.at).num_minutes();
766                    result.skipped.push(format!(
767                        "redeploy cooldown — skip {} for ~{}m after {}",
768                        u.to_uppercase(),
769                        remaining.max(0),
770                        sig.reason
771                    ));
772                }
773            }
774        }
775        let (want_vertical, want_condor, vertical_type) = if rules.regime.enabled {
776            let want_vertical = preferred.eq_ignore_ascii_case("put_credit")
777                || preferred.eq_ignore_ascii_case("call_credit");
778            let want_condor = preferred.eq_ignore_ascii_case("iron_condor");
779            let vertical_type = if preferred.eq_ignore_ascii_case("call_credit") {
780                "call_credit"
781            } else {
782                "put_credit"
783            };
784            (want_vertical, want_condor, vertical_type)
785        } else {
786            (
787                true,
788                true,
789                rules.entry_rules.vertical.r#type.as_str(),
790            )
791        };
792
793        for account in rules.enabled_accounts() {
794            match scan_entries_for_account(
795                market,
796                rules,
797                state,
798                &account.hash,
799                today,
800                &scan_watchlist,
801                want_vertical,
802                want_condor,
803                vertical_type,
804            )
805            .await
806            {
807                Ok(found) => {
808                    for skip in found.skipped {
809                        result.skipped.push(skip);
810                    }
811                    pending_entries.extend(found.entries);
812                }
813                Err(e) => result.skipped.push(format!("entry scan {}: {e:#}", account.hash)),
814            }
815        }
816        if rules.regime.enabled {
817            result.skipped.push(format!(
818                "regime={} preferred={} vix={}",
819                regime_snap
820                    .as_ref()
821                    .map(|s| s.class.as_str())
822                    .unwrap_or("?"),
823                preferred,
824                regime_snap
825                    .as_ref()
826                    .and_then(|s| s.vix)
827                    .map(|v| format!("{v:.1}"))
828                    .unwrap_or_else(|| "?".into())
829            ));
830        }
831    }
832
833    for (_, _, signal) in &pending_entries {
834        result.signals.push(signal.clone());
835    }
836
837    // LLM review — selection when candidates exist; monitor on schedule when positions open
838    let mut llm_veto_entries = false;
839    let mut llm_close_ids: Vec<String> = Vec::new();
840    let has_candidates = !pending_entries.is_empty();
841    let has_positions = !result.monitored_positions.is_empty();
842    let mechanical_alert =
843        mechanical_alert_from_monitored(&result.monitored_positions, rules.exit_rules.dte_close);
844
845    if let Some(client) = llm_client {
846        if let Some(phase) = resolve_llm_phase(
847            rules,
848            state,
849            has_candidates,
850            has_positions,
851            result.at_open,
852            mechanical_alert,
853        ) {
854            let use_web =
855                should_use_web_research(rules, state) && matches!(phase, LlmPhase::Selection);
856
857            let open_playbook_for_llm = match phase {
858                LlmPhase::Monitor if result.at_open => prior_open_playbook.as_ref(),
859                _ => None,
860            };
861
862            let context = json!({
863                "agent_id": rules.agent_id,
864                "tick": state.regular_tick_count,
865                "date": today.to_string(),
866                "phase": match phase {
867                    LlmPhase::Selection => "selection",
868                    LlmPhase::Monitor => "monitor",
869                    LlmPhase::OvernightDigest => "overnight_digest",
870                },
871                "at_open": result.at_open,
872                "mechanical_alert": mechanical_alert,
873                "market": market_context_summary_for_llm(),
874                "regime": state.last_regime.clone(),
875                "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
876                "open_positions": result.monitored_positions,
877                "open_playbook": open_playbook_for_llm,
878                "candidate_entries": pending_entries.iter().map(|(_, _, s)| s).collect::<Vec<_>>(),
879                "recent_signals": result.signals,
880                "watchlist": rules.watchlist_items(),
881                "entry_policy": {
882                    "mode": format!("{:?}", rules.entry_policy.mode),
883                    "fallback_only_after_primary_exhausted": rules.entry_policy.fallback_only_after_primary_exhausted,
884                },
885                "risk": {
886                    "max_trades_per_day": rules.risk.max_trades_per_day,
887                    "trades_today": state.trades_today,
888                    "max_risk_per_trade_usd": rules.risk.max_risk_per_trade_usd,
889                    "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
890                    "blocked_dates_active": active_blocked_dates,
891                    "blocked_events": rules.risk.blocked_events,
892                },
893            });
894
895            match client.review(&rules.llm, phase, &context, use_web).await {
896                Ok(review) => {
897                    let review_json = review.to_json();
898                    result.llm_review = Some(review_json.clone());
899                    state.last_llm_review_tick = Some(state.regular_tick_count);
900                    state.llm_review_count += 1;
901                    state.last_llm_summary = Some(review_json.clone());
902                    state.record_action("llm_review", review_json.clone());
903
904                    if rules.llm.veto_entries
905                        && matches!(phase, LlmPhase::Selection)
906                        && review.should_veto_entries()
907                    {
908                        llm_veto_entries = true;
909                        state.entry_proceed_cache = None;
910                        result
911                            .skipped
912                            .push(format!("LLM veto entries: {}", review.entry_reasoning));
913                    } else if matches!(phase, LlmPhase::Selection) {
914                        let fingerprints: Vec<String> = pending_entries
915                            .iter()
916                            .filter_map(|(_, _, s)| candidate_fingerprint(s))
917                            .collect();
918                        if review.entry_recommendation.eq_ignore_ascii_case("proceed") {
919                            state.entry_proceed_cache = Some(EntryProceedCache {
920                                at: Utc::now(),
921                                candidate_fingerprints: fingerprints,
922                            });
923                        } else {
924                            state.entry_proceed_cache = None;
925                        }
926                    }
927
928                    if rules.llm.allow_llm_exits {
929                        for pos in review.urgent_close_positions() {
930                            llm_close_ids.push(pos.position_id.clone());
931                        }
932                    }
933
934                    let notify = should_send_llm_telegram(
935                        &review,
936                        &rules.notify.telegram,
937                        state,
938                        Utc::now(),
939                    );
940                    if notify {
941                        notify_llm(
942                            telegram,
943                            &review,
944                            &result.monitored_positions,
945                            state,
946                        )
947                        .await;
948                    }
949                }
950                Err(e) => {
951                    result.skipped.push(format!("LLM review failed: {e:#}"));
952                    if rules.llm.veto_entries && matches!(phase, LlmPhase::Selection) {
953                        llm_veto_entries = true;
954                        state.entry_proceed_cache = None;
955                        result
956                            .skipped
957                            .push("LLM selection failed closed — entries deferred".into());
958                    }
959                }
960            }
961        }
962    } else if entry_execution_requires_llm(rules) && has_candidates {
963        llm_veto_entries = true;
964        result
965            .skipped
966            .push("LLM selection unavailable — entries deferred".into());
967    }
968
969    let mut entries_blocked = llm_veto_entries;
970    if !entries_blocked && has_candidates && entry_execution_requires_llm(rules) {
971        let fingerprints: Vec<String> = pending_entries
972            .iter()
973            .filter_map(|(_, _, s)| candidate_fingerprint(s))
974            .collect();
975        let cache_ok = state
976            .entry_proceed_cache
977            .as_ref()
978            .is_some_and(|cache| entry_proceed_cache_valid(&rules.entry_policy, cache, &fingerprints));
979        if !cache_ok {
980            entries_blocked = true;
981            result.skipped.push(
982                "entry deferred — awaiting LLM proceed for current candidate (see proceed_cache_minutes)"
983                    .into(),
984            );
985        }
986    }
987
988    // LLM-requested exits (high urgency only, when enabled)
989    if !llm_close_ids.is_empty() && !runtime.dry_run && !runtime.simulate {
990        for account in rules.enabled_accounts() {
991            let legs = list_option_positions(trader, Some(&account.hash)).await?;
992            let groups = group_option_legs(&legs);
993            for group in &groups {
994                let position_id = stable_position_key(&account.hash, group);
995                if !llm_close_ids
996                    .iter()
997                    .any(|id| id == &position_id || id == &group.id)
998                {
999                    continue;
1000                }
1001                if state.has_pending_position(&position_id) {
1002                    result
1003                        .skipped
1004                        .push(format!("LLM exit already pending for {position_id}"));
1005                    continue;
1006                }
1007                let exit = json!({
1008                    "type": "exit",
1009                    "reason": "llm_recommendation",
1010                    "position_id": position_id,
1011                    "legacy_position_id": group.id,
1012                    "underlying": group.underlying,
1013                    "expiry": group.expiry,
1014                });
1015                result.signals.push(exit.clone());
1016                if let Ok(action) =
1017                    execute_exit(runtime, trader, &account.hash, rules, group, &exit, state).await
1018                {
1019                    result.actions.push(action);
1020                    notify_action(telegram, "LLM EXIT", &exit).await;
1021                }
1022            }
1023        }
1024    }
1025
1026    // Execute pending entries unless LLM vetoed (dry-run never executes)
1027    if !entries_blocked && !runtime.dry_run {
1028        if runtime.simulate {
1029            for (account_hash, kind, signal) in pending_entries {
1030                match record_sim_entry(rules_path, state, rules, &account_hash, kind, &signal) {
1031                    Ok(detail) => {
1032                        if detail
1033                            .get("fill_status")
1034                            .and_then(|v| v.as_str())
1035                            == Some("FILLED")
1036                        {
1037                            result.actions.push(detail.clone());
1038                            notify_action(telegram, "SIM ENTRY", &detail).await;
1039                        } else {
1040                            result.skipped.push(format!(
1041                                "sim entry skipped: {}",
1042                                detail
1043                                    .get("reason")
1044                                    .and_then(|v| v.as_str())
1045                                    .unwrap_or("unknown")
1046                            ));
1047                        }
1048                    }
1049                    Err(err) => result.skipped.push(format!("sim entry failed: {err:#}")),
1050                }
1051            }
1052        } else {
1053            for (account_hash, kind, signal) in pending_entries {
1054                if let Ok(Some(a)) =
1055                    maybe_execute_entry(runtime, trader, &account_hash, kind, &signal, rules, state)
1056                        .await
1057                {
1058                    result.actions.push(a.clone());
1059                    let label = a
1060                        .pointer("/fill_status")
1061                        .and_then(|v| v.as_str())
1062                        .unwrap_or("UNKNOWN");
1063                    match label {
1064                        "FILLED" => notify_action(telegram, "ENTRY FILLED", &a).await,
1065                        "WORKING" | "ACCEPTED" | "PENDING_ACTIVATION" | "QUEUED" => {
1066                            notify_action(telegram, "ORDER WORKING (limit)", &a).await
1067                        }
1068                        other if is_failure_status(other) => {
1069                            notify_action(telegram, "ORDER REJECTED", &a).await
1070                        }
1071                        _ => notify_action(telegram, "ORDER", &a).await,
1072                    }
1073                }
1074            }
1075        }
1076    }
1077
1078    Ok(result)
1079}
1080
1081fn should_run_llm_review(rules: &RulesConfig, state: &AgentState, has_positions: bool) -> bool {
1082    let every = rules.llm.effective_llm_review_ticks(
1083        has_positions,
1084        min_open_position_dte(state),
1085        rules.exit_rules.dte_close,
1086    );
1087    schedule::should_run_monitor_review(
1088        state.regular_tick_count,
1089        state.last_llm_review_tick,
1090        every,
1091    )
1092}
1093
1094fn min_open_position_dte(state: &AgentState) -> Option<i64> {
1095    let today = Local::now().date_naive();
1096    state
1097        .open_positions
1098        .values()
1099        .filter_map(|p| {
1100            chrono::NaiveDate::parse_from_str(&p.expiry, "%Y-%m-%d")
1101                .ok()
1102                .map(|exp| days_to_expiry(exp, today))
1103        })
1104        .min()
1105}
1106
1107async fn tick_overnight(
1108    _runtime: &RuntimeConfig,
1109    rules: &RulesConfig,
1110    state: &mut AgentState,
1111    llm_client: Option<&OpenRouterClient>,
1112    telegram: Option<&TelegramNotifier>,
1113    result: &mut TickResult,
1114) -> Result<TickResult> {
1115    let today = Local::now().date_naive();
1116    result.monitored_positions = overnight_position_snapshots(state);
1117
1118    if result.monitored_positions.is_empty() {
1119        result.skipped.push("overnight — no open positions".into());
1120    } else {
1121        result.skipped.push(format!(
1122            "overnight — monitoring {} open position(s) (no live marks)",
1123            result.monitored_positions.len()
1124        ));
1125    }
1126
1127    let digest_due =
1128        schedule::should_run_overnight_digest(state, &rules.schedule.overnight, Utc::now());
1129
1130    if !digest_due {
1131        result.skipped.push(format!(
1132            "overnight digest next in ~{} min",
1133            rules.schedule.overnight.tick_interval_seconds / 60
1134        ));
1135        return Ok(result.clone());
1136    }
1137
1138    if !rules.llm.enabled {
1139        result
1140            .skipped
1141            .push("overnight digest skipped (llm.enabled false)".into());
1142        return Ok(result.clone());
1143    }
1144
1145    let Some(client) = llm_client else {
1146        result
1147            .skipped
1148            .push("overnight digest skipped (no LLM client)".into());
1149        return Ok(result.clone());
1150    };
1151
1152    let context = json!({
1153        "agent_id": rules.agent_id,
1154        "date": today.to_string(),
1155        "phase": "overnight_digest",
1156        "market_closed": true,
1157        "open_positions": result.monitored_positions,
1158        "prior_open_playbook": state.open_playbook,
1159        "watchlist": rules.watchlist_items(),
1160        "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
1161        "note": "Build open playbook for next session. No chain data. new_entries must be skip.",
1162    });
1163
1164    match client
1165        .review(&rules.llm, LlmPhase::OvernightDigest, &context, true)
1166        .await
1167    {
1168        Ok(review) => {
1169            let review_json = review.to_json();
1170            result.llm_review = Some(review_json.clone());
1171            state.last_overnight_digest_at = Some(Utc::now());
1172            state.open_playbook = Some(json!({
1173                "updated_at": Utc::now(),
1174                "market_commentary": review.market_commentary,
1175                "positions": review.position_reviews,
1176                "risk_alerts": review.risk_alerts,
1177                "open_actions": review.entry_reasoning,
1178            }));
1179            state.last_llm_summary = Some(review_json.clone());
1180            state.record_action("overnight_digest", review_json);
1181
1182            let should_notify = if rules.schedule.overnight.alert_on_risk_only {
1183                !review.risk_alerts.is_empty() || is_llm_urgent_for_overnight(&review)
1184            } else {
1185                should_send_llm_telegram(&review, &rules.notify.telegram, state, Utc::now())
1186            };
1187            if should_notify {
1188                notify_overnight_alert(telegram, &review, state).await;
1189            }
1190        }
1191        Err(e) => result
1192            .skipped
1193            .push(format!("overnight digest failed: {e:#}")),
1194    }
1195
1196    Ok(result.clone())
1197}
1198
1199fn overnight_position_snapshots(state: &AgentState) -> Vec<Value> {
1200    state
1201        .open_positions
1202        .values()
1203        .map(|p| {
1204            json!({
1205                "position_id": p.position_id,
1206                "underlying": p.underlying,
1207                "expiry": p.expiry,
1208                "strategy": p.strategy,
1209                "contracts": p.contracts.max(1),
1210                "entry_credit": p.entry_credit,
1211                "max_loss_usd": p.max_loss_usd,
1212                "status": "overnight (reconciled, no live marks)",
1213            })
1214        })
1215        .collect()
1216}
1217
1218async fn check_auth_reminder(state: &mut AgentState, telegram: Option<&TelegramNotifier>) {
1219    let Ok(config) = schwab_api::ClientConfig::from_env() else {
1220        return;
1221    };
1222    let oauth = schwab_api::OAuthClient::new(config);
1223    let Ok(Some(tokens)) = oauth.status().await else {
1224        return;
1225    };
1226    let reminder = assess_refresh_token(&tokens);
1227    maybe_notify_auth_reminder(telegram, state, &reminder).await;
1228}
1229
1230async fn poll_pending_orders(
1231    trader: &Arc<TraderApi>,
1232    state: &mut AgentState,
1233    rules: &RulesConfig,
1234    result: &mut TickResult,
1235) -> Result<()> {
1236    let pending = state.pending_orders.clone();
1237    for pending_order in pending {
1238        let order = match trader
1239            .orders()
1240            .get(&pending_order.account_hash, &pending_order.order_id)
1241            .await
1242        {
1243            Ok(order) => order,
1244            Err(e) => {
1245                result.skipped.push(format!(
1246                    "pending order {} status unavailable: {e:#}",
1247                    pending_order.order_id
1248                ));
1249                continue;
1250            }
1251        };
1252        let status = order_status(&order).unwrap_or_else(|| "UNKNOWN".into());
1253        if let Some(stored) = state
1254            .pending_orders
1255            .iter_mut()
1256            .find(|p| p.order_id == pending_order.order_id)
1257        {
1258            stored.last_status = Some(status.clone());
1259        }
1260
1261        match pending_order.action {
1262            PendingOrderAction::Entry => {
1263                if status == "FILLED" {
1264                    state.remove_pending_order(&pending_order.order_id);
1265                    if let Some(detail) = pending_order.detail.as_ref() {
1266                        track_filled_entry_from_pending(state, detail, &pending_order);
1267                    }
1268                    state.trades_today = state.trades_today.saturating_add(1);
1269                    state.record_action(
1270                        "entry_filled",
1271                        json!({
1272                            "order_id": pending_order.order_id,
1273                            "position_id": pending_order.position_id,
1274                            "status": status,
1275                        }),
1276                    );
1277                    trade_audio::speak(TradeAudioEvent::EntryOpened);
1278                } else if is_failure_status(&status) || is_terminal_status(&status) {
1279                    state.remove_pending_order(&pending_order.order_id);
1280                    state.record_action(
1281                        "entry_terminal",
1282                        json!({
1283                            "order_id": pending_order.order_id,
1284                            "position_id": pending_order.position_id,
1285                            "status": status,
1286                        }),
1287                    );
1288                } else if pending_is_stale(&pending_order, rules) {
1289                    match trader
1290                        .orders()
1291                        .cancel(&pending_order.account_hash, &pending_order.order_id)
1292                        .await
1293                    {
1294                        Ok(cancel) => {
1295                            state.remove_pending_order(&pending_order.order_id);
1296                            state.record_action(
1297                                "entry_cancelled_stale",
1298                                json!({
1299                                    "order_id": pending_order.order_id,
1300                                    "position_id": pending_order.position_id,
1301                                    "status": status,
1302                                    "cancel": {
1303                                        "status": cancel.status,
1304                                        "location": cancel.location,
1305                                    },
1306                                }),
1307                            );
1308                            trade_audio::speak(TradeAudioEvent::EntryCancelled);
1309                        }
1310                        Err(e) => result.skipped.push(format!(
1311                            "stale entry order {} cancel failed: {e:#}",
1312                            pending_order.order_id
1313                        )),
1314                    }
1315                }
1316            }
1317            PendingOrderAction::Exit => {
1318                if status == "FILLED" {
1319                    state.remove_pending_order(&pending_order.order_id);
1320                    if let Some(tracked) = state
1321                        .open_positions
1322                        .get(&pending_order.position_id)
1323                        .cloned()
1324                    {
1325                        let debit = pending_order
1326                            .detail
1327                            .as_ref()
1328                            .and_then(|d| d.pointer("/signal/mark/debit_to_close"))
1329                            .or_else(|| {
1330                                pending_order
1331                                    .detail
1332                                    .as_ref()
1333                                    .and_then(|d| d.get("limit_price"))
1334                            })
1335                            .and_then(|v| v.as_f64())
1336                            .unwrap_or(0.0);
1337                        record_live_realized_pnl(state, &tracked, debit);
1338                    }
1339                    state.open_positions.remove(&pending_order.position_id);
1340                    state.record_action(
1341                        "exit_filled",
1342                        json!({
1343                            "order_id": pending_order.order_id,
1344                            "position_id": pending_order.position_id,
1345                            "status": status,
1346                        }),
1347                    );
1348                    let reason = pending_order
1349                        .detail
1350                        .as_ref()
1351                        .and_then(|d| d.pointer("/signal/reason"))
1352                        .and_then(|v| v.as_str())
1353                        .unwrap_or("");
1354                    trade_audio::speak_exit_reason(reason);
1355                } else if is_failure_status(&status) || is_terminal_status(&status) {
1356                    state.remove_pending_order(&pending_order.order_id);
1357                    state.record_action(
1358                        "exit_terminal_position_kept",
1359                        json!({
1360                            "order_id": pending_order.order_id,
1361                            "position_id": pending_order.position_id,
1362                            "status": status,
1363                        }),
1364                    );
1365                }
1366            }
1367        }
1368    }
1369    state.clear_legacy_pending_ids();
1370    Ok(())
1371}
1372
1373fn pending_is_stale(pending: &PendingOrder, rules: &RulesConfig) -> bool {
1374    let timeout = rules.execution.fill_timeout_seconds.max(1) as i64;
1375    (Utc::now() - pending.submitted_at).num_seconds() >= timeout
1376}
1377
1378fn track_filled_entry_from_pending(state: &mut AgentState, detail: &Value, pending: &PendingOrder) {
1379    let Some(signal) = detail.get("signal") else {
1380        return;
1381    };
1382    let Some(params) = signal.get("params") else {
1383        return;
1384    };
1385    let underlying = params
1386        .get("underlying")
1387        .and_then(|v| v.as_str())
1388        .unwrap_or("")
1389        .to_string();
1390    let expiry = params
1391        .get("expiry")
1392        .and_then(|v| v.as_str())
1393        .unwrap_or("")
1394        .to_string();
1395    let strategy = signal
1396        .get("strategy")
1397        .and_then(|v| v.as_str())
1398        .unwrap_or("vertical")
1399        .to_string();
1400    let contracts = params
1401        .get("contracts")
1402        .and_then(|v| v.as_f64())
1403        .unwrap_or(1.0)
1404        .round()
1405        .max(1.0) as u32;
1406    let entry_credit = signal.get("estimated_credit").and_then(|v| v.as_f64());
1407
1408    state
1409        .open_positions
1410        .entry(pending.position_id.clone())
1411        .or_insert_with(|| TrackedPosition {
1412            position_id: pending.position_id.clone(),
1413            account_hash: pending.account_hash.clone(),
1414            underlying,
1415            expiry,
1416            strategy,
1417            opened_at: Utc::now(),
1418            entry_credit,
1419            max_loss_usd: pending.reserved_risk_usd,
1420            contracts,
1421            entry_params: None,
1422            peak_profit_pct: None,
1423            entry_pop_pct: signal
1424                .pointer("/market_context/spread_pop_pct")
1425                .and_then(|v| v.as_f64()),
1426            entry_short_delta: signal
1427                .pointer("/market_context/short_delta")
1428                .and_then(|v| v.as_f64())
1429                .map(f64::abs),
1430            ..Default::default()
1431        });
1432}
1433
1434fn watchlist_for_scan(rules: &RulesConfig, state: &AgentState) -> Vec<WatchlistItemConfig> {
1435    let mut items = rules.watchlist_items();
1436    if let Some(sig) = &state.redeploy_signal {
1437        if let Some(u) = &sig.underlying {
1438            let key = u.to_uppercase();
1439            if redeploy_cooldown_active(rules, sig) {
1440                items.retain(|i| !i.symbol.eq_ignore_ascii_case(&key));
1441            } else if rules.entry_policy.promote_redeploy_symbol {
1442                if let Some(pos) = items.iter().position(|i| i.symbol.eq_ignore_ascii_case(&key)) {
1443                    let item = items.remove(pos);
1444                    items.insert(0, item);
1445                }
1446            }
1447        }
1448    }
1449    items
1450}
1451
1452struct ScanEntriesResult {
1453    entries: Vec<(String, StrategyKind, Value)>,
1454    skipped: Vec<String>,
1455}
1456
1457async fn scan_entries_for_account(
1458    market: &MarketDataApi,
1459    rules: &RulesConfig,
1460    state: &AgentState,
1461    account_hash: &str,
1462    today: NaiveDate,
1463    scan_watchlist: &[WatchlistItemConfig],
1464    want_vertical: bool,
1465    want_condor: bool,
1466    vertical_type: &str,
1467) -> Result<ScanEntriesResult> {
1468    let mut result = ScanEntriesResult {
1469        entries: Vec::new(),
1470        skipped: Vec::new(),
1471    };
1472    let policy = &rules.entry_policy;
1473
1474    let mut primary_items: Vec<&WatchlistItemConfig> = Vec::new();
1475    let mut fallback_items: Vec<&WatchlistItemConfig> = Vec::new();
1476    for item in scan_watchlist {
1477        match item.role {
1478            WatchlistRole::Primary => primary_items.push(item),
1479            WatchlistRole::Fallback => fallback_items.push(item),
1480        }
1481    }
1482
1483    scan_watchlist_tier(
1484        market,
1485        rules,
1486        state,
1487        account_hash,
1488        today,
1489        &primary_items,
1490        want_vertical,
1491        want_condor,
1492        vertical_type,
1493        &mut result,
1494    )
1495    .await?;
1496
1497    let need_fallback = result.entries.is_empty()
1498        && policy.fallback_only_after_primary_exhausted
1499        && !fallback_items.is_empty();
1500    let scan_all_fallbacks = !policy.fallback_only_after_primary_exhausted;
1501
1502    if need_fallback || scan_all_fallbacks {
1503        scan_watchlist_tier(
1504            market,
1505            rules,
1506            state,
1507            account_hash,
1508            today,
1509            &fallback_items,
1510            want_vertical,
1511            want_condor,
1512            vertical_type,
1513            &mut result,
1514        )
1515        .await?;
1516    }
1517
1518    if policy.mode == EntryScanMode::FirstQualifying && result.entries.len() > 1 {
1519        result.entries.truncate(1);
1520    }
1521
1522    Ok(result)
1523}
1524
1525async fn scan_watchlist_tier(
1526    market: &MarketDataApi,
1527    rules: &RulesConfig,
1528    state: &AgentState,
1529    account_hash: &str,
1530    today: NaiveDate,
1531    items: &[&WatchlistItemConfig],
1532    want_vertical: bool,
1533    want_condor: bool,
1534    vertical_type: &str,
1535    result: &mut ScanEntriesResult,
1536) -> Result<()> {
1537    let policy = &rules.entry_policy;
1538
1539    for item in items {
1540        if policy.mode == EntryScanMode::FirstQualifying && !result.entries.is_empty() {
1541            break;
1542        }
1543
1544        let sym = item.symbol.to_uppercase();
1545        if !rules.risk.allowed_underlyings.is_empty()
1546            && !rules
1547                .risk
1548                .allowed_underlyings
1549                .iter()
1550                .any(|u| u.eq_ignore_ascii_case(&sym))
1551        {
1552            continue;
1553        }
1554        if underlying_entry_cap_reached(rules, state, account_hash, &sym) {
1555            continue;
1556        }
1557        if let Some(group_name) = correlation_group_cap_reached(rules, state, account_hash, &sym) {
1558            result.skipped.push(format!(
1559                "correlation group cap — skip {sym} (group {group_name})"
1560            ));
1561            continue;
1562        }
1563        if let Some(days) = stop_loss_re_entry_blocked(rules, state, &sym) {
1564            result.skipped.push(format!(
1565                "stop-loss re-entry cooldown — skip {sym} for ~{days}d"
1566            ));
1567            continue;
1568        }
1569
1570        let vertical_rules = rules.effective_vertical_entry(&sym);
1571
1572        if rules.strategies.vertical.enabled && want_vertical {
1573            match evaluate_vertical_entry(
1574                market,
1575                rules,
1576                &vertical_rules,
1577                &sym,
1578                today,
1579                state,
1580                account_hash,
1581                vertical_type,
1582            )
1583            .await
1584            {
1585                Ok(Some(signal)) => {
1586                    result.entries.push((
1587                        account_hash.to_string(),
1588                        StrategyKind::Vertical,
1589                        signal,
1590                    ));
1591                    if policy.mode == EntryScanMode::FirstQualifying {
1592                        break;
1593                    }
1594                }
1595                Ok(None) => {}
1596                Err(e) => result.skipped.push(format!("{sym} vertical: {e:#}")),
1597            }
1598        }
1599
1600        if policy.mode == EntryScanMode::FirstQualifying && !result.entries.is_empty() {
1601            break;
1602        }
1603
1604        if rules.strategies.iron_condor.enabled && want_condor {
1605            match evaluate_condor_entry(market, rules, &sym, today, state, account_hash).await {
1606                Ok(Some(signal)) => {
1607                    result.entries.push((
1608                        account_hash.to_string(),
1609                        StrategyKind::IronCondor,
1610                        signal,
1611                    ));
1612                    if policy.mode == EntryScanMode::FirstQualifying {
1613                        break;
1614                    }
1615                }
1616                Ok(None) => {}
1617                Err(e) => result.skipped.push(format!("{sym} iron_condor: {e:#}")),
1618            }
1619        }
1620    }
1621
1622    Ok(())
1623}
1624
1625fn entry_execution_requires_llm(rules: &RulesConfig) -> bool {
1626    rules.llm.enabled && rules.llm.veto_entries && rules.entry_policy.require_llm_proceed
1627}
1628
1629fn redeploy_cooldown_active(rules: &RulesConfig, sig: &RedeploySignal) -> bool {
1630    let cooldown = rules
1631        .exit_rules
1632        .thesis
1633        .redeploy_cooldown_minutes
1634        .unwrap_or(0);
1635    if cooldown == 0 {
1636        return false;
1637    }
1638    Utc::now().signed_duration_since(sig.at).num_minutes() < cooldown as i64
1639}
1640
1641fn underlying_entry_cap_reached(
1642    rules: &RulesConfig,
1643    state: &AgentState,
1644    account_hash: &str,
1645    underlying: &str,
1646) -> bool {
1647    let Some(cap) = rules.risk.max_open_for_underlying(underlying) else {
1648        return false;
1649    };
1650    let open = state.count_open_for_underlying(account_hash, underlying);
1651    let pending = state.pending_entry_count_for_underlying(account_hash, underlying);
1652    open + pending >= cap
1653}
1654
1655/// Returns the group name when opening `underlying` would exceed that group's `max_open`.
1656fn correlation_group_cap_reached(
1657    rules: &RulesConfig,
1658    state: &AgentState,
1659    account_hash: &str,
1660    underlying: &str,
1661) -> Option<String> {
1662    let group = rules.risk.correlation_group_for(underlying)?;
1663    if group.max_open == 0 {
1664        return None;
1665    }
1666    let open = state.count_open_in_symbol_set(account_hash, &group.symbols);
1667    let pending = state.pending_entry_count_in_symbol_set(account_hash, &group.symbols);
1668    if open + pending >= group.max_open {
1669        Some(group.name.clone())
1670    } else {
1671        None
1672    }
1673}
1674
1675fn maybe_clear_stale_redeploy(state: &mut AgentState) {
1676    let Some(sig) = &state.redeploy_signal else {
1677        return;
1678    };
1679    if Utc::now().signed_duration_since(sig.at).num_hours() >= 4 {
1680        state.redeploy_signal = None;
1681    }
1682}
1683
1684fn clear_redeploy_after_entry(state: &mut AgentState) {
1685    state.redeploy_signal = None;
1686}
1687
1688async fn notify_trading_halted(
1689    telegram: Option<&TelegramNotifier>,
1690    rules: &RulesConfig,
1691    reason: &str,
1692) {
1693    let Some(tg) = telegram else { return };
1694    if !tg.wants_actions() {
1695        return;
1696    }
1697    let _ = tg
1698        .send(&format!(
1699            "schwab [{}]\n⚠ TRADING HALTED\n{reason}\nExits still armed.",
1700            rules.agent_id
1701        ))
1702        .await;
1703}
1704
1705async fn notify_trading_recovered(telegram: Option<&TelegramNotifier>, rules: &RulesConfig) {
1706    let Some(tg) = telegram else { return };
1707    if !tg.wants_actions() {
1708        return;
1709    }
1710    let _ = tg
1711        .send(&format!(
1712            "schwab [{}]\n✓ TRADING RESUMED\nDrawdown halt cleared — new entries allowed.",
1713            rules.agent_id
1714        ))
1715        .await;
1716}
1717
1718async fn notify_at_open(
1719    telegram: Option<&TelegramNotifier>,
1720    playbook: Option<&Value>,
1721    open_position_count: usize,
1722) {
1723    let Some(tg) = telegram else { return };
1724    if !tg.wants_actions() {
1725        return;
1726    }
1727    let body = format_market_open_telegram(playbook, open_position_count);
1728    let _ = tg.send(&body).await;
1729}
1730
1731async fn notify_overnight_alert(
1732    telegram: Option<&TelegramNotifier>,
1733    review: &super::llm::LlmReview,
1734    state: &mut AgentState,
1735) {
1736    let Some(tg) = telegram else { return };
1737    if !tg.wants_actions() {
1738        return;
1739    }
1740    let now = Utc::now();
1741    let _ = tg
1742        .send(&format_overnight_telegram(review))
1743        .await;
1744    record_llm_telegram_sent(state, review, now);
1745}
1746
1747fn is_llm_urgent_for_overnight(review: &super::llm::LlmReview) -> bool {
1748    super::telegram_format::is_llm_urgent(review)
1749        || review
1750            .position_reviews
1751            .iter()
1752            .any(|p| p.urgency.eq_ignore_ascii_case("high"))
1753}
1754
1755async fn fetch_option_market_status(market: &MarketDataApi) -> Result<(bool, Option<Value>)> {
1756    let hours = market.markets().hours("option", None).await?;
1757    let open = crate::market_hours::option_market_open_from_hours(&hours, chrono::Utc::now())
1758        .unwrap_or(false);
1759    Ok((open, Some(hours)))
1760}
1761
1762fn resolve_llm_phase(
1763    rules: &RulesConfig,
1764    state: &AgentState,
1765    has_candidates: bool,
1766    has_positions: bool,
1767    at_open: bool,
1768    mechanical_alert: bool,
1769) -> Option<LlmPhase> {
1770    if !rules.llm.enabled {
1771        return None;
1772    }
1773    if !has_candidates && !has_positions {
1774        return None;
1775    }
1776    if at_open && !has_candidates && !mechanical_alert {
1777        return None;
1778    }
1779    if !should_run_llm_review(rules, state, has_positions) {
1780        return None;
1781    }
1782    if has_candidates {
1783        return Some(LlmPhase::Selection);
1784    }
1785    Some(LlmPhase::Monitor)
1786}
1787
1788/// True when live marks show a mechanical exit threshold is near or breached.
1789fn mechanical_alert_from_monitored(monitored_positions: &[Value], dte_close: u32) -> bool {
1790    monitored_positions.iter().any(|pos| {
1791        if let Some(rules) = pos.get("mechanical_rules") {
1792            if rules
1793                .get("stop_triggered")
1794                .and_then(|v| v.as_bool())
1795                .unwrap_or(false)
1796            {
1797                return true;
1798            }
1799            if rules
1800                .get("profit_target_triggered")
1801                .and_then(|v| v.as_bool())
1802                .unwrap_or(false)
1803            {
1804                return true;
1805            }
1806        }
1807        pos.get("dte")
1808            .and_then(|v| v.as_i64())
1809            .is_some_and(|dte| dte <= dte_close as i64)
1810    })
1811}
1812
1813fn any_entry_slots_available(rules: &RulesConfig, state: &AgentState) -> bool {
1814    let pending = state.pending_entry_count();
1815    for account in rules.enabled_accounts() {
1816        if rules.strategies.vertical.enabled
1817            && state.count_open_for_strategy(&account.hash, StrategyKind::Vertical) + pending
1818                < rules.entry_rules.vertical.max_open_positions
1819        {
1820            return true;
1821        }
1822        if rules.strategies.iron_condor.enabled
1823            && state.count_open_for_strategy(&account.hash, StrategyKind::IronCondor) + pending
1824                < rules.entry_rules.iron_condor.max_open_positions
1825        {
1826            return true;
1827        }
1828    }
1829    false
1830}
1831
1832/// Every Nth LLM review uses web_model during selection phase.
1833fn should_use_web_research(rules: &RulesConfig, state: &AgentState) -> bool {
1834    if rules.llm.web_research_every_reviews == 0 {
1835        return false;
1836    }
1837    let next_review = state.llm_review_count + 1;
1838    next_review % rules.llm.web_research_every_reviews.max(1) == 0
1839}
1840
1841async fn notify_tick(
1842    telegram: Option<&TelegramNotifier>,
1843    rules: &RulesConfig,
1844    result: &TickResult,
1845    dry_run: bool,
1846) {
1847    let Some(tg) = telegram else { return };
1848    if !tg.wants_tick_summary() {
1849        return;
1850    }
1851    let prefix = if dry_run { "[DRY RUN] " } else { "" };
1852    let msg = format!(
1853        "{prefix}Agent `{}` tick\nsignals: {}\nactions: {}\nskipped: {}",
1854        rules.agent_id,
1855        result.signals.len(),
1856        result.actions.len(),
1857        result.skipped.len()
1858    );
1859    let _ = tg.send(&msg).await;
1860}
1861
1862async fn notify_action(telegram: Option<&TelegramNotifier>, kind: &str, detail: &Value) {
1863    trade_audio::speak_from_action(kind, detail);
1864    let Some(tg) = telegram else { return };
1865    if !tg.wants_actions() {
1866        return;
1867    }
1868    let Some(msg) = format_action_telegram(kind, detail) else {
1869        return;
1870    };
1871    let _ = tg.send(&msg).await;
1872}
1873
1874async fn notify_llm(
1875    telegram: Option<&TelegramNotifier>,
1876    review: &super::llm::LlmReview,
1877    monitored: &[Value],
1878    state: &mut AgentState,
1879) {
1880    let Some(tg) = telegram else { return };
1881    if !tg.wants_actions() {
1882        return;
1883    }
1884    let now = Utc::now();
1885    let _ = tg
1886        .send(&format_llm_review_telegram(review, monitored))
1887        .await;
1888    record_llm_telegram_sent(state, review, now);
1889}
1890
1891async fn evaluate_vertical_entry(
1892    market: &MarketDataApi,
1893    rules: &RulesConfig,
1894    entry: &VerticalEntryRules,
1895    underlying: &str,
1896    today: NaiveDate,
1897    state: &AgentState,
1898    account_hash: &str,
1899    spread_type: &str,
1900) -> Result<Option<Value>> {
1901    let open_count = state.count_open_for_strategy(account_hash, StrategyKind::Vertical);
1902    if open_count + state.pending_entry_count() >= entry.max_open_positions {
1903        return Ok(None);
1904    }
1905
1906    let is_put = !spread_type.eq_ignore_ascii_case("call_credit");
1907    let contract_type = if is_put { "PUT" } else { "CALL" };
1908    let map_key = if is_put {
1909        "putExpDateMap"
1910    } else {
1911        "callExpDateMap"
1912    };
1913
1914    let chain = market
1915        .chains()
1916        .get(&ChainQuery {
1917            symbol: underlying,
1918            contract_type: Some(contract_type),
1919            strike_count: Some(120),
1920            include_underlying_quote: Some(true),
1921            ..Default::default()
1922        })
1923        .await?;
1924
1925    let (expiry, strike_map) =
1926        pick_expiry_map(&chain, map_key, entry.dte_min, entry.dte_max, today)?;
1927    let underlying_price = chain
1928        .pointer("/underlying/last")
1929        .or_else(|| chain.pointer("/underlyingPrice"))
1930        .and_then(|v| v.as_f64())
1931        .unwrap_or(0.0);
1932
1933    if underlying_price <= 0.0 {
1934        return Ok(None);
1935    }
1936
1937    let short_strike = pick_strike_by_delta(
1938        &strike_map,
1939        entry.short_delta_min,
1940        entry.short_delta_max,
1941        is_put,
1942    )
1943    .or_else(|| pick_otm_strike(&strike_map, underlying_price, 0.05, is_put).ok())
1944    .context("no suitable short strike")?;
1945    let long_strike = pick_wing_strike(&strike_map, short_strike, entry.max_width, is_put)?;
1946    let width = (short_strike - long_strike).abs();
1947    if width < entry.max_width * 0.5 {
1948        return Ok(None);
1949    }
1950    let credit = estimate_spread_credit(&strike_map, short_strike, long_strike)?;
1951    if credit < entry.min_credit {
1952        return Ok(None);
1953    }
1954    if !entry_quality_ok(&strike_map, short_strike, long_strike, width, credit, entry) {
1955        return Ok(None);
1956    }
1957
1958    let lookback = rules.regime.realized_vol_lookback.max(5);
1959    let realized_vol_pct = fetch_realized_vol_pct(market, underlying, lookback)
1960        .await
1961        .ok()
1962        .flatten();
1963
1964    let market_context = vertical_entry_market_context(
1965        &chain,
1966        underlying,
1967        expiry,
1968        today,
1969        &strike_map,
1970        short_strike,
1971        long_strike,
1972        width,
1973        credit,
1974        entry.max_contracts_per_trade as f64,
1975        is_put,
1976        realized_vol_pct,
1977    );
1978
1979    let analytics = analytics_from_json(market_context.get("analytics").unwrap_or(&json!({})));
1980    if let Some(ref a) = analytics {
1981        if !entry_analytics_pass(entry, a) {
1982            return Ok(None);
1983        }
1984        if candidate_fails_thesis_gates(rules, a).is_some() {
1985            return Ok(None);
1986        }
1987    } else if entry.reject_short_inside_1sigma || entry.min_iv_rv_ratio.is_some() {
1988        // Analytics unavailable but gates require it — fail closed.
1989        return Ok(None);
1990    }
1991
1992    let right = if is_put { 'P' } else { 'C' };
1993    let candidate_id = candidate_position_id(
1994        account_hash,
1995        underlying,
1996        &expiry.to_string(),
1997        StrategyKind::Vertical.as_str(),
1998        vec![
1999            (right, short_strike, "S"),
2000            (right, long_strike, "L"),
2001        ],
2002    );
2003    if state.open_positions.contains_key(&candidate_id)
2004        || state.has_pending_position(&candidate_id)
2005        || has_legacy_duplicate(state, account_hash, underlying, &expiry.to_string())
2006    {
2007        return Ok(None);
2008    }
2009
2010    let resolved_type = if is_put { "put_credit" } else { "call_credit" };
2011    let params = VerticalParams {
2012        underlying: underlying.to_string(),
2013        expiry: expiry.to_string(),
2014        spread_type: resolved_type.to_string(),
2015        short_strike,
2016        long_strike,
2017        contracts: entry.max_contracts_per_trade as f64,
2018        limit_credit: Some(credit),
2019        limit_debit: None,
2020        duration: None,
2021        session: None,
2022    };
2023
2024    Ok(Some(json!({
2025        "type": "entry",
2026        "strategy": "vertical",
2027        "account_hash": account_hash,
2028        "position_id": candidate_id,
2029        "params": params,
2030        "estimated_credit": credit,
2031        "market_context": market_context,
2032        "regime_preferred": resolved_type,
2033    })))
2034}
2035
2036async fn evaluate_condor_entry(
2037    market: &MarketDataApi,
2038    rules: &RulesConfig,
2039    underlying: &str,
2040    today: NaiveDate,
2041    state: &AgentState,
2042    account_hash: &str,
2043) -> Result<Option<Value>> {
2044    let entry = &rules.entry_rules.iron_condor;
2045    let open_count = state.count_open_for_strategy(account_hash, StrategyKind::IronCondor);
2046    if open_count + state.pending_entry_count() >= entry.max_open_positions {
2047        return Ok(None);
2048    }
2049
2050    let chain = market
2051        .chains()
2052        .get(&ChainQuery {
2053            symbol: underlying,
2054            contract_type: Some("ALL"),
2055            strike_count: Some(40),
2056            include_underlying_quote: Some(true),
2057            ..Default::default()
2058        })
2059        .await?;
2060
2061    let underlying_price = chain
2062        .pointer("/underlying/last")
2063        .or_else(|| chain.pointer("/underlyingPrice"))
2064        .and_then(|v| v.as_f64())
2065        .unwrap_or(0.0);
2066    if underlying_price <= 0.0 {
2067        return Ok(None);
2068    }
2069
2070    let (expiry, put_map) =
2071        pick_expiry_map(&chain, "putExpDateMap", entry.dte_min, entry.dte_max, today)?;
2072    let (_, call_map) = pick_expiry_map(
2073        &chain,
2074        "callExpDateMap",
2075        entry.dte_min,
2076        entry.dte_max,
2077        today,
2078    )?;
2079
2080    let put_short = pick_otm_strike(&put_map, underlying_price, entry.short_delta, true)?;
2081    let put_long = put_short - entry.wing_width;
2082    let call_short = pick_otm_strike(&call_map, underlying_price, entry.short_delta, false)?;
2083    let call_long = call_short + entry.wing_width;
2084
2085    let put_credit = estimate_spread_credit(&put_map, put_short, put_long)?;
2086    let call_credit = estimate_spread_credit(&call_map, call_short, call_long)?;
2087    let total_credit = put_credit + call_credit;
2088    if total_credit < entry.min_credit {
2089        return Ok(None);
2090    }
2091
2092    if entry.min_iv_rv_ratio.is_some() {
2093        let lookback = rules.regime.realized_vol_lookback.max(5);
2094        let realized_vol_pct = fetch_realized_vol_pct(market, underlying, lookback)
2095            .await
2096            .ok()
2097            .flatten();
2098        let chain_iv = chain.get("volatility").and_then(|v| v.as_f64());
2099        let ratio = iv_rv_ratio(chain_iv, realized_vol_pct);
2100        if !passes_min_iv_rv_ratio(entry.min_iv_rv_ratio, ratio) {
2101            return Ok(None);
2102        }
2103    }
2104
2105    let candidate_id = candidate_position_id(
2106        account_hash,
2107        underlying,
2108        &expiry.to_string(),
2109        StrategyKind::IronCondor.as_str(),
2110        vec![
2111            ('P', put_short, "S"),
2112            ('P', put_long, "L"),
2113            ('C', call_short, "S"),
2114            ('C', call_long, "L"),
2115        ],
2116    );
2117    if state.open_positions.contains_key(&candidate_id)
2118        || state.has_pending_position(&candidate_id)
2119        || has_legacy_duplicate(state, account_hash, underlying, &expiry.to_string())
2120    {
2121        return Ok(None);
2122    }
2123
2124    let params = IronCondorParams {
2125        underlying: underlying.to_string(),
2126        expiry: expiry.to_string(),
2127        put_short,
2128        put_long,
2129        call_short,
2130        call_long,
2131        contracts: entry.max_contracts_per_trade as f64,
2132        limit_credit: total_credit,
2133        duration: None,
2134        session: None,
2135    };
2136
2137    Ok(Some(json!({
2138        "type": "entry",
2139        "strategy": "iron_condor",
2140        "account_hash": account_hash,
2141        "position_id": candidate_id,
2142        "params": params,
2143        "estimated_credit": total_credit,
2144    })))
2145}
2146
2147fn pick_expiry_map(
2148    chain: &Value,
2149    map_key: &str,
2150    dte_min: u32,
2151    dte_max: u32,
2152    today: NaiveDate,
2153) -> Result<(NaiveDate, Value)> {
2154    let map = chain
2155        .get(map_key)
2156        .context("chain missing exp date map")?
2157        .as_object()
2158        .context("exp date map not an object")?;
2159
2160    for key in map.keys() {
2161        let date_part = key.split(':').next().unwrap_or(key);
2162        if let Ok(expiry) = parse_expiry(date_part) {
2163            let dte = days_to_expiry(expiry, today);
2164            if dte >= dte_min as i64 && dte <= dte_max as i64 {
2165                if let Some(strikes) = map.get(key) {
2166                    return Ok((expiry, strikes.clone()));
2167                }
2168            }
2169        }
2170    }
2171    anyhow::bail!("no expiry found in DTE window {dte_min}-{dte_max}")
2172}
2173
2174fn pick_otm_strike(strike_map: &Value, underlying: f64, otm_pct: f64, puts: bool) -> Result<f64> {
2175    let target = if puts {
2176        underlying * (1.0 - otm_pct)
2177    } else {
2178        underlying * (1.0 + otm_pct)
2179    };
2180    pick_nearest_strike(strike_map, target)
2181}
2182
2183/// For put credit spreads, long strike is below short by approximately `width`.
2184fn pick_wing_strike(strike_map: &Value, short_strike: f64, width: f64, puts: bool) -> Result<f64> {
2185    let target = if puts {
2186        short_strike - width
2187    } else {
2188        short_strike + width
2189    };
2190    let obj = strike_map.as_object().context("strike map not object")?;
2191    let candidates: Vec<f64> = obj
2192        .keys()
2193        .filter_map(|k| k.parse::<f64>().ok())
2194        .filter(|s| {
2195            if puts {
2196                *s < short_strike - f64::EPSILON
2197            } else {
2198                *s > short_strike + f64::EPSILON
2199            }
2200        })
2201        .collect();
2202    if candidates.is_empty() {
2203        anyhow::bail!("no wing strikes beyond short {short_strike}");
2204    }
2205    candidates
2206        .into_iter()
2207        .min_by(|a, b| {
2208            ((*a - target).abs())
2209                .partial_cmp(&(*b - target).abs())
2210                .unwrap()
2211        })
2212        .context("no wing strike candidates")
2213}
2214
2215fn pick_nearest_strike(strike_map: &Value, target: f64) -> Result<f64> {
2216    let obj = strike_map.as_object().context("strike map not object")?;
2217    let candidates: Vec<f64> = obj.keys().filter_map(|k| k.parse::<f64>().ok()).collect();
2218    if candidates.is_empty() {
2219        anyhow::bail!("no strikes in chain");
2220    }
2221    candidates
2222        .into_iter()
2223        .min_by(|a, b| {
2224            ((*a - target).abs())
2225                .partial_cmp(&(*b - target).abs())
2226                .unwrap()
2227        })
2228        .context("no strike candidates")
2229}
2230
2231/// Pick strike whose |delta| is closest to the middle of [delta_min, delta_max].
2232fn pick_strike_by_delta(
2233    strike_map: &Value,
2234    delta_min: f64,
2235    delta_max: f64,
2236    puts: bool,
2237) -> Option<f64> {
2238    let obj = strike_map.as_object()?;
2239    let target = (delta_min + delta_max) / 2.0;
2240    let mut best: Option<(f64, f64)> = None;
2241    for (key, contracts) in obj {
2242        let strike = key.parse::<f64>().ok()?;
2243        let delta = contracts.as_array()?.first()?.get("delta")?.as_f64()?;
2244        // Puts have negative delta; calls positive — skip wrong side.
2245        if puts && delta > 0.0 {
2246            continue;
2247        }
2248        if !puts && delta < 0.0 {
2249            continue;
2250        }
2251        let abs_delta = delta.abs();
2252        if abs_delta < delta_min || abs_delta > delta_max {
2253            continue;
2254        }
2255        let dist = (abs_delta - target).abs();
2256        if best.is_none() || dist < best.unwrap().1 {
2257            best = Some((strike, dist));
2258        }
2259    }
2260    best.map(|(s, _)| s)
2261}
2262
2263fn estimate_spread_credit(put_map: &Value, short: f64, long: f64) -> Result<f64> {
2264    let short_bid = strike_quote_field(put_map, short, "bid")?;
2265    let long_ask = strike_quote_field(put_map, long, "ask")?;
2266    Ok((short_bid - long_ask).max(0.0))
2267}
2268
2269fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
2270    for key in strike_key_candidates(strike) {
2271        if let Some(val) = strike_map
2272            .get(&key)
2273            .and_then(|contracts| contracts.as_array()?.first())
2274            .and_then(|c| c.get(field))
2275            .and_then(|v| v.as_f64())
2276        {
2277            return Ok(val);
2278        }
2279    }
2280    anyhow::bail!("missing {field} for strike {strike}")
2281}
2282
2283fn strike_key_candidates(strike: f64) -> Vec<String> {
2284    vec![
2285        format!("{strike:.1}"),
2286        format!("{strike:.0}"),
2287        strike.to_string(),
2288    ]
2289}
2290
2291fn entry_quality_ok(
2292    strike_map: &Value,
2293    short_strike: f64,
2294    long_strike: f64,
2295    width: f64,
2296    credit: f64,
2297    entry: &VerticalEntryRules,
2298) -> bool {
2299    if width <= f64::EPSILON || credit <= f64::EPSILON {
2300        return false;
2301    }
2302    let min_ctw = entry.min_credit_to_width_pct.unwrap_or(12.5);
2303    let credit_to_width_pct = (credit / width) * 100.0;
2304    if credit_to_width_pct < min_ctw {
2305        return false;
2306    }
2307    let short_quote_width = quote_width(strike_map, short_strike).unwrap_or(f64::INFINITY);
2308    let long_quote_width = quote_width(strike_map, long_strike).unwrap_or(f64::INFINITY);
2309    (short_quote_width + long_quote_width) <= credit * MAX_ENTRY_QUOTE_WIDTH_RATIO
2310}
2311
2312fn quote_width(strike_map: &Value, strike: f64) -> Option<f64> {
2313    let bid = strike_quote_field(strike_map, strike, "bid").ok()?;
2314    let ask = strike_quote_field(strike_map, strike, "ask").ok()?;
2315    if bid < 0.0 || ask <= 0.0 || ask < bid {
2316        return None;
2317    }
2318    Some(ask - bid)
2319}
2320
2321fn has_legacy_duplicate(
2322    state: &AgentState,
2323    account_hash: &str,
2324    underlying: &str,
2325    expiry: &str,
2326) -> bool {
2327    state
2328        .open_positions
2329        .values()
2330        .any(|p| p.account_hash == account_hash && p.underlying == underlying && p.expiry == expiry)
2331}
2332
2333fn candidate_id_from_params(account_hash: &str, kind: StrategyKind, params: &Value) -> String {
2334    let underlying = params
2335        .get("underlying")
2336        .and_then(|v| v.as_str())
2337        .unwrap_or("");
2338    let expiry = params.get("expiry").and_then(|v| v.as_str()).unwrap_or("");
2339    match kind {
2340        StrategyKind::Vertical => {
2341            let spread_type = params
2342                .get("type")
2343                .and_then(|v| v.as_str())
2344                .unwrap_or("put_credit");
2345            let put_call = if spread_type.starts_with("call") {
2346                'C'
2347            } else {
2348                'P'
2349            };
2350            let short = params
2351                .get("short_strike")
2352                .and_then(|v| v.as_f64())
2353                .unwrap_or(0.0);
2354            let long = params
2355                .get("long_strike")
2356                .and_then(|v| v.as_f64())
2357                .unwrap_or(0.0);
2358            candidate_position_id(
2359                account_hash,
2360                underlying,
2361                expiry,
2362                kind.as_str(),
2363                vec![(put_call, short, "S"), (put_call, long, "L")],
2364            )
2365        }
2366        StrategyKind::IronCondor => candidate_position_id(
2367            account_hash,
2368            underlying,
2369            expiry,
2370            kind.as_str(),
2371            vec![
2372                (
2373                    'P',
2374                    params
2375                        .get("put_short")
2376                        .and_then(|v| v.as_f64())
2377                        .unwrap_or(0.0),
2378                    "S",
2379                ),
2380                (
2381                    'P',
2382                    params
2383                        .get("put_long")
2384                        .and_then(|v| v.as_f64())
2385                        .unwrap_or(0.0),
2386                    "L",
2387                ),
2388                (
2389                    'C',
2390                    params
2391                        .get("call_short")
2392                        .and_then(|v| v.as_f64())
2393                        .unwrap_or(0.0),
2394                    "S",
2395                ),
2396                (
2397                    'C',
2398                    params
2399                        .get("call_long")
2400                        .and_then(|v| v.as_f64())
2401                        .unwrap_or(0.0),
2402                    "L",
2403                ),
2404            ],
2405        ),
2406    }
2407}
2408
2409async fn maybe_execute_entry(
2410    runtime: &RuntimeConfig,
2411    trader: &Arc<TraderApi>,
2412    account_hash: &str,
2413    kind: StrategyKind,
2414    signal: &Value,
2415    rules: &RulesConfig,
2416    state: &mut AgentState,
2417) -> Result<Option<Value>> {
2418    if runtime.dry_run {
2419        return Ok(None);
2420    }
2421
2422    let roll_replacement = signal
2423        .get("roll_replacement")
2424        .and_then(|v| v.as_bool())
2425        .unwrap_or(false);
2426
2427    if !roll_replacement
2428        && rules.risk.max_trades_per_day > 0
2429        && state.trades_capacity_used() >= rules.risk.max_trades_per_day
2430    {
2431        return Ok(Some(json!({
2432            "fill_status": "SKIPPED",
2433            "reason": "max_trades_per_day reached or reserved by pending entries",
2434            "trades_today": state.trades_today,
2435            "pending_entries": state.pending_entry_count(),
2436            "max_trades_per_day": rules.risk.max_trades_per_day,
2437            "signal": signal,
2438        })));
2439    }
2440
2441    let params = signal
2442        .get("params")
2443        .cloned()
2444        .context("signal missing params")?;
2445    let margin = crate::options::validate::estimate_order_margin(&json!({}), kind, &params)?;
2446    if margin > rules.risk.max_risk_per_trade_usd {
2447        return Ok(Some(json!({
2448            "fill_status": "SKIPPED",
2449            "reason": "max_risk_per_trade_usd exceeded",
2450            "required_margin_usd": margin,
2451            "max_risk_per_trade_usd": rules.risk.max_risk_per_trade_usd,
2452            "signal": signal,
2453        })));
2454    }
2455    let reserved = state.reserved_risk_usd();
2456    if reserved + margin > rules.risk.max_portfolio_risk_usd {
2457        return Ok(Some(json!({
2458            "fill_status": "SKIPPED",
2459            "reason": "max_portfolio_risk_usd exceeded",
2460            "reserved_risk_usd": reserved,
2461            "new_order_margin_usd": margin,
2462            "projected_reserved_risk_usd": reserved + margin,
2463            "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
2464            "signal": signal,
2465        })));
2466    }
2467    let position_id = signal
2468        .get("position_id")
2469        .and_then(|v| v.as_str())
2470        .map(str::to_string)
2471        .unwrap_or_else(|| candidate_id_from_params(account_hash, kind, &params));
2472    if state.open_positions.contains_key(&position_id) || state.has_pending_position(&position_id) {
2473        return Ok(Some(json!({
2474            "fill_status": "SKIPPED",
2475            "reason": "position already open or pending",
2476            "position_id": position_id,
2477            "signal": signal,
2478        })));
2479    }
2480
2481    if let Some(remaining) =
2482        entry_attempt_cooldown_active(&rules.entry_policy, state, &position_id)
2483    {
2484        if !roll_replacement {
2485            return Ok(Some(json!({
2486                "fill_status": "SKIPPED",
2487                "reason": "entry_attempt_cooldown",
2488                "remaining_minutes": remaining,
2489                "position_id": position_id,
2490                "signal": signal,
2491            })));
2492        }
2493    }
2494
2495    require_trading_approval(
2496        runtime,
2497        "agent entry",
2498        &format!("Open {kind:?} on {account_hash}"),
2499    )?;
2500
2501    ensure_option_buying_power(trader, account_hash, margin).await?;
2502    let order = build_order_for_strategy(kind, &params)?;
2503    runtime.safety.validate_order(&order, None, None)?;
2504
2505    record_entry_attempt(state, &position_id);
2506
2507    let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
2508
2509    let order_id = place
2510        .get("order_id")
2511        .and_then(|v| v.as_str())
2512        .map(str::to_string);
2513
2514    let wait_result = if let Some(ref order_id) = order_id {
2515        let condition = if rules.execution.wait_for_fill {
2516            WaitCondition::Terminal
2517        } else {
2518            WaitCondition::Accepted
2519        };
2520        Some(
2521            wait_for_order(
2522                trader,
2523                account_hash,
2524                order_id,
2525                WaitOptions {
2526                    condition,
2527                    timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
2528                    interval: std::time::Duration::from_secs(5),
2529                    proceed_on_partial_fill: false,
2530                    requested_quantity: None,
2531                },
2532            )
2533            .await?,
2534        )
2535    } else {
2536        None
2537    };
2538
2539    let fill_status = wait_result
2540        .as_ref()
2541        .and_then(|w| w.final_status.as_deref())
2542        .unwrap_or("ACCEPTED");
2543
2544    if is_failure_status(fill_status) {
2545        let detail = json!({
2546            "signal": signal,
2547            "place": place,
2548            "wait": wait_result.as_ref().map(wait_result_json),
2549            "fill_status": fill_status,
2550        });
2551        state.record_action("entry_rejected", detail.clone());
2552        return Ok(Some(detail));
2553    }
2554
2555    if fill_status != "FILLED" && rules.execution.wait_for_fill {
2556        if let Some(order_id) = order_id.as_ref() {
2557            state.add_pending_order(PendingOrder {
2558                order_id: order_id.clone(),
2559                account_hash: account_hash.to_string(),
2560                action: PendingOrderAction::Entry,
2561                position_id: position_id.clone(),
2562                reserved_risk_usd: margin,
2563                submitted_at: Utc::now(),
2564                last_status: Some(fill_status.to_string()),
2565                detail: Some(json!({
2566                    "signal": signal,
2567                    "place": place.clone(),
2568                    "wait": wait_result.as_ref().map(wait_result_json),
2569                })),
2570            });
2571        }
2572        let detail = json!({
2573            "signal": signal,
2574            "place": place,
2575            "wait": wait_result.as_ref().map(wait_result_json),
2576            "fill_status": fill_status,
2577            "position_id": position_id,
2578            "reserved_risk_usd": margin,
2579            "note": "Limit order working; risk and trade capacity reserved until terminal status",
2580        });
2581        state.record_action("entry_working", detail.clone());
2582        return Ok(Some(detail));
2583    }
2584
2585    if !roll_replacement {
2586        state.trades_today += 1;
2587    }
2588    let underlying = params
2589        .get("underlying")
2590        .and_then(|v| v.as_str())
2591        .unwrap_or("")
2592        .to_string();
2593    let expiry = params
2594        .get("expiry")
2595        .and_then(|v| v.as_str())
2596        .unwrap_or("")
2597        .to_string();
2598    let order_contracts = params
2599        .get("contracts")
2600        .and_then(|v| v.as_f64())
2601        .unwrap_or(1.0)
2602        .round()
2603        .max(1.0) as u32;
2604    let new_credit = signal.get("estimated_credit").and_then(|v| v.as_f64());
2605    let rolls_used = signal
2606        .get("rolls_used")
2607        .and_then(|v| v.as_u64())
2608        .unwrap_or(0) as u32;
2609    let last_roll_at = if roll_replacement {
2610        Some(Utc::now())
2611    } else {
2612        None
2613    };
2614
2615    let total_contracts;
2616    if let Some(existing) = state.open_positions.get_mut(&position_id) {
2617        let prev_contracts = existing.contracts.max(1);
2618        existing.contracts = prev_contracts + order_contracts;
2619        existing.max_loss_usd += margin;
2620        if let Some(credit) = new_credit {
2621            let blended = existing.entry_credit.unwrap_or(credit) * prev_contracts as f64
2622                + credit * order_contracts as f64;
2623            existing.entry_credit = Some(blended / existing.contracts as f64);
2624        }
2625        total_contracts = existing.contracts;
2626    } else {
2627        total_contracts = order_contracts;
2628        state.open_positions.insert(
2629            position_id.clone(),
2630            TrackedPosition {
2631                position_id: position_id.clone(),
2632                account_hash: account_hash.to_string(),
2633                underlying,
2634                expiry,
2635                strategy: kind.as_str().to_string(),
2636                opened_at: Utc::now(),
2637                entry_credit: new_credit,
2638                max_loss_usd: margin,
2639                contracts: order_contracts,
2640                entry_params: None,
2641                peak_profit_pct: None,
2642                entry_pop_pct: signal
2643                    .pointer("/market_context/spread_pop_pct")
2644                    .and_then(|v| v.as_f64()),
2645                entry_short_delta: signal
2646                    .pointer("/market_context/short_delta")
2647                    .and_then(|v| v.as_f64())
2648                    .map(f64::abs),
2649                rolls_used,
2650                last_roll_at,
2651                ..Default::default()
2652            },
2653        );
2654    }
2655    clear_redeploy_after_entry(state);
2656    state.entry_proceed_cache = None;
2657    state.record_action("entry", signal.clone());
2658
2659    // Rebuild the order for the position's TOTAL contract count (not just this fill's
2660    // incremental contracts) so a top-up to an existing position gets a protective order
2661    // sized for the whole stack, and stash the params so `reconcile_protective_orders` can
2662    // rebuild the same order later without needing this local state.
2663    let mut full_params = params.clone();
2664    full_params["contracts"] = json!(total_contracts);
2665    if let Some(p) = state.open_positions.get_mut(&position_id) {
2666        p.entry_params = Some(full_params.clone());
2667    }
2668    match build_order_for_strategy(kind, &full_params) {
2669        Ok(full_order) => {
2670            place_or_replace_protective_order(
2671                runtime,
2672                trader,
2673                account_hash,
2674                rules,
2675                &full_order,
2676                &position_id,
2677                state,
2678            )
2679            .await;
2680        }
2681        Err(e) => {
2682            tracing::warn!(
2683                "could not rebuild full-size order for protective placement on \
2684                 {position_id}: {e:#}"
2685            );
2686        }
2687    }
2688
2689    Ok(Some(json!({
2690        "entry": place,
2691        "signal": signal,
2692        "wait": wait_result.as_ref().map(wait_result_json),
2693        "position_id": position_id,
2694        "fill_status": fill_status,
2695    })))
2696}
2697
2698/// Place (or replace, if this fill added to an existing tracked position) the broker-resident
2699/// GTC profit-target close order for `position_id`. Best-effort: failure does not block the
2700/// entry, which is already filled — it's recorded on the position for the reconcile pass to
2701/// retry (see `reconcile_protective_orders`). Schwab does not support a stop trigger on
2702/// multi-leg option orders, so this only ever covers the profit-target side (see
2703/// `agent::protective` and `docs/OPTIONS_RULES.md`).
2704async fn place_or_replace_protective_order(
2705    runtime: &RuntimeConfig,
2706    trader: &Arc<TraderApi>,
2707    account_hash: &str,
2708    rules: &RulesConfig,
2709    entry_order: &Value,
2710    position_id: &str,
2711    state: &mut AgentState,
2712) {
2713    if !rules.execution.protective_order.enabled {
2714        return;
2715    }
2716
2717    if let Some(stale_id) = state
2718        .open_positions
2719        .get(position_id)
2720        .and_then(|p| p.protective_order_id.clone())
2721    {
2722        if let Err(e) =
2723            protective::cancel_protective_order(runtime, trader, account_hash, &stale_id).await
2724        {
2725            tracing::warn!("failed to cancel stale protective order {stale_id}: {e:#}");
2726        }
2727        if let Some(p) = state.open_positions.get_mut(position_id) {
2728            p.protective_order_id = None;
2729            p.protective_order_status = None;
2730        }
2731    }
2732
2733    let Some(entry_credit) = state
2734        .open_positions
2735        .get(position_id)
2736        .and_then(|p| p.entry_credit)
2737    else {
2738        return;
2739    };
2740
2741    let cfg = &rules.execution.protective_order;
2742    match protective::place_profit_target_order_with_retry(
2743        runtime,
2744        trader,
2745        account_hash,
2746        entry_order,
2747        entry_credit,
2748        rules.exit_rules.profit_target_pct,
2749        cfg.max_attempts,
2750        cfg.max_seconds,
2751    )
2752    .await
2753    {
2754        Ok(result) => {
2755            if let Some(p) = state.open_positions.get_mut(position_id) {
2756                p.protective_order_id = result.order_id.clone();
2757                p.protective_order_status = Some("WORKING".to_string());
2758                p.protective_order_attempts = 0;
2759            }
2760            state.record_action(
2761                "protective_order_placed",
2762                json!({
2763                    "position_id": position_id,
2764                    "order_id": result.order_id,
2765                    "attempts": result.attempts,
2766                    "order": result.order,
2767                }),
2768            );
2769        }
2770        Err(e) => {
2771            if let Some(p) = state.open_positions.get_mut(position_id) {
2772                p.protective_order_attempts = p.protective_order_attempts.saturating_add(1);
2773            }
2774            tracing::warn!("protective order placement failed for {position_id}: {e:#}");
2775            state.record_action(
2776                "protective_order_failed",
2777                json!({
2778                    "position_id": position_id,
2779                    "error": format!("{e:#}"),
2780                }),
2781            );
2782        }
2783    }
2784}
2785
2786enum DefensiveRollOutcome {
2787    /// Close + open succeeded; do not record stop_loss cooldown.
2788    Success(Value),
2789    /// Eligibility failed — caller should take the normal stop path.
2790    NotAttempted { reason: String },
2791    /// Close already applied (filled/pending) but replacement failed — no second exit.
2792    StopCompleted {
2793        detail: Option<Value>,
2794        reason: String,
2795    },
2796}
2797
2798/// Intercept mechanical `stop_loss` with a managed vertical roll when eligible.
2799#[allow(clippy::too_many_arguments)]
2800async fn try_defensive_roll(
2801    runtime: &RuntimeConfig,
2802    trader: &Arc<TraderApi>,
2803    market: &MarketDataApi,
2804    rules_path: &std::path::Path,
2805    rules: &RulesConfig,
2806    state: &mut AgentState,
2807    today: NaiveDate,
2808    account_hash: &str,
2809    position_id: &str,
2810    tracked: &TrackedPosition,
2811    group: &crate::options::OptionPositionGroup,
2812    eval: &ExitEvaluation,
2813    short_otm_pct: Option<f64>,
2814    simulate: bool,
2815    _telegram: Option<&TelegramNotifier>,
2816) -> Result<DefensiveRollOutcome> {
2817    let roll_cfg = &rules.exit_rules.roll;
2818    if let Err(skip) = roll_eligible(
2819        roll_cfg,
2820        &RollEligibility {
2821            tracked,
2822            mark_dte: eval.mark.dte,
2823            short_otm_pct,
2824            rolls_today: state.rolls_today,
2825            reserved_risk_usd: state.reserved_risk_usd(),
2826            max_portfolio_risk_usd: rules.risk.max_portfolio_risk_usd,
2827        },
2828    ) {
2829        return Ok(DefensiveRollOutcome::NotAttempted {
2830            reason: skip.as_str().to_string(),
2831        });
2832    }
2833
2834    let Some(spread_type) = spread_type_from_tracked(tracked) else {
2835        return Ok(DefensiveRollOutcome::NotAttempted {
2836            reason: "roll_missing_spread_type".into(),
2837        });
2838    };
2839    if !spread_type.eq_ignore_ascii_case("put_credit")
2840        && !spread_type.eq_ignore_ascii_case("call_credit")
2841    {
2842        return Ok(DefensiveRollOutcome::NotAttempted {
2843            reason: "roll_not_credit_vertical".into(),
2844        });
2845    }
2846
2847    let close_debit = eval.mark.debit_to_close;
2848    let entry_credit = tracked
2849        .entry_credit
2850        .unwrap_or(eval.mark.entry_credit)
2851        .max(0.0);
2852    if entry_credit <= f64::EPSILON {
2853        return Ok(DefensiveRollOutcome::NotAttempted {
2854            reason: "roll_missing_entry_credit".into(),
2855        });
2856    }
2857
2858    // Close first (cancels protective GTC via execute_exit / removes sim position).
2859    let exit_signal = exit_signal_json_for_account(account_hash, group, eval);
2860    let close_detail = if simulate {
2861        match record_sim_exit(
2862            rules_path,
2863            state,
2864            rules,
2865            position_id,
2866            "defensive_roll",
2867            &eval.mark,
2868            &exit_signal,
2869        ) {
2870            Ok(d) => d,
2871            Err(e) => {
2872                return Ok(DefensiveRollOutcome::NotAttempted {
2873                    reason: format!("roll_close_failed:{e:#}"),
2874                });
2875            }
2876        }
2877    } else {
2878        match execute_exit(
2879            runtime,
2880            trader,
2881            account_hash,
2882            rules,
2883            group,
2884            &exit_signal,
2885            state,
2886        )
2887        .await
2888        {
2889            Ok(d) => d,
2890            Err(e) => {
2891                return Ok(DefensiveRollOutcome::NotAttempted {
2892                    reason: format!("roll_close_failed:{e:#}"),
2893                });
2894            }
2895        }
2896    };
2897
2898    let close_fill = close_detail
2899        .get("fill_status")
2900        .and_then(|v| v.as_str())
2901        .unwrap_or("")
2902        .to_string();
2903    let close_ok = close_fill.eq_ignore_ascii_case("FILLED")
2904        || (!rules.execution.wait_for_fill && !close_fill.eq_ignore_ascii_case("SKIPPED"));
2905    if !close_ok {
2906        return Ok(DefensiveRollOutcome::StopCompleted {
2907            detail: Some(close_detail),
2908            reason: format!("close_not_filled:{close_fill}"),
2909        });
2910    }
2911
2912    let original_width = tracked
2913        .entry_params
2914        .as_ref()
2915        .and_then(original_width_from_params);
2916    let biased = roll_biased_entry_rules(
2917        &rules.entry_rules.vertical,
2918        roll_cfg,
2919        eval.mark.dte,
2920        tracked.entry_short_delta,
2921        original_width,
2922        tracked.contracts.max(1),
2923    );
2924
2925    let candidate = match evaluate_vertical_entry(
2926        market,
2927        rules,
2928        &biased,
2929        &tracked.underlying,
2930        today,
2931        state,
2932        account_hash,
2933        &spread_type,
2934    )
2935    .await
2936    {
2937        Ok(Some(mut signal)) => {
2938            let new_credit = signal
2939                .get("estimated_credit")
2940                .and_then(|v| v.as_f64())
2941                .unwrap_or(0.0);
2942            if !roll_money_ok(
2943                entry_credit,
2944                close_debit,
2945                new_credit,
2946                roll_cfg.max_debit_pct_of_entry_credit,
2947            ) {
2948                return Ok(DefensiveRollOutcome::StopCompleted {
2949                    detail: Some(close_detail),
2950                    reason: format!(
2951                        "roll_debit_too_large:net={:.3}",
2952                        roll_net(close_debit, new_credit)
2953                    ),
2954                });
2955            }
2956            let next_rolls = tracked.rolls_used.saturating_add(1);
2957            if let Some(obj) = signal.as_object_mut() {
2958                obj.insert("roll_replacement".into(), json!(true));
2959                obj.insert("rolls_used".into(), json!(next_rolls));
2960                obj.insert("closed_position_id".into(), json!(position_id));
2961                obj.insert("roll_close_debit".into(), json!(close_debit));
2962                obj.insert(
2963                    "roll_net".into(),
2964                    json!(roll_net(close_debit, new_credit)),
2965                );
2966            }
2967            signal
2968        }
2969        Ok(None) => {
2970            return Ok(DefensiveRollOutcome::StopCompleted {
2971                detail: Some(close_detail),
2972                reason: "no_roll_candidate".into(),
2973            });
2974        }
2975        Err(e) => {
2976            return Ok(DefensiveRollOutcome::StopCompleted {
2977                detail: Some(close_detail),
2978                reason: format!("roll_candidate_error:{e:#}"),
2979            });
2980        }
2981    };
2982
2983    let new_credit = candidate
2984        .get("estimated_credit")
2985        .and_then(|v| v.as_f64())
2986        .unwrap_or(0.0);
2987    let new_id = candidate
2988        .get("position_id")
2989        .and_then(|v| v.as_str())
2990        .unwrap_or("")
2991        .to_string();
2992
2993    let open_detail = if simulate {
2994        match record_sim_entry(
2995            rules_path,
2996            state,
2997            rules,
2998            account_hash,
2999            StrategyKind::Vertical,
3000            &candidate,
3001        ) {
3002            Ok(d) => d,
3003            Err(e) => {
3004                return Ok(DefensiveRollOutcome::StopCompleted {
3005                    detail: Some(close_detail),
3006                    reason: format!("roll_open_failed:{e:#}"),
3007                });
3008            }
3009        }
3010    } else {
3011        match maybe_execute_entry(
3012            runtime,
3013            trader,
3014            account_hash,
3015            StrategyKind::Vertical,
3016            &candidate,
3017            rules,
3018            state,
3019        )
3020        .await
3021        {
3022            Ok(Some(d)) => d,
3023            Ok(None) => {
3024                return Ok(DefensiveRollOutcome::StopCompleted {
3025                    detail: Some(close_detail),
3026                    reason: "roll_open_dry_or_none".into(),
3027                });
3028            }
3029            Err(e) => {
3030                return Ok(DefensiveRollOutcome::StopCompleted {
3031                    detail: Some(close_detail),
3032                    reason: format!("roll_open_failed:{e:#}"),
3033                });
3034            }
3035        }
3036    };
3037
3038    let open_fill = open_detail
3039        .get("fill_status")
3040        .and_then(|v| v.as_str())
3041        .unwrap_or("");
3042    if !open_fill.eq_ignore_ascii_case("FILLED")
3043        && !(open_fill.is_empty() && !rules.execution.wait_for_fill)
3044    {
3045        // Working/skipped after close — treat as stop completed (position already closed).
3046        if open_fill.eq_ignore_ascii_case("SKIPPED")
3047            || open_fill.eq_ignore_ascii_case("REJECTED")
3048            || open_fill.eq_ignore_ascii_case("CANCELED")
3049        {
3050            return Ok(DefensiveRollOutcome::StopCompleted {
3051                detail: Some(json!({
3052                    "close": close_detail,
3053                    "open": open_detail,
3054                })),
3055                reason: format!("roll_open_status:{open_fill}"),
3056            });
3057        }
3058        // WORKING: replacement pending — still count as roll in progress; bump rolls_today
3059        // only on FILLED. Treat working as incomplete stop path so cooldown applies.
3060        if !open_fill.eq_ignore_ascii_case("FILLED") {
3061            return Ok(DefensiveRollOutcome::StopCompleted {
3062                detail: Some(json!({
3063                    "close": close_detail,
3064                    "open": open_detail,
3065                })),
3066                reason: format!("roll_open_not_filled:{open_fill}"),
3067            });
3068        }
3069    }
3070
3071    state.rolls_today = state.rolls_today.saturating_add(1);
3072    let net = roll_net(close_debit, new_credit);
3073    let detail = json!({
3074        "type": "defensive_roll",
3075        "fill_status": "FILLED",
3076        "reason": "rolled",
3077        "closed_id": position_id,
3078        "new_id": new_id,
3079        "close_debit": close_debit,
3080        "new_credit": new_credit,
3081        "net": net,
3082        "underlying": tracked.underlying,
3083        "rolls_used": tracked.rolls_used.saturating_add(1),
3084        "close": close_detail,
3085        "open": open_detail,
3086        "signal": candidate,
3087    });
3088    state.record_action("defensive_roll", detail.clone());
3089    let _ = journal::append_event(rules_path, simulate, "defensive_roll", detail.clone());
3090    Ok(DefensiveRollOutcome::Success(detail))
3091}
3092
3093async fn execute_exit(
3094    runtime: &RuntimeConfig,
3095    trader: &Arc<TraderApi>,
3096    account_hash: &str,
3097    rules: &RulesConfig,
3098    group: &crate::options::OptionPositionGroup,
3099    signal: &Value,
3100    state: &mut AgentState,
3101) -> Result<Value> {
3102    require_trading_approval(
3103        runtime,
3104        "agent exit",
3105        &format!("Close position {}", group.id),
3106    )?;
3107
3108    let position_id = stable_position_key(account_hash, group);
3109    if state.has_pending_position(&position_id) {
3110        return Ok(json!({
3111            "fill_status": "SKIPPED",
3112            "reason": "exit already pending",
3113            "position_id": position_id,
3114            "signal": signal,
3115        }));
3116    }
3117
3118    // Cancel any resting broker-side profit-target order first, to avoid a race where it
3119    // fills at the same time as this mechanical (stop/DTE/thesis) close.
3120    if let Some(protective_id) = state
3121        .open_positions
3122        .get(&position_id)
3123        .and_then(|p| p.protective_order_id.clone())
3124    {
3125        match protective::cancel_protective_order(runtime, trader, account_hash, &protective_id)
3126            .await
3127        {
3128            Ok(_) => {
3129                if let Some(p) = state.open_positions.get_mut(&position_id) {
3130                    p.protective_order_id = None;
3131                    p.protective_order_status = Some("CANCELED".to_string());
3132                }
3133            }
3134            Err(e) => {
3135                tracing::warn!(
3136                    "failed to cancel protective order {protective_id} before exit \
3137                     (it may have already filled): {e:#}"
3138                );
3139            }
3140        }
3141    }
3142
3143    let close_limit = close_limit_from_signal(signal)
3144        .or_else(|| close_limit_from_group_mark(group))
3145        .context("could not derive close limit price for spread exit")?;
3146    let order = build_close_order_for_group_with_limit(group, Some(close_limit))?;
3147    runtime.safety.validate_order(&order, None, None)?;
3148    let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
3149
3150    let mut wait_json = None;
3151    let mut fill_status = "ACCEPTED".to_string();
3152    let order_id = place
3153        .get("order_id")
3154        .and_then(|v| v.as_str())
3155        .map(str::to_string);
3156
3157    if rules.execution.wait_for_fill {
3158        if let Some(order_id) = order_id.as_ref() {
3159            let wait = wait_for_order(
3160                trader,
3161                account_hash,
3162                order_id,
3163                WaitOptions {
3164                    condition: WaitCondition::Filled,
3165                    timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
3166                    interval: std::time::Duration::from_secs(5),
3167                    proceed_on_partial_fill: false,
3168                    requested_quantity: None,
3169                },
3170            )
3171            .await;
3172            match wait {
3173                Ok(wait) => {
3174                    fill_status = wait
3175                        .final_status
3176                        .as_deref()
3177                        .unwrap_or("UNKNOWN")
3178                        .to_string();
3179                    wait_json = Some(wait_result_json(&wait));
3180                }
3181                Err(e) => {
3182                    fill_status = "WAIT_ERROR".into();
3183                    wait_json = Some(json!({ "error": e.to_string() }));
3184                }
3185            }
3186        }
3187    }
3188
3189    let detail = json!({
3190        "exit": place,
3191        "signal": signal,
3192        "position_id": position_id,
3193        "limit_price": close_limit,
3194        "wait": wait_json,
3195        "fill_status": fill_status.clone(),
3196    });
3197
3198    if fill_status == "FILLED" || !rules.execution.wait_for_fill {
3199        if let Some(tracked) = state.open_positions.get(&position_id).cloned() {
3200            let debit = signal
3201                .pointer("/mark/debit_to_close")
3202                .and_then(|v| v.as_f64())
3203                .unwrap_or(close_limit);
3204            record_live_realized_pnl(state, &tracked, debit);
3205        }
3206        state.open_positions.remove(&position_id);
3207        state.open_positions.remove(&group.id);
3208        state.record_action("exit", signal.clone());
3209    } else {
3210        if let Some(order_id) = order_id {
3211            state.add_pending_order(PendingOrder {
3212                order_id,
3213                account_hash: account_hash.to_string(),
3214                action: PendingOrderAction::Exit,
3215                position_id,
3216                reserved_risk_usd: 0.0,
3217                submitted_at: Utc::now(),
3218                last_status: Some(fill_status),
3219                detail: Some(detail.clone()),
3220            });
3221        }
3222        state.record_action("exit_working_position_kept", detail.clone());
3223    }
3224
3225    Ok(detail)
3226}
3227
3228fn close_limit_from_signal(signal: &Value) -> Option<f64> {
3229    let debit = signal
3230        .pointer("/mark/debit_to_close")
3231        .and_then(|v| v.as_f64())?;
3232    Some((debit + EXIT_LIMIT_SLIPPAGE).max(0.01))
3233}
3234
3235fn close_limit_from_group_mark(group: &crate::options::OptionPositionGroup) -> Option<f64> {
3236    let contracts = crate::options::spread_contract_count(group) as f64;
3237    if contracts <= 0.0 {
3238        return None;
3239    }
3240    Some((group.net_market_value.abs() / contracts / 100.0 + EXIT_LIMIT_SLIPPAGE).max(0.01))
3241}
3242
3243#[cfg(test)]
3244mod llm_schedule_tests {
3245    use super::*;
3246    use crate::agent::llm::LlmReview;
3247    use crate::agent::state::{AgentState, TrackedPosition};
3248    use crate::rules::{
3249        AccountType, EntryPolicyConfig, EntryRules, ExecutionConfig, ExitRules, LlmConfig,
3250        NotifyConfig, RiskConfig, RulesAccount, RulesConfig, ScheduleConfig, StrategiesToggle,
3251        StrategyEnabled, VerticalEntryRules, WatchlistEntry,
3252    };
3253
3254    fn test_rules(max_open: u32) -> RulesConfig {
3255        RulesConfig {
3256            version: 1,
3257            agent_id: "test".into(),
3258            accounts: vec![RulesAccount {
3259                hash: "ACC".into(),
3260                label: Some("test".into()),
3261                r#type: AccountType::Ira,
3262                enabled: true,
3263            }],
3264            schedule: ScheduleConfig {
3265                tick_interval_seconds: 120,
3266                ..Default::default()
3267            },
3268            strategies: StrategiesToggle {
3269                vertical: StrategyEnabled { enabled: true },
3270                iron_condor: StrategyEnabled { enabled: false },
3271            },
3272            watchlist: vec![WatchlistEntry::from("IWM")],
3273            entry_policy: EntryPolicyConfig::default(),
3274            entry_rules: EntryRules {
3275                vertical: VerticalEntryRules {
3276                    max_open_positions: max_open,
3277                    ..Default::default()
3278                },
3279                ..Default::default()
3280            },
3281            exit_rules: ExitRules {
3282                dte_close: 21,
3283                ..Default::default()
3284            },
3285            risk: RiskConfig::default(),
3286            regime: Default::default(),
3287            execution: ExecutionConfig::default(),
3288            llm: LlmConfig {
3289                enabled: true,
3290                review_every_ticks: 15,
3291                monitor_review_every_ticks: Some(30),
3292                ..Default::default()
3293            },
3294            notify: NotifyConfig::default(),
3295            simulation: None,
3296        }
3297    }
3298
3299    fn state_with_open_position() -> AgentState {
3300        let mut state = AgentState::default();
3301        state.open_positions.insert(
3302            "pos".into(),
3303            TrackedPosition {
3304                position_id: "pos".into(),
3305                account_hash: "ACC".into(),
3306                underlying: "IWM".into(),
3307                expiry: "2026-07-31".into(),
3308                strategy: "vertical".into(),
3309                opened_at: chrono::Utc::now(),
3310                entry_credit: Some(0.30),
3311                max_loss_usd: 170.0,
3312                contracts: 1,
3313                entry_params: None,
3314                peak_profit_pct: None,
3315                entry_pop_pct: None,
3316                entry_short_delta: None,
3317                ..Default::default()
3318            },
3319        );
3320        state
3321    }
3322
3323    #[test]
3324    fn selection_llm_is_throttled_when_candidates_exist() {
3325        let rules = test_rules(2);
3326        let mut state = state_with_open_position();
3327        state.regular_tick_count = 100;
3328        state.last_llm_review_tick = Some(95);
3329
3330        assert!(resolve_llm_phase(&rules, &state, true, true, false, false).is_none());
3331
3332        state.regular_tick_count = 125;
3333        assert!(matches!(
3334            resolve_llm_phase(&rules, &state, true, true, false, false),
3335            Some(LlmPhase::Selection)
3336        ));
3337    }
3338
3339    #[test]
3340    fn monitor_runs_when_no_candidates_and_due() {
3341        let rules = test_rules(2);
3342        let mut state = state_with_open_position();
3343        state.regular_tick_count = 125;
3344        state.last_llm_review_tick = Some(95);
3345
3346        assert!(matches!(
3347            resolve_llm_phase(&rules, &state, false, true, false, false),
3348            Some(LlmPhase::Monitor)
3349        ));
3350    }
3351
3352    #[test]
3353    fn monitor_skipped_at_open_without_candidates_or_mechanical_alert() {
3354        let rules = test_rules(2);
3355        let mut state = state_with_open_position();
3356        state.regular_tick_count = 125;
3357        state.last_llm_review_tick = Some(95);
3358
3359        assert!(resolve_llm_phase(&rules, &state, false, true, true, false).is_none());
3360    }
3361
3362    #[test]
3363    fn monitor_runs_at_open_when_mechanical_alert() {
3364        let rules = test_rules(2);
3365        let mut state = state_with_open_position();
3366        state.regular_tick_count = 125;
3367        state.last_llm_review_tick = Some(95);
3368
3369        assert!(matches!(
3370            resolve_llm_phase(&rules, &state, false, true, true, true),
3371            Some(LlmPhase::Monitor)
3372        ));
3373    }
3374
3375    #[test]
3376    fn mechanical_alert_detects_stop_triggered() {
3377        let monitored = vec![json!({
3378            "mechanical_rules": { "stop_triggered": true }
3379        })];
3380        assert!(mechanical_alert_from_monitored(&monitored, 21));
3381    }
3382
3383    #[test]
3384    fn entry_scan_skipped_when_all_accounts_at_capacity() {
3385        let rules = test_rules(1);
3386        let state = state_with_open_position();
3387        assert!(!any_entry_slots_available(&rules, &state));
3388    }
3389
3390    #[test]
3391    fn redeploy_cooldown_removes_underlying_from_scan() {
3392        let mut rules = test_rules(2);
3393        rules.watchlist = vec![WatchlistEntry::from("SPY"), WatchlistEntry::from("IWM")];
3394        rules.exit_rules.thesis.redeploy_cooldown_minutes = Some(120);
3395        let mut state = AgentState::default();
3396        state.redeploy_signal = Some(RedeploySignal {
3397            at: Utc::now(),
3398            reason: "thesis_near_strike".into(),
3399            underlying: Some("IWM".into()),
3400        });
3401        let wl = watchlist_for_scan(&rules, &state);
3402        assert!(!wl.iter().any(|i| i.symbol.eq_ignore_ascii_case("IWM")));
3403
3404        state.redeploy_signal = Some(RedeploySignal {
3405            at: Utc::now() - chrono::Duration::minutes(121),
3406            reason: "thesis_near_strike".into(),
3407            underlying: Some("IWM".into()),
3408        });
3409        let wl = watchlist_for_scan(&rules, &state);
3410        assert_eq!(wl.first().map(|i| i.symbol.as_str()), Some("SPY"));
3411
3412        rules.entry_policy.promote_redeploy_symbol = true;
3413        let wl = watchlist_for_scan(&rules, &state);
3414        assert_eq!(wl.first().map(|i| i.symbol.as_str()), Some("IWM"));
3415    }
3416
3417    #[test]
3418    fn underlying_entry_cap_blocks_second_iwm_spread() {
3419        let mut rules = test_rules(2);
3420        rules
3421            .risk
3422            .max_open_per_underlying
3423            .insert("IWM".into(), 1);
3424        let state = state_with_open_position();
3425        assert!(underlying_entry_cap_reached(
3426            &rules,
3427            &state,
3428            "ACC",
3429            "IWM"
3430        ));
3431        assert!(!underlying_entry_cap_reached(
3432            &rules,
3433            &state,
3434            "ACC",
3435            "SPY"
3436        ));
3437    }
3438
3439    #[test]
3440    fn correlation_group_cap_blocks_second_index() {
3441        let mut rules = test_rules(2);
3442        rules.risk.correlation_groups = vec![crate::rules::CorrelationGroupConfig {
3443            name: "broad_market".into(),
3444            symbols: vec!["QQQ".into(), "IWM".into()],
3445            max_open: 1,
3446        }];
3447        let state = state_with_open_position(); // IWM open on ACC
3448        assert_eq!(
3449            correlation_group_cap_reached(&rules, &state, "ACC", "QQQ").as_deref(),
3450            Some("broad_market")
3451        );
3452        assert!(correlation_group_cap_reached(&rules, &AgentState::default(), "ACC", "QQQ").is_none());
3453    }
3454
3455    #[test]
3456    fn selection_telegram_only_on_proceed_or_urgent_close() {
3457        use crate::agent::telegram_format::is_llm_urgent;
3458
3459        let defer = LlmReview {
3460            phase: "selection".into(),
3461            model: "test".into(),
3462            used_web: false,
3463            raw: serde_json::json!({}),
3464            market_commentary: "ok".into(),
3465            web_insights: vec![],
3466            position_reviews: vec![],
3467            entry_recommendation: "defer".into(),
3468            entry_reasoning: "wait".into(),
3469            risk_alerts: vec!["noise".into()],
3470        };
3471        assert!(!is_llm_urgent(&defer));
3472
3473        let proceed = LlmReview {
3474            entry_recommendation: "proceed".into(),
3475            ..defer.clone()
3476        };
3477        assert!(is_llm_urgent(&proceed));
3478    }
3479}