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 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 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 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 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 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
614fn 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
859fn 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
893fn 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, ¶ms)?;
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, ¶ms)?;
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}