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 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 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 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 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 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
895fn 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
1187fn 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
1213fn 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, ¶ms)?;
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, ¶ms));
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, ¶ms)?;
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}