Skip to main content

schwab_cli/agent/
runner.rs

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