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