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