Skip to main content

schwab_cli/agent/
runner.rs

1use anyhow::{Context, Result};
2use chrono::{Local, NaiveDate, Utc};
3use schwab_api::TraderApi;
4use schwab_market_data::endpoints::chains::ChainQuery;
5use schwab_market_data::MarketDataApi;
6use serde_json::{json, Value};
7use std::sync::Arc;
8
9use crate::config::RuntimeConfig;
10use crate::notify::TelegramNotifier;
11use crate::options::{
12    build_order_for_strategy, ensure_option_buying_power, group_option_legs,
13    list_option_positions, parse_expiry, days_to_expiry, IronCondorParams, StrategyKind,
14    VerticalParams,
15};
16use crate::options::positions::build_close_order_for_group;
17use crate::order_status::{
18    is_failure_status, wait_for_order, wait_result_json, WaitCondition, WaitOptions,
19};
20use crate::rules::{LlmPhase, RulesConfig};
21use crate::safety::{execute_trading_order, require_trading_approval};
22
23use super::exits::{
24    evaluate_position_monitor, exit_signal_json, find_tracked_position, position_key,
25    reconcile_open_positions,
26};
27use super::llm::OpenRouterClient;
28use super::market_context::{market_context_summary_for_llm, vertical_entry_market_context};
29use super::paths::{default_state_path, load_agent_state};
30use super::schedule::{self, AgentSession};
31use super::state::{save_state, AgentState, TrackedPosition};
32
33#[derive(Debug, Clone, serde::Serialize)]
34pub struct TickResult {
35    pub session: String,
36    pub at_open: bool,
37    pub next_sleep_seconds: u64,
38    pub signals: Vec<Value>,
39    pub actions: Vec<Value>,
40    pub skipped: Vec<String>,
41    pub monitored_positions: Vec<Value>,
42    pub llm_review: Option<Value>,
43}
44
45pub async fn run_agent_loop(
46    runtime: &RuntimeConfig,
47    rules_path: &std::path::Path,
48    once: bool,
49) -> Result<()> {
50    let rules = RulesConfig::load(rules_path)?;
51    let state_path = default_state_path(rules_path);
52    let mut state = load_agent_state(rules_path, &rules.agent_id);
53    state.agent_id = rules.agent_id.clone();
54
55    if !runtime.dry_run {
56        crate::safety::require_trading_approval(
57            runtime,
58            "agent run",
59            &format!("Run options agent `{}`", rules.agent_id),
60        )?;
61    }
62
63    let trader = runtime.build_api()?;
64    let market = runtime.build_market_api()?;
65    let telegram = TelegramNotifier::from_env(&rules.notify.telegram).ok().flatten();
66    let llm_client = if rules.llm.enabled {
67        Some(OpenRouterClient::from_env()?)
68    } else {
69        None
70    };
71
72    loop {
73        let result = tick_once(
74            runtime,
75            &rules,
76            &trader,
77            &market,
78            &mut state,
79            llm_client.as_ref(),
80            telegram.as_ref(),
81        )
82        .await?;
83        state.last_tick = Some(Utc::now());
84        save_state(&state_path, &state)?;
85
86        runtime.emit(crate::output::ResponseEnvelope::ok(
87            if once { "agent run once" } else { "agent tick" },
88            json!({
89                "agent_id": rules.agent_id,
90                "session": result.session,
91                "at_open": result.at_open,
92                "next_sleep_seconds": result.next_sleep_seconds,
93                "signals": result.signals,
94                "actions": result.actions,
95                "skipped": result.skipped,
96                "monitored_positions": result.monitored_positions,
97                "llm_review": result.llm_review,
98                "dry_run": runtime.dry_run,
99            }),
100        ));
101
102        notify_tick(telegram.as_ref(), &rules, &result, runtime.dry_run).await;
103
104        if once {
105            break;
106        }
107
108        tokio::time::sleep(std::time::Duration::from_secs(result.next_sleep_seconds))
109            .await;
110    }
111
112    Ok(())
113}
114
115pub async fn tick_once(
116    runtime: &RuntimeConfig,
117    rules: &RulesConfig,
118    trader: &Arc<TraderApi>,
119    market: &Arc<MarketDataApi>,
120    state: &mut AgentState,
121    llm_client: Option<&OpenRouterClient>,
122    telegram: Option<&TelegramNotifier>,
123) -> Result<TickResult> {
124    let today = Local::now().date_naive();
125    state.reset_daily_if_needed(today);
126    state.tick_count += 1;
127
128    let mut result = TickResult {
129        session: "unknown".into(),
130        at_open: false,
131        next_sleep_seconds: rules.schedule.tick_interval_seconds.max(5),
132        signals: vec![],
133        actions: vec![],
134        skipped: vec![],
135        monitored_positions: vec![],
136        llm_review: None,
137    };
138
139    reconcile_open_positions(trader, state, rules).await?;
140
141    let market_open = market_is_open(market).await?;
142    let transition = schedule::resolve_session(
143        market_open,
144        &rules.schedule,
145        state.last_session.as_deref(),
146    );
147    result.session = transition.session.as_str().to_string();
148    result.next_sleep_seconds = transition.sleep_seconds;
149    state.last_session = Some(result.session.clone());
150
151    match transition.session {
152        AgentSession::Idle => {
153            result
154                .skipped
155                .push("market closed (option hours)".into());
156            return Ok(result);
157        }
158        AgentSession::Overnight => {
159            return tick_overnight(
160                runtime,
161                rules,
162                state,
163                llm_client,
164                telegram,
165                &mut result,
166            )
167            .await;
168        }
169        AgentSession::RegularHours => {
170            if transition.just_opened {
171                result.at_open = true;
172                result
173                    .skipped
174                    .push("market open — full evaluation (mechanical exits + live marks)".into());
175                notify_at_open(telegram, state.open_playbook.as_ref()).await;
176            }
177            state.regular_tick_count += 1;
178        }
179    }
180
181    let entries_paused = state.trades_today >= rules.risk.max_trades_per_day;
182
183    // Exit evaluation and position monitoring (always runs when market is open)
184    for account in rules.enabled_accounts() {
185        let legs = list_option_positions(trader, Some(&account.hash)).await?;
186        let groups = group_option_legs(&legs);
187        for group in &groups {
188            let tracked = find_tracked_position(state, &account.hash, group);
189            let monitor = evaluate_position_monitor(market, group, rules, today, tracked).await?;
190
191            if !group.legs.is_empty() {
192                result.monitored_positions.push(monitor.snapshot);
193            }
194
195            if let Some(eval) = monitor.exit {
196                let exit = exit_signal_json(group, &eval);
197                result.signals.push(exit.clone());
198                if !runtime.dry_run {
199                    if let Ok(action) = execute_exit(
200                        runtime,
201                        trader,
202                        &account.hash,
203                        rules,
204                        group,
205                        &exit,
206                        state,
207                    )
208                    .await
209                    {
210                        result.actions.push(action);
211                        notify_action(telegram, "EXIT", &exit).await;
212                    }
213                }
214            }
215        }
216    }
217
218    // Entry scan (signals collected; execution after LLM review)
219    let mut pending_entries: Vec<(String, StrategyKind, Value)> = Vec::new();
220    if entries_paused {
221        result.skipped.push(format!(
222            "new entries paused — max_trades_per_day ({}) reached",
223            rules.risk.max_trades_per_day
224        ));
225    } else {
226        for account in rules.enabled_accounts() {
227            for underlying in &rules.watchlist {
228                let sym = underlying.to_uppercase();
229                if !rules.risk.allowed_underlyings.is_empty()
230                    && !rules
231                        .risk
232                        .allowed_underlyings
233                        .iter()
234                        .any(|u| u.eq_ignore_ascii_case(&sym))
235                {
236                    continue;
237                }
238
239                if rules.strategies.vertical.enabled {
240                    match evaluate_vertical_entry(
241                        market,
242                        rules,
243                        &sym,
244                        today,
245                        state,
246                        &account.hash,
247                    )
248                    .await
249                    {
250                        Ok(Some(signal)) => {
251                            pending_entries.push((
252                                account.hash.clone(),
253                                StrategyKind::Vertical,
254                                signal,
255                            ));
256                        }
257                        Ok(None) => {}
258                        Err(e) => result
259                            .skipped
260                            .push(format!("{sym} vertical: {e:#}")),
261                    }
262                }
263
264                if rules.strategies.iron_condor.enabled {
265                    match evaluate_condor_entry(
266                        market,
267                        rules,
268                        &sym,
269                        today,
270                        state,
271                        &account.hash,
272                    )
273                    .await
274                    {
275                        Ok(Some(signal)) => {
276                            pending_entries.push((
277                                account.hash.clone(),
278                                StrategyKind::IronCondor,
279                                signal,
280                            ));
281                        }
282                        Ok(None) => {}
283                        Err(e) => result
284                            .skipped
285                            .push(format!("{sym} iron_condor: {e:#}")),
286                    }
287                }
288            }
289        }
290    }
291
292    for (_, _, signal) in &pending_entries {
293        result.signals.push(signal.clone());
294    }
295
296    // LLM review — selection when candidates exist; monitor on schedule when positions open
297    let mut llm_veto_entries = false;
298    let mut llm_close_ids: Vec<String> = Vec::new();
299    let has_candidates = !pending_entries.is_empty();
300    let has_positions = !result.monitored_positions.is_empty();
301
302    if let Some(client) = llm_client {
303        if let Some(phase) = resolve_llm_phase(rules, state, has_candidates, has_positions) {
304            let use_web =
305                should_use_web_research(rules, state) && matches!(phase, LlmPhase::Selection);
306
307            let context = json!({
308                "agent_id": rules.agent_id,
309                "tick": state.regular_tick_count,
310                "date": today.to_string(),
311                "phase": match phase {
312                    LlmPhase::Selection => "selection",
313                    LlmPhase::Monitor => "monitor",
314                    LlmPhase::OvernightDigest => "overnight_digest",
315                },
316                "market": market_context_summary_for_llm(),
317                "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
318                "open_positions": result.monitored_positions,
319                "open_playbook": state.open_playbook,
320                "candidate_entries": pending_entries.iter().map(|(_, _, s)| s).collect::<Vec<_>>(),
321                "recent_signals": result.signals,
322                "watchlist": rules.watchlist,
323                "risk": {
324                    "max_trades_per_day": rules.risk.max_trades_per_day,
325                    "trades_today": state.trades_today,
326                    "max_risk_per_trade_usd": rules.risk.max_risk_per_trade_usd,
327                    "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
328                },
329            });
330
331            match client.review(&rules.llm, phase, &context, use_web).await {
332                Ok(review) => {
333                    let review_json = review.to_json();
334                    result.llm_review = Some(review_json.clone());
335                    state.last_llm_review_tick = Some(state.regular_tick_count);
336                    state.llm_review_count += 1;
337                    state.last_llm_summary = Some(review_json.clone());
338                    state.record_action("llm_review", review_json.clone());
339
340                    if rules.llm.veto_entries && review.should_veto_entries() {
341                        llm_veto_entries = true;
342                        result.skipped.push(format!(
343                            "LLM veto entries: {}",
344                            review.entry_reasoning
345                        ));
346                    }
347
348                    if rules.llm.allow_llm_exits {
349                        for pos in review.urgent_close_positions() {
350                            llm_close_ids.push(pos.position_id.clone());
351                        }
352                    }
353
354                    if !review.risk_alerts.is_empty() || !review.market_commentary.is_empty() {
355                        notify_llm(telegram, &review.market_commentary, &review.risk_alerts).await;
356                    }
357                }
358                Err(e) => result.skipped.push(format!("LLM review failed: {e:#}")),
359            }
360        }
361    }
362
363    // LLM-requested exits (high urgency only, when enabled)
364    if !llm_close_ids.is_empty() && !runtime.dry_run {
365        for account in rules.enabled_accounts() {
366            let legs = list_option_positions(trader, Some(&account.hash)).await?;
367            let groups = group_option_legs(&legs);
368            for group in &groups {
369                if !llm_close_ids.iter().any(|id| id == &group.id) {
370                    continue;
371                }
372                let exit = json!({
373                    "type": "exit",
374                    "reason": "llm_recommendation",
375                    "position_id": group.id,
376                    "underlying": group.underlying,
377                    "expiry": group.expiry,
378                });
379                result.signals.push(exit.clone());
380                if let Ok(action) =
381                    execute_exit(runtime, trader, &account.hash, rules, group, &exit, state).await
382                {
383                    result.actions.push(action);
384                    notify_action(telegram, "LLM EXIT", &exit).await;
385                }
386            }
387        }
388    }
389
390    // Execute pending entries unless LLM vetoed (dry-run never executes)
391    if !llm_veto_entries && !runtime.dry_run {
392        for (account_hash, kind, signal) in pending_entries {
393            if let Ok(action) = maybe_execute_entry(
394                runtime,
395                trader,
396                &account_hash,
397                kind,
398                &signal,
399                rules,
400                state,
401            )
402            .await
403            {
404                if let Some(a) = action {
405                    result.actions.push(a.clone());
406                    let label = a
407                        .pointer("/fill_status")
408                        .and_then(|v| v.as_str())
409                        .unwrap_or("UNKNOWN");
410                    match label {
411                        "FILLED" => notify_action(telegram, "ENTRY FILLED", &a).await,
412                        "WORKING" | "ACCEPTED" | "PENDING_ACTIVATION" | "QUEUED" => {
413                            notify_action(telegram, "ORDER WORKING (limit)", &a).await
414                        }
415                        other if is_failure_status(other) => {
416                            notify_action(telegram, "ORDER REJECTED", &a).await
417                        }
418                        _ => notify_action(telegram, "ORDER", &a).await,
419                    }
420                }
421            }
422        }
423    }
424
425    Ok(result)
426}
427
428fn should_run_monitor_review(rules: &RulesConfig, state: &AgentState) -> bool {
429    schedule::should_run_monitor_review(
430        state.regular_tick_count,
431        state.last_llm_review_tick,
432        rules.llm.review_every_ticks,
433    )
434}
435
436async fn tick_overnight(
437    _runtime: &RuntimeConfig,
438    rules: &RulesConfig,
439    state: &mut AgentState,
440    llm_client: Option<&OpenRouterClient>,
441    telegram: Option<&TelegramNotifier>,
442    result: &mut TickResult,
443) -> Result<TickResult> {
444    let today = Local::now().date_naive();
445    result.monitored_positions = overnight_position_snapshots(state);
446
447    if result.monitored_positions.is_empty() {
448        result.skipped.push("overnight — no open positions".into());
449    } else {
450        result.skipped.push(format!(
451            "overnight — monitoring {} open position(s) (no live marks)",
452            result.monitored_positions.len()
453        ));
454    }
455
456    let digest_due = schedule::should_run_overnight_digest(
457        state,
458        &rules.schedule.overnight,
459        Utc::now(),
460    );
461
462    if !digest_due {
463        result.skipped.push(format!(
464            "overnight digest next in ~{} min",
465            rules.schedule.overnight.tick_interval_seconds / 60
466        ));
467        return Ok(result.clone());
468    }
469
470    if !rules.llm.enabled {
471        result.skipped.push("overnight digest skipped (llm.enabled false)".into());
472        return Ok(result.clone());
473    }
474
475    let Some(client) = llm_client else {
476        result.skipped.push("overnight digest skipped (no LLM client)".into());
477        return Ok(result.clone());
478    };
479
480    let context = json!({
481        "agent_id": rules.agent_id,
482        "date": today.to_string(),
483        "phase": "overnight_digest",
484        "market_closed": true,
485        "open_positions": result.monitored_positions,
486        "prior_open_playbook": state.open_playbook,
487        "watchlist": rules.watchlist,
488        "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
489        "note": "Build open playbook for next session. No chain data. new_entries must be skip.",
490    });
491
492    match client
493        .review(
494            &rules.llm,
495            LlmPhase::OvernightDigest,
496            &context,
497            true,
498        )
499        .await
500    {
501        Ok(review) => {
502            let review_json = review.to_json();
503            result.llm_review = Some(review_json.clone());
504            state.last_overnight_digest_at = Some(Utc::now());
505            state.open_playbook = Some(json!({
506                "updated_at": Utc::now(),
507                "market_commentary": review.market_commentary,
508                "positions": review.position_reviews,
509                "risk_alerts": review.risk_alerts,
510                "open_actions": review.entry_reasoning,
511            }));
512            state.last_llm_summary = Some(review_json.clone());
513            state.record_action("overnight_digest", review_json);
514
515            let should_notify = if rules.schedule.overnight.alert_on_risk_only {
516                !review.risk_alerts.is_empty()
517            } else {
518                !review.risk_alerts.is_empty() || !review.market_commentary.is_empty()
519            };
520            if should_notify {
521                notify_overnight_alert(telegram, &review).await;
522            }
523        }
524        Err(e) => result.skipped.push(format!("overnight digest failed: {e:#}")),
525    }
526
527    Ok(result.clone())
528}
529
530fn overnight_position_snapshots(state: &AgentState) -> Vec<Value> {
531    state
532        .open_positions
533        .values()
534        .map(|p| {
535            json!({
536                "position_id": p.position_id,
537                "underlying": p.underlying,
538                "expiry": p.expiry,
539                "strategy": p.strategy,
540                "entry_credit": p.entry_credit,
541                "max_loss_usd": p.max_loss_usd,
542                "status": "overnight (reconciled, no live marks)",
543            })
544        })
545        .collect()
546}
547
548async fn notify_at_open(telegram: Option<&TelegramNotifier>, playbook: Option<&Value>) {
549    let Some(tg) = telegram else { return };
550    if !tg.wants_actions() {
551        return;
552    }
553    let body = if let Some(pb) = playbook {
554        let commentary = pb
555            .get("market_commentary")
556            .and_then(|v| v.as_str())
557            .unwrap_or("(no overnight playbook)");
558        format!("Market open — running full evaluation.\n\nOvernight playbook:\n{commentary}")
559    } else {
560        "Market open — running full evaluation.".into()
561    };
562    let _ = tg.send(&body).await;
563}
564
565async fn notify_overnight_alert(
566    telegram: Option<&TelegramNotifier>,
567    review: &super::llm::LlmReview,
568) {
569    let Some(tg) = telegram else { return };
570    if !tg.wants_actions() {
571        return;
572    }
573    let alerts = if review.risk_alerts.is_empty() {
574        String::new()
575    } else {
576        format!("\nAlerts: {}", review.risk_alerts.join("; "))
577    };
578    let _ = tg
579        .send(&format!(
580            "OVERNIGHT DIGEST\n{}{}",
581            review.market_commentary, alerts
582        ))
583        .await;
584}
585
586async fn market_is_open(market: &MarketDataApi) -> Result<bool> {
587    let hours = market.markets().hours("option", None).await?;
588    let open = hours
589        .pointer("/option/EQO/isOpen")
590        .or_else(|| hours.pointer("/option/option/isOpen"))
591        .and_then(|v| v.as_bool())
592        .unwrap_or(true);
593    Ok(open)
594}
595
596fn resolve_llm_phase(
597    rules: &RulesConfig,
598    state: &AgentState,
599    has_candidates: bool,
600    has_positions: bool,
601) -> Option<LlmPhase> {
602    if !rules.llm.enabled {
603        return None;
604    }
605    if has_candidates {
606        return Some(LlmPhase::Selection);
607    }
608    if has_positions && should_run_monitor_review(rules, state) {
609        return Some(LlmPhase::Monitor);
610    }
611    None
612}
613
614/// Every Nth LLM review uses web_model during selection phase.
615fn should_use_web_research(rules: &RulesConfig, state: &AgentState) -> bool {
616    if rules.llm.web_research_every_reviews == 0 {
617        return false;
618    }
619    let next_review = state.llm_review_count + 1;
620    next_review % rules.llm.web_research_every_reviews.max(1) == 0
621}
622
623async fn notify_tick(
624    telegram: Option<&TelegramNotifier>,
625    rules: &RulesConfig,
626    result: &TickResult,
627    dry_run: bool,
628) {
629    let Some(tg) = telegram else { return };
630    if !tg.wants_tick_summary() {
631        return;
632    }
633    let prefix = if dry_run { "[DRY RUN] " } else { "" };
634    let msg = format!(
635        "{prefix}Agent `{}` tick\nsignals: {}\nactions: {}\nskipped: {}",
636        rules.agent_id,
637        result.signals.len(),
638        result.actions.len(),
639        result.skipped.len()
640    );
641    let _ = tg.send(&msg).await;
642}
643
644async fn notify_action(telegram: Option<&TelegramNotifier>, kind: &str, detail: &Value) {
645    let Some(tg) = telegram else { return };
646    if !tg.wants_actions() {
647        return;
648    }
649    let msg = format!("{kind}\n```\n{}\n```", serde_json::to_string_pretty(detail).unwrap_or_default());
650    let _ = tg.send(&msg).await;
651}
652
653async fn notify_llm(telegram: Option<&TelegramNotifier>, commentary: &str, alerts: &[String]) {
654    let Some(tg) = telegram else { return };
655    if !tg.wants_actions() {
656        return;
657    }
658    let alerts_text = if alerts.is_empty() {
659        String::new()
660    } else {
661        format!("\nAlerts: {}", alerts.join("; "))
662    };
663    let _ = tg.send(&format!("LLM review\n{commentary}{alerts_text}")).await;
664}
665
666async fn evaluate_vertical_entry(
667    market: &MarketDataApi,
668    rules: &RulesConfig,
669    underlying: &str,
670    today: NaiveDate,
671    state: &AgentState,
672    account_hash: &str,
673) -> Result<Option<Value>> {
674    let entry = &rules.entry_rules.vertical;
675    let open_count = state.count_open_for_strategy(account_hash, StrategyKind::Vertical);
676    if open_count >= entry.max_open_positions {
677        return Ok(None);
678    }
679
680    let chain = market
681        .chains()
682        .get(&ChainQuery {
683            symbol: underlying,
684            contract_type: Some("PUT"),
685            strike_count: Some(50),
686            include_underlying_quote: Some(true),
687            ..Default::default()
688        })
689        .await?;
690
691    let (expiry, put_map) = pick_expiry_map(&chain, "putExpDateMap", entry.dte_min, entry.dte_max, today)?;
692    let underlying_price = chain
693        .pointer("/underlying/last")
694        .or_else(|| chain.pointer("/underlyingPrice"))
695        .and_then(|v| v.as_f64())
696        .unwrap_or(0.0);
697
698    if underlying_price <= 0.0 {
699        return Ok(None);
700    }
701
702    let short_strike = pick_strike_by_delta(
703        &put_map,
704        entry.short_delta_min,
705        entry.short_delta_max,
706        true,
707    )
708    .or_else(|| pick_otm_strike(&put_map, underlying_price, 0.10, true).ok())
709    .context("no suitable short strike")?;
710    let long_strike = pick_wing_strike(&put_map, short_strike, entry.max_width, true)?;
711    let credit = estimate_spread_credit(&put_map, short_strike, long_strike)?;
712    if credit < entry.min_credit {
713        return Ok(None);
714    }
715
716    let params = VerticalParams {
717        underlying: underlying.to_string(),
718        expiry: expiry.to_string(),
719        spread_type: entry.r#type.clone(),
720        short_strike,
721        long_strike,
722        contracts: entry.max_contracts_per_trade as f64,
723        limit_credit: Some(credit),
724        limit_debit: None,
725        duration: None,
726        session: None,
727    };
728
729    let market_context = vertical_entry_market_context(
730        &chain,
731        underlying,
732        expiry,
733        today,
734        &put_map,
735        short_strike,
736        long_strike,
737        entry.max_width,
738        credit,
739        entry.max_contracts_per_trade as f64,
740    );
741
742    Ok(Some(json!({
743        "type": "entry",
744        "strategy": "vertical",
745        "account_hash": account_hash,
746        "params": params,
747        "estimated_credit": credit,
748        "market_context": market_context,
749    })))
750}
751
752async fn evaluate_condor_entry(
753    market: &MarketDataApi,
754    rules: &RulesConfig,
755    underlying: &str,
756    today: NaiveDate,
757    state: &AgentState,
758    account_hash: &str,
759) -> Result<Option<Value>> {
760    let entry = &rules.entry_rules.iron_condor;
761    let open_count = state.count_open_for_strategy(account_hash, StrategyKind::IronCondor);
762    if open_count >= entry.max_open_positions {
763        return Ok(None);
764    }
765
766    let chain = market
767        .chains()
768        .get(&ChainQuery {
769            symbol: underlying,
770            contract_type: Some("ALL"),
771            strike_count: Some(40),
772            include_underlying_quote: Some(true),
773            ..Default::default()
774        })
775        .await?;
776
777    let underlying_price = chain
778        .pointer("/underlying/last")
779        .or_else(|| chain.pointer("/underlyingPrice"))
780        .and_then(|v| v.as_f64())
781        .unwrap_or(0.0);
782    if underlying_price <= 0.0 {
783        return Ok(None);
784    }
785
786    let (expiry, put_map) = pick_expiry_map(&chain, "putExpDateMap", entry.dte_min, entry.dte_max, today)?;
787    let (_, call_map) = pick_expiry_map(&chain, "callExpDateMap", entry.dte_min, entry.dte_max, today)?;
788
789    let put_short = pick_otm_strike(&put_map, underlying_price, entry.short_delta, true)?;
790    let put_long = put_short - entry.wing_width;
791    let call_short = pick_otm_strike(&call_map, underlying_price, entry.short_delta, false)?;
792    let call_long = call_short + entry.wing_width;
793
794    let put_credit = estimate_spread_credit(&put_map, put_short, put_long)?;
795    let call_credit = estimate_spread_credit(&call_map, call_short, call_long)?;
796    let total_credit = put_credit + call_credit;
797    if total_credit < entry.min_credit {
798        return Ok(None);
799    }
800
801    let params = IronCondorParams {
802        underlying: underlying.to_string(),
803        expiry: expiry.to_string(),
804        put_short,
805        put_long,
806        call_short,
807        call_long,
808        contracts: entry.max_contracts_per_trade as f64,
809        limit_credit: total_credit,
810        duration: None,
811        session: None,
812    };
813
814    Ok(Some(json!({
815        "type": "entry",
816        "strategy": "iron_condor",
817        "account_hash": account_hash,
818        "params": params,
819        "estimated_credit": total_credit,
820    })))
821}
822
823fn pick_expiry_map(
824    chain: &Value,
825    map_key: &str,
826    dte_min: u32,
827    dte_max: u32,
828    today: NaiveDate,
829) -> Result<(NaiveDate, Value)> {
830    let map = chain
831        .get(map_key)
832        .context("chain missing exp date map")?
833        .as_object()
834        .context("exp date map not an object")?;
835
836    for key in map.keys() {
837        let date_part = key.split(':').next().unwrap_or(key);
838        if let Ok(expiry) = parse_expiry(date_part) {
839            let dte = days_to_expiry(expiry, today);
840            if dte >= dte_min as i64 && dte <= dte_max as i64 {
841                if let Some(strikes) = map.get(key) {
842                    return Ok((expiry, strikes.clone()));
843                }
844            }
845        }
846    }
847    anyhow::bail!("no expiry found in DTE window {dte_min}-{dte_max}")
848}
849
850fn pick_otm_strike(strike_map: &Value, underlying: f64, otm_pct: f64, puts: bool) -> Result<f64> {
851    let target = if puts {
852        underlying * (1.0 - otm_pct)
853    } else {
854        underlying * (1.0 + otm_pct)
855    };
856    pick_nearest_strike(strike_map, target)
857}
858
859/// For put credit spreads, long strike is below short by approximately `width`.
860fn pick_wing_strike(
861    strike_map: &Value,
862    short_strike: f64,
863    width: f64,
864    puts: bool,
865) -> Result<f64> {
866    let target = if puts {
867        short_strike - width
868    } else {
869        short_strike + width
870    };
871    pick_nearest_strike(strike_map, target)
872}
873
874fn pick_nearest_strike(strike_map: &Value, target: f64) -> Result<f64> {
875    let obj = strike_map.as_object().context("strike map not object")?;
876    let candidates: Vec<f64> = obj
877        .keys()
878        .filter_map(|k| k.parse::<f64>().ok())
879        .collect();
880    if candidates.is_empty() {
881        anyhow::bail!("no strikes in chain");
882    }
883    candidates
884        .into_iter()
885        .min_by(|a, b| {
886            ((*a - target).abs())
887                .partial_cmp(&(*b - target).abs())
888                .unwrap()
889        })
890        .context("no strike candidates")
891}
892
893/// Pick put strike whose |delta| is closest to the middle of [delta_min, delta_max].
894fn pick_strike_by_delta(
895    strike_map: &Value,
896    delta_min: f64,
897    delta_max: f64,
898    puts: bool,
899) -> Option<f64> {
900    let obj = strike_map.as_object()?;
901    let target = (delta_min + delta_max) / 2.0;
902    let mut best: Option<(f64, f64)> = None;
903    for (key, contracts) in obj {
904        let strike = key.parse::<f64>().ok()?;
905        let delta = contracts
906            .as_array()?
907            .first()?
908            .get("delta")?
909            .as_f64()?;
910        let abs_delta = delta.abs();
911        if puts && delta > 0.0 {
912            continue;
913        }
914        let dist = (abs_delta - target).abs();
915        if best.is_none() || dist < best.unwrap().1 {
916            best = Some((strike, dist));
917        }
918    }
919    best.map(|(s, _)| s)
920}
921
922fn estimate_spread_credit(put_map: &Value, short: f64, long: f64) -> Result<f64> {
923    let short_bid = strike_quote_field(put_map, short, "bid")?;
924    let long_ask = strike_quote_field(put_map, long, "ask")?;
925    Ok((short_bid - long_ask).max(0.0))
926}
927
928fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
929    for key in strike_key_candidates(strike) {
930        if let Some(val) = strike_map
931            .get(&key)
932            .and_then(|contracts| contracts.as_array()?.first())
933            .and_then(|c| c.get(field))
934            .and_then(|v| v.as_f64())
935        {
936            return Ok(val);
937        }
938    }
939    anyhow::bail!("missing {field} for strike {strike}")
940}
941
942fn strike_key_candidates(strike: f64) -> Vec<String> {
943    vec![
944        format!("{strike:.1}"),
945        format!("{strike:.0}"),
946        strike.to_string(),
947    ]
948}
949
950async fn maybe_execute_entry(
951    runtime: &RuntimeConfig,
952    trader: &Arc<TraderApi>,
953    account_hash: &str,
954    kind: StrategyKind,
955    signal: &Value,
956    rules: &RulesConfig,
957    state: &mut AgentState,
958) -> Result<Option<Value>> {
959    if runtime.dry_run {
960        return Ok(None);
961    }
962
963    if state.trades_today >= rules.risk.max_trades_per_day {
964        return Ok(None);
965    }
966
967    let params = signal.get("params").cloned().context("signal missing params")?;
968    let margin = crate::options::validate::estimate_order_margin(&json!({}), kind, &params)?;
969    if margin > rules.risk.max_risk_per_trade_usd {
970        return Ok(None);
971    }
972
973    require_trading_approval(
974        runtime,
975        "agent entry",
976        &format!("Open {kind:?} on {account_hash}"),
977    )?;
978
979    ensure_option_buying_power(trader, account_hash, margin).await?;
980    let order = build_order_for_strategy(kind, &params)?;
981    runtime.safety.validate_order(&order, None, None)?;
982
983    let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
984
985    let order_id = place
986        .get("order_id")
987        .and_then(|v| v.as_str())
988        .map(str::to_string);
989
990    let wait_result = if let Some(ref order_id) = order_id {
991        let condition = if rules.execution.wait_for_fill {
992            WaitCondition::Terminal
993        } else {
994            WaitCondition::Accepted
995        };
996        Some(
997            wait_for_order(
998                trader,
999                account_hash,
1000                order_id,
1001                WaitOptions {
1002                    condition,
1003                    timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
1004                    interval: std::time::Duration::from_secs(5),
1005                    proceed_on_partial_fill: false,
1006                    requested_quantity: None,
1007                },
1008            )
1009            .await?,
1010        )
1011    } else {
1012        None
1013    };
1014
1015    let fill_status = wait_result
1016        .as_ref()
1017        .and_then(|w| w.final_status.as_deref())
1018        .unwrap_or("ACCEPTED");
1019
1020    if is_failure_status(fill_status) {
1021        let detail = json!({
1022            "signal": signal,
1023            "place": place,
1024            "wait": wait_result.as_ref().map(wait_result_json),
1025            "fill_status": fill_status,
1026        });
1027        state.record_action("entry_rejected", detail.clone());
1028        return Ok(Some(detail));
1029    }
1030
1031    if fill_status != "FILLED" && rules.execution.wait_for_fill {
1032        let detail = json!({
1033            "signal": signal,
1034            "place": place,
1035            "wait": wait_result.as_ref().map(wait_result_json),
1036            "fill_status": fill_status,
1037            "note": "Limit order working; position not opened in agent state until filled",
1038        });
1039        state.record_action("entry_working", detail.clone());
1040        return Ok(Some(detail));
1041    }
1042
1043    state.trades_today += 1;
1044    let underlying = params
1045        .get("underlying")
1046        .and_then(|v| v.as_str())
1047        .unwrap_or("")
1048        .to_string();
1049    let expiry = params
1050        .get("expiry")
1051        .and_then(|v| v.as_str())
1052        .unwrap_or("")
1053        .to_string();
1054    let position_id = position_key(&underlying, &expiry);
1055    state.open_positions.insert(
1056        position_id.clone(),
1057        TrackedPosition {
1058            position_id,
1059            account_hash: account_hash.to_string(),
1060            underlying,
1061            expiry,
1062            strategy: kind.as_str().to_string(),
1063            opened_at: Utc::now(),
1064            entry_credit: signal.get("estimated_credit").and_then(|v| v.as_f64()),
1065            max_loss_usd: margin,
1066        },
1067    );
1068    state.record_action("entry", signal.clone());
1069
1070    Ok(Some(json!({
1071        "entry": place,
1072        "signal": signal,
1073        "wait": wait_result.as_ref().map(wait_result_json),
1074        "fill_status": fill_status,
1075    })))
1076}
1077
1078async fn execute_exit(
1079    runtime: &RuntimeConfig,
1080    trader: &Arc<TraderApi>,
1081    account_hash: &str,
1082    rules: &RulesConfig,
1083    group: &crate::options::OptionPositionGroup,
1084    signal: &Value,
1085    state: &mut AgentState,
1086) -> Result<Value> {
1087    require_trading_approval(
1088        runtime,
1089        "agent exit",
1090        &format!("Close position {}", group.id),
1091    )?;
1092
1093    let order = build_close_order_for_group(group)?;
1094    runtime.safety.validate_order(&order, None, None)?;
1095    let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
1096
1097    if rules.execution.wait_for_fill {
1098        if let Some(order_id) = place.get("order_id").and_then(|v| v.as_str()) {
1099            let _ = wait_for_order(
1100                trader,
1101                account_hash,
1102                order_id,
1103                WaitOptions {
1104                    condition: WaitCondition::Filled,
1105                    timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
1106                    interval: std::time::Duration::from_secs(5),
1107                    proceed_on_partial_fill: false,
1108                    requested_quantity: None,
1109                },
1110            )
1111            .await;
1112        }
1113    }
1114
1115    state.open_positions.remove(&group.id);
1116    state.record_action("exit", signal.clone());
1117    Ok(json!({ "exit": place, "signal": signal }))
1118}