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::{
27 EntryScanMode, LlmPhase, RulesConfig, VerticalEntryRules, WatchlistItemConfig, WatchlistRole,
28};
29use crate::safety::{execute_trading_order, require_trading_approval};
30use crate::trade_audio::{self, TradeAudioEvent};
31
32use super::exits::{
33 candidate_fails_thesis_gates, evaluate_position_monitor, exit_signal_json_for_account,
34 find_tracked_position, option_group_from_tracked, reconcile_open_positions, stable_position_key,
35 ExitEvaluation,
36};
37use super::journal;
38use super::llm::OpenRouterClient;
39use super::market_context::{market_context_summary_for_llm, vertical_entry_market_context};
40use super::regime::{detect_options_regime, OptionsRegimeSnapshot};
41use super::risk::{drawdown_to_json, record_live_realized_pnl, update_drawdown};
42use super::roll::{
43 original_width_from_params, roll_biased_entry_rules, roll_eligible, roll_money_ok, roll_net,
44 spread_type_from_tracked, RollEligibility,
45};
46use super::spread_analytics::{analytics_from_json, entry_analytics_pass, passes_min_iv_rv_ratio};
47use super::volatility::{fetch_realized_vol_pct, iv_rv_ratio};
48use super::paths::{active_state_path, load_agent_state, load_sim_agent_state};
49use super::protective;
50use super::resilience;
51use super::sim::{ensure_ledger, record_sim_entry, record_sim_exit};
52use super::schedule::{self, AgentSession};
53use super::state::{
54 candidate_fingerprint, entry_attempt_cooldown_active, entry_proceed_cache_valid,
55 is_stop_loss_exit_reason, is_thesis_exit_reason, record_entry_attempt, record_stop_loss_exit,
56 save_state, stop_loss_re_entry_blocked, update_peak_profit_pct, AgentState, EntryProceedCache,
57 PendingOrder, PendingOrderAction, RedeploySignal, TrackedPosition,
58};
59use super::telegram_format::{
60 format_action_telegram, format_llm_review_telegram, format_market_open_telegram,
61 format_overnight_telegram, record_llm_telegram_sent, should_send_llm_telegram,
62};
63use crate::ui::agent_health::SharedAgentHealth;
64
65const MAX_ENTRY_QUOTE_WIDTH_RATIO: f64 = 1.0;
66const EXIT_LIMIT_SLIPPAGE: f64 = 0.05;
67
68#[derive(Debug, Clone, serde::Serialize)]
69pub struct TickResult {
70 pub session: String,
71 pub at_open: bool,
72 pub next_sleep_seconds: u64,
73 pub signals: Vec<Value>,
74 pub actions: Vec<Value>,
75 pub skipped: Vec<String>,
76 pub monitored_positions: Vec<Value>,
77 pub llm_review: Option<Value>,
78 #[serde(default)]
79 pub monitoring: Value,
80}
81
82pub async fn run_agent_loop(
83 runtime: &RuntimeConfig,
84 rules_path: &std::path::Path,
85 once: bool,
86 watch_health: Option<SharedAgentHealth>,
87) -> Result<()> {
88 let rules = RulesConfig::load(rules_path)?;
89 let state_path = active_state_path(rules_path, runtime.simulate);
90 let mut state = if runtime.simulate {
91 load_sim_agent_state(rules_path, &rules.agent_id)
92 } else {
93 load_agent_state(rules_path, &rules.agent_id)
94 };
95 state.agent_id = rules.agent_id.clone();
96
97 trade_audio::init(runtime.no_audio);
98
99 if rules.execution.require_preview && !runtime.safety.require_preview_before_place {
100 anyhow::bail!(
101 "rules require preview before order placement, but safety.json has require_preview_before_place=false"
102 );
103 }
104
105 if !runtime.dry_run && !runtime.simulate {
106 crate::safety::require_trading_approval(
107 runtime,
108 "agent run",
109 &format!("Run options agent `{}`", rules.agent_id),
110 )?;
111 }
112
113 let trader = runtime.build_api()?;
114 let market = runtime.build_market_api()?;
115 let telegram = TelegramNotifier::from_env(&rules.notify.telegram)
116 .ok()
117 .flatten();
118 let llm_client = if rules.llm.enabled {
119 match OpenRouterClient::from_env() {
120 Ok(client) => Some(client),
121 Err(e) => {
122 let msg = format!(
123 "LLM disabled for this run: {e:#} (set OPENROUTER_API_KEY or llm.enabled: false)"
124 );
125 let _ = super::paths::append_agent_log(rules_path, &msg);
126 if let Some(h) = watch_health.as_ref() {
127 if let Ok(mut g) = h.lock() {
128 g.record_error(&msg);
129 }
130 }
131 None
132 }
133 }
134 } else {
135 None
136 };
137
138 let mut consecutive_errors = 0u32;
139 let mut last_logged_error: Option<String> = None;
140 let mut auth_notified = false;
141
142 loop {
143 if let Err(err) = trader.client().oauth().ensure_access_token().await {
145 tracing::debug!("access token probe: {err}");
146 }
147
148 match tick_once(
149 runtime,
150 rules_path,
151 &rules,
152 &trader,
153 &market,
154 &mut state,
155 llm_client.as_ref(),
156 telegram.as_ref(),
157 )
158 .await
159 {
160 Ok(result) => {
161 if consecutive_errors > 0 {
162 let msg = format!(
163 "agent recovered after {consecutive_errors} failure(s)"
164 );
165 let _ = super::paths::append_agent_log(rules_path, &msg);
166 if let Some(tg) = telegram.as_ref() {
167 if tg.wants_actions() {
168 let _ = tg
169 .send(&format!(
170 "schwab [{}]\n✓ AGENT RECOVERED\nafter {consecutive_errors} failure(s) — exits armed again",
171 rules.agent_id
172 ))
173 .await;
174 }
175 }
176 }
177 consecutive_errors = 0;
178 auth_notified = false;
179 state.last_tick = Some(Utc::now());
180 save_state(&state_path, &state)?;
181
182 if let Some(h) = watch_health.as_ref() {
183 if let Ok(mut g) = h.lock() {
184 g.record_tick();
185 }
186 }
187
188 let tick_payload = json!({
189 "agent_id": rules.agent_id,
190 "session": result.session,
191 "at_open": result.at_open,
192 "next_sleep_seconds": result.next_sleep_seconds,
193 "signals": result.signals,
194 "actions": result.actions,
195 "skipped": result.skipped,
196 "monitored_positions": result.monitored_positions,
197 "llm_review": result.llm_review,
198 "dry_run": runtime.dry_run,
199 "simulate": runtime.simulate,
200 });
201
202 if runtime.suppress_tick_output {
203 let summary = format!(
204 "{} session={} signals={} actions={} skipped={}",
205 Utc::now().format("%Y-%m-%d %H:%M:%S"),
206 result.session,
207 result.signals.len(),
208 result.actions.len(),
209 result.skipped.len()
210 );
211 let _ = super::paths::append_agent_log(rules_path, &summary);
212 } else {
213 runtime.emit(crate::output::ResponseEnvelope::ok(
214 if once { "agent run once" } else { "agent tick" },
215 tick_payload,
216 ));
217 }
218
219 notify_tick(telegram.as_ref(), &rules, &result, runtime.dry_run).await;
220
221 if once {
222 break;
223 }
224
225 tokio::time::sleep(std::time::Duration::from_secs(result.next_sleep_seconds)).await;
226 }
227 Err(e) => {
228 consecutive_errors += 1;
229 let err_str = format!("{e:#}");
230 let class = resilience::classify_agent_error(&e);
234 let class_label = resilience::class_label(class);
235
236 if class == resilience::AgentErrorClass::AuthFatal {
237 let msg = "agent degraded: Schwab login required (refresh token invalid). Run: schwab auth login — retrying";
238 if last_logged_error.as_deref() != Some(msg) {
239 let _ = super::paths::append_agent_log(rules_path, msg);
240 last_logged_error = Some(msg.to_string());
241 }
242 if let Some(h) = watch_health.as_ref() {
243 if let Ok(mut g) = h.lock() {
244 g.record_error(msg);
245 }
246 }
247 if !auth_notified {
248 notify_auth_required(telegram.as_ref(), msg).await;
249 auth_notified = true;
250 }
251 if once {
252 return Err(e);
253 }
254 let backoff = resilience::backoff_seconds(class, consecutive_errors);
255 tokio::time::sleep(Duration::from_secs(backoff)).await;
256 continue;
257 }
258
259 let msg = format!("tick error ({class_label}, #{consecutive_errors}): {err_str}");
260 if last_logged_error.as_deref() != Some(msg.as_str()) {
261 let _ = super::paths::append_agent_log(rules_path, &msg);
262 last_logged_error = Some(msg.clone());
263 }
264 if let Some(h) = watch_health.as_ref() {
265 if let Ok(mut g) = h.lock() {
266 g.record_error(&msg);
267 }
268 }
269 if consecutive_errors == 1 || consecutive_errors % 10 == 0 {
272 if let Some(tg) = telegram.as_ref() {
273 if tg.wants_actions() {
274 let short: String = err_str.chars().take(280).collect();
275 let _ = tg
276 .send(&format!(
277 "schwab [{}]\n⚠ AGENT DEGRADED ({class_label}) ×{consecutive_errors}\n{short}\nAgent staying up; backing off and retrying",
278 rules.agent_id
279 ))
280 .await;
281 }
282 }
283 }
284 if once {
285 return Err(e);
286 }
287 let backoff = resilience::backoff_seconds(class, consecutive_errors);
288 tokio::time::sleep(Duration::from_secs(backoff)).await;
289 }
290 }
291 }
292
293 if let Some(h) = watch_health.as_ref() {
294 if let Ok(mut g) = h.lock() {
295 g.loop_running = false;
296 }
297 }
298
299 Ok(())
300}
301
302#[allow(clippy::too_many_arguments)]
303pub async fn tick_once(
304 runtime: &RuntimeConfig,
305 rules_path: &std::path::Path,
306 rules: &RulesConfig,
307 trader: &Arc<TraderApi>,
308 market: &Arc<MarketDataApi>,
309 state: &mut AgentState,
310 llm_client: Option<&OpenRouterClient>,
311 telegram: Option<&TelegramNotifier>,
312) -> Result<TickResult> {
313 let today = Local::now().date_naive();
314 state.reset_daily_if_needed(today);
315 state.tick_count += 1;
316
317 check_auth_reminder(state, telegram).await;
318 maybe_clear_stale_redeploy(state);
319
320 let mut result = TickResult {
321 session: "unknown".into(),
322 at_open: false,
323 next_sleep_seconds: rules.schedule.tick_interval_seconds.max(5),
324 signals: vec![],
325 actions: vec![],
326 skipped: vec![],
327 monitored_positions: vec![],
328 llm_review: None,
329 monitoring: Value::Null,
330 };
331
332 if runtime.simulate {
333 let _ = ensure_ledger(state, rules);
334 result
335 .skipped
336 .push("simulate — using paper state (no Schwab reconcile)".into());
337 } else {
338 reconcile_open_positions(trader, state, rules).await?;
339 poll_pending_orders(trader, state, rules, &mut result).await?;
340 let protective_summary =
341 protective::reconcile_protective_orders(runtime, trader, rules, state).await;
342 result.monitoring = json!({
343 "unprotected_count": protective_summary.unprotected_count,
344 "protective_orders_placed_this_tick": protective_summary.placed,
345 "protective_orders_failed_this_tick": protective_summary.failed,
346 });
347 }
348
349 let (market_open, hours) = fetch_option_market_status(market).await?;
350 state.last_market_open = Some(market_open);
351 if let Some(ref h) = hours {
352 let _ = crate::ui::market_status::save_market_hours_cache(rules_path, h);
353 }
354 let transition =
355 schedule::resolve_session(market_open, &rules.schedule, state.last_session.as_deref());
356 result.session = transition.session.as_str().to_string();
357 result.next_sleep_seconds = transition.sleep_seconds;
358 state.last_session = Some(result.session.clone());
359
360 match transition.session {
361 AgentSession::Idle => {
362 result.skipped.push("market closed (option hours)".into());
363 return Ok(result);
364 }
365 AgentSession::Overnight => {
366 return tick_overnight(runtime, rules, state, llm_client, telegram, &mut result).await;
367 }
368 AgentSession::RegularHours => {
369 if transition.just_opened {
370 result.at_open = true;
371 result
372 .skipped
373 .push("market open — full evaluation (mechanical exits + live marks)".into());
374 }
375 state.regular_tick_count += 1;
376 }
377 }
378
379 let prior_open_playbook = if result.at_open {
380 let pb = state.open_playbook.take();
381 notify_at_open(
382 telegram,
383 pb.as_ref(),
384 state.open_positions.len(),
385 )
386 .await;
387 pb
388 } else {
389 None
390 };
391
392 let entries_paused = rules.risk.max_trades_per_day > 0
393 && state.trades_capacity_used() >= rules.risk.max_trades_per_day;
394 let blocked_events_active = !rules.risk.blocked_events.is_empty();
395 let active_blocked_dates = rules.risk.active_blocked_date_labels(today);
396 let blocked_dates_active = !active_blocked_dates.is_empty();
397 let calendar_or_manual_block = blocked_events_active || blocked_dates_active;
398
399 if let Some(obj) = result.monitoring.as_object_mut() {
400 obj.insert(
401 "blocked_dates_active".into(),
402 json!(active_blocked_dates),
403 );
404 obj.insert(
405 "blocked_events_active".into(),
406 json!(blocked_events_active),
407 );
408 } else {
409 result.monitoring = json!({
410 "blocked_dates_active": active_blocked_dates,
411 "blocked_events_active": blocked_events_active,
412 });
413 }
414
415 let mut regime_snap: Option<OptionsRegimeSnapshot> = None;
416 if rules.regime.enabled && !entries_paused && !calendar_or_manual_block {
417 match detect_options_regime(market, &rules.regime).await {
418 Ok(snap) => {
419 state.last_regime = Some(snap.to_json());
420 if snap.pause_entries {
421 result.skipped.push(format!(
422 "regime pause — {} (preferred={})",
423 snap.class, snap.preferred_strategy
424 ));
425 }
426 regime_snap = Some(snap);
427 }
428 Err(e) => {
429 result
430 .skipped
431 .push(format!("regime detect failed (continuing with defaults): {e:#}"));
432 }
433 }
434 }
435
436 if runtime.simulate {
438 let position_ids: Vec<String> = state.open_positions.keys().cloned().collect();
439 for position_id in position_ids {
440 let Some(tracked) = state.open_positions.get(&position_id).cloned() else {
441 continue;
442 };
443 let Some(group) = option_group_from_tracked(&tracked) else {
444 result.skipped.push(format!(
445 "simulate exit skip {position_id}: missing entry_params"
446 ));
447 continue;
448 };
449 let monitor =
450 evaluate_position_monitor(market, &group, rules, today, Some(&tracked)).await?;
451 if let Some(profit) = monitor.mark.as_ref().map(|m| m.profit_pct) {
452 if let Some(p) = state.open_positions.get_mut(&position_id) {
453 update_peak_profit_pct(p, profit);
454 }
455 }
456 if !group.legs.is_empty() {
457 result.monitored_positions.push(monitor.snapshot);
458 }
459 if let Some(eval) = monitor.exit {
460 if is_thesis_exit_reason(&eval.reason) {
461 state.redeploy_signal = Some(RedeploySignal {
462 at: Utc::now(),
463 reason: eval.reason.clone(),
464 underlying: Some(tracked.underlying.clone()),
465 });
466 }
467
468 let mut handled_as_roll = false;
469 if is_stop_loss_exit_reason(&eval.reason) && !runtime.dry_run {
470 match try_defensive_roll(
471 runtime,
472 trader,
473 market,
474 rules_path,
475 rules,
476 state,
477 today,
478 &tracked.account_hash,
479 &position_id,
480 &tracked,
481 &group,
482 &eval,
483 monitor.analytics.as_ref().and_then(|a| a.short_otm_pct),
484 true,
485 telegram,
486 )
487 .await
488 {
489 Ok(DefensiveRollOutcome::Success(detail)) => {
490 handled_as_roll = true;
491 result.signals.push(detail.clone());
492 result.actions.push(detail.clone());
493 result.skipped.push(format!("rolled {position_id}"));
494 notify_action(telegram, "DEFENSIVE ROLL", &detail).await;
495 }
496 Ok(DefensiveRollOutcome::NotAttempted { reason }) => {
497 result.skipped.push(format!(
498 "roll skipped {position_id}: {reason} — stop_loss"
499 ));
500 }
501 Ok(DefensiveRollOutcome::StopCompleted { detail, reason }) => {
502 handled_as_roll = true;
503 record_stop_loss_exit(state, &tracked.underlying);
504 state.redeploy_signal = None;
505 state.entry_proceed_cache = None;
506 result.skipped.push(format!(
507 "roll aborted after close {position_id}: {reason} — stop_loss"
508 ));
509 if let Some(d) = detail {
510 result.actions.push(d);
511 }
512 let exit =
513 exit_signal_json_for_account(&tracked.account_hash, &group, &eval);
514 result.signals.push(exit.clone());
515 notify_action(telegram, "SIM EXIT", &exit).await;
516 }
517 Err(err) => {
518 result.skipped.push(format!(
519 "roll error {position_id}: {err:#} — stop_loss"
520 ));
521 }
522 }
523 }
524
525 if !handled_as_roll {
526 if is_stop_loss_exit_reason(&eval.reason) {
527 record_stop_loss_exit(state, &tracked.underlying);
528 state.redeploy_signal = None;
529 state.entry_proceed_cache = None;
530 }
531 let exit =
532 exit_signal_json_for_account(&tracked.account_hash, &group, &eval);
533 result.signals.push(exit.clone());
534 if !runtime.dry_run {
535 match record_sim_exit(
536 rules_path,
537 state,
538 rules,
539 &position_id,
540 &eval.reason,
541 &eval.mark,
542 &exit,
543 ) {
544 Ok(action) => {
545 result.actions.push(action);
546 notify_action(telegram, "SIM EXIT", &exit).await;
547 }
548 Err(err) => {
549 result
550 .skipped
551 .push(format!("sim exit failed {position_id}: {err:#}"));
552 }
553 }
554 }
555 }
556 }
557 }
558 } else {
559 for account in rules.enabled_accounts() {
560 let legs = list_option_positions(trader, Some(&account.hash)).await?;
561 let groups = group_option_legs(&legs);
562 for group in &groups {
563 let tracked_owned = find_tracked_position(state, &account.hash, group).cloned();
564 let monitor = evaluate_position_monitor(
565 market,
566 group,
567 rules,
568 today,
569 tracked_owned.as_ref(),
570 )
571 .await?;
572 let position_id = stable_position_key(&account.hash, group);
573 if let Some(profit) = monitor.mark.as_ref().map(|m| m.profit_pct) {
574 if let Some(p) = state.open_positions.get_mut(&position_id) {
575 update_peak_profit_pct(p, profit);
576 }
577 }
578
579 if !group.legs.is_empty() {
580 result.monitored_positions.push(monitor.snapshot);
581 }
582
583 if let Some(eval) = monitor.exit {
584 if is_thesis_exit_reason(&eval.reason) {
585 state.redeploy_signal = Some(RedeploySignal {
586 at: Utc::now(),
587 reason: eval.reason.clone(),
588 underlying: Some(group.underlying.clone()),
589 });
590 }
591
592 let mut handled_as_roll = false;
593 if is_stop_loss_exit_reason(&eval.reason)
594 && !runtime.dry_run
595 && tracked_owned.is_some()
596 && !state.has_pending_position(&position_id)
597 {
598 let tracked_ref = tracked_owned.as_ref().expect("checked is_some");
599 match try_defensive_roll(
600 runtime,
601 trader,
602 market,
603 rules_path,
604 rules,
605 state,
606 today,
607 &account.hash,
608 &position_id,
609 tracked_ref,
610 group,
611 &eval,
612 monitor.analytics.as_ref().and_then(|a| a.short_otm_pct),
613 false,
614 telegram,
615 )
616 .await
617 {
618 Ok(DefensiveRollOutcome::Success(detail)) => {
619 handled_as_roll = true;
620 result.signals.push(detail.clone());
621 result.actions.push(detail.clone());
622 result.skipped.push(format!("rolled {position_id}"));
623 notify_action(telegram, "DEFENSIVE ROLL", &detail).await;
624 }
625 Ok(DefensiveRollOutcome::NotAttempted { reason }) => {
626 result.skipped.push(format!(
627 "roll skipped {position_id}: {reason} — stop_loss"
628 ));
629 }
630 Ok(DefensiveRollOutcome::StopCompleted { detail, reason }) => {
631 handled_as_roll = true;
632 record_stop_loss_exit(state, &group.underlying);
633 state.redeploy_signal = None;
634 state.entry_proceed_cache = None;
635 result.skipped.push(format!(
636 "roll aborted after close {position_id}: {reason} — stop_loss"
637 ));
638 if let Some(d) = detail {
639 result.actions.push(d);
640 }
641 let exit =
642 exit_signal_json_for_account(&account.hash, group, &eval);
643 result.signals.push(exit.clone());
644 notify_action(telegram, "EXIT", &exit).await;
645 }
646 Err(err) => {
647 result.skipped.push(format!(
648 "roll error {position_id}: {err:#} — stop_loss"
649 ));
650 }
651 }
652 }
653
654 if !handled_as_roll {
655 if is_stop_loss_exit_reason(&eval.reason) {
656 record_stop_loss_exit(state, &group.underlying);
657 state.redeploy_signal = None;
658 state.entry_proceed_cache = None;
659 }
660 let exit = exit_signal_json_for_account(&account.hash, group, &eval);
661 result.signals.push(exit.clone());
662 if state.has_pending_position(&position_id) {
663 result
664 .skipped
665 .push(format!("exit already pending for {position_id}"));
666 } else if !runtime.dry_run {
667 if let Ok(action) = execute_exit(
668 runtime,
669 trader,
670 &account.hash,
671 rules,
672 group,
673 &exit,
674 state,
675 )
676 .await
677 {
678 result.actions.push(action);
679 notify_action(telegram, "EXIT", &exit).await;
680 }
681 }
682 }
683 }
684 }
685 }
686 }
687
688 let halted_before = state.trading_halted_reason.clone();
690 let drawdown = update_drawdown(state, rules, &result.monitored_positions);
691 if let Some(obj) = result.monitoring.as_object_mut() {
692 obj.insert("drawdown".into(), drawdown_to_json(&drawdown));
693 obj.insert(
694 "trading_halted_reason".into(),
695 json!(state.trading_halted_reason),
696 );
697 } else {
698 result.monitoring = json!({
699 "drawdown": drawdown_to_json(&drawdown),
700 "trading_halted_reason": state.trading_halted_reason,
701 });
702 }
703 if state.trading_halted_reason != halted_before {
704 if let Some(reason) = &state.trading_halted_reason {
705 notify_trading_halted(telegram, rules, reason).await;
706 } else if halted_before.is_some() {
707 notify_trading_recovered(telegram, rules).await;
708 }
709 }
710
711 let mut pending_entries: Vec<(String, StrategyKind, Value)> = Vec::new();
712 let regime_pause = regime_snap.as_ref().is_some_and(|s| s.pause_entries);
713 let preferred = regime_snap
714 .as_ref()
715 .map(|s| s.preferred_strategy.as_str())
716 .unwrap_or("put_credit");
717 let trading_halted = state.trading_halted_reason.is_some();
718
719 if entries_paused {
720 result.skipped.push(format!(
721 "new entries paused — max_trades_per_day ({}) reached or reserved by pending entries \
722 (soft churn cap; hard gates are max_open_positions + portfolio/trade risk)",
723 rules.risk.max_trades_per_day
724 ));
725 } else if trading_halted {
726 result.skipped.push(format!(
727 "new entries paused — {}",
728 state
729 .trading_halted_reason
730 .as_deref()
731 .unwrap_or("trading halted")
732 ));
733 } else if blocked_events_active {
734 result.skipped.push(format!(
735 "new entries paused — blocked_events active: {}",
736 rules.risk.blocked_events.join(", ")
737 ));
738 } else if blocked_dates_active {
739 result.skipped.push(format!(
740 "new entries paused — blocked_dates active: {}",
741 active_blocked_dates.join(", ")
742 ));
743 } else if regime_pause || preferred.eq_ignore_ascii_case("pause") {
744 result.skipped.push(format!(
745 "new entries paused — hostile/pause regime ({})",
746 regime_snap
747 .as_ref()
748 .map(|s| s.class.as_str())
749 .unwrap_or("pause")
750 ));
751 } else if !any_entry_slots_available(rules, state) {
752 result
753 .skipped
754 .push("entry scan skipped — all enabled accounts at max_open_positions".into());
755 } else {
756 let scan_watchlist = watchlist_for_scan(rules, state);
757 if let Some(sig) = &state.redeploy_signal {
758 if let Some(u) = &sig.underlying {
759 if redeploy_cooldown_active(rules, sig) {
760 let remaining = rules
761 .exit_rules
762 .thesis
763 .redeploy_cooldown_minutes
764 .unwrap_or(0) as i64
765 - Utc::now().signed_duration_since(sig.at).num_minutes();
766 result.skipped.push(format!(
767 "redeploy cooldown — skip {} for ~{}m after {}",
768 u.to_uppercase(),
769 remaining.max(0),
770 sig.reason
771 ));
772 }
773 }
774 }
775 let (want_vertical, want_condor, vertical_type) = if rules.regime.enabled {
776 let want_vertical = preferred.eq_ignore_ascii_case("put_credit")
777 || preferred.eq_ignore_ascii_case("call_credit");
778 let want_condor = preferred.eq_ignore_ascii_case("iron_condor");
779 let vertical_type = if preferred.eq_ignore_ascii_case("call_credit") {
780 "call_credit"
781 } else {
782 "put_credit"
783 };
784 (want_vertical, want_condor, vertical_type)
785 } else {
786 (
787 true,
788 true,
789 rules.entry_rules.vertical.r#type.as_str(),
790 )
791 };
792
793 for account in rules.enabled_accounts() {
794 match scan_entries_for_account(
795 market,
796 rules,
797 state,
798 &account.hash,
799 today,
800 &scan_watchlist,
801 want_vertical,
802 want_condor,
803 vertical_type,
804 )
805 .await
806 {
807 Ok(found) => {
808 for skip in found.skipped {
809 result.skipped.push(skip);
810 }
811 pending_entries.extend(found.entries);
812 }
813 Err(e) => result.skipped.push(format!("entry scan {}: {e:#}", account.hash)),
814 }
815 }
816 if rules.regime.enabled {
817 result.skipped.push(format!(
818 "regime={} preferred={} vix={}",
819 regime_snap
820 .as_ref()
821 .map(|s| s.class.as_str())
822 .unwrap_or("?"),
823 preferred,
824 regime_snap
825 .as_ref()
826 .and_then(|s| s.vix)
827 .map(|v| format!("{v:.1}"))
828 .unwrap_or_else(|| "?".into())
829 ));
830 }
831 }
832
833 for (_, _, signal) in &pending_entries {
834 result.signals.push(signal.clone());
835 }
836
837 let mut llm_veto_entries = false;
839 let mut llm_close_ids: Vec<String> = Vec::new();
840 let has_candidates = !pending_entries.is_empty();
841 let has_positions = !result.monitored_positions.is_empty();
842 let mechanical_alert =
843 mechanical_alert_from_monitored(&result.monitored_positions, rules.exit_rules.dte_close);
844
845 if let Some(client) = llm_client {
846 if let Some(phase) = resolve_llm_phase(
847 rules,
848 state,
849 has_candidates,
850 has_positions,
851 result.at_open,
852 mechanical_alert,
853 ) {
854 let use_web =
855 should_use_web_research(rules, state) && matches!(phase, LlmPhase::Selection);
856
857 let open_playbook_for_llm = match phase {
858 LlmPhase::Monitor if result.at_open => prior_open_playbook.as_ref(),
859 _ => None,
860 };
861
862 let context = json!({
863 "agent_id": rules.agent_id,
864 "tick": state.regular_tick_count,
865 "date": today.to_string(),
866 "phase": match phase {
867 LlmPhase::Selection => "selection",
868 LlmPhase::Monitor => "monitor",
869 LlmPhase::OvernightDigest => "overnight_digest",
870 },
871 "at_open": result.at_open,
872 "mechanical_alert": mechanical_alert,
873 "market": market_context_summary_for_llm(),
874 "regime": state.last_regime.clone(),
875 "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
876 "open_positions": result.monitored_positions,
877 "open_playbook": open_playbook_for_llm,
878 "candidate_entries": pending_entries.iter().map(|(_, _, s)| s).collect::<Vec<_>>(),
879 "recent_signals": result.signals,
880 "watchlist": rules.watchlist_items(),
881 "entry_policy": {
882 "mode": format!("{:?}", rules.entry_policy.mode),
883 "fallback_only_after_primary_exhausted": rules.entry_policy.fallback_only_after_primary_exhausted,
884 },
885 "risk": {
886 "max_trades_per_day": rules.risk.max_trades_per_day,
887 "trades_today": state.trades_today,
888 "max_risk_per_trade_usd": rules.risk.max_risk_per_trade_usd,
889 "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
890 "blocked_dates_active": active_blocked_dates,
891 "blocked_events": rules.risk.blocked_events,
892 },
893 });
894
895 match client.review(&rules.llm, phase, &context, use_web).await {
896 Ok(review) => {
897 let review_json = review.to_json();
898 result.llm_review = Some(review_json.clone());
899 state.last_llm_review_tick = Some(state.regular_tick_count);
900 state.llm_review_count += 1;
901 state.last_llm_summary = Some(review_json.clone());
902 state.record_action("llm_review", review_json.clone());
903
904 if rules.llm.veto_entries
905 && matches!(phase, LlmPhase::Selection)
906 && review.should_veto_entries()
907 {
908 llm_veto_entries = true;
909 state.entry_proceed_cache = None;
910 result
911 .skipped
912 .push(format!("LLM veto entries: {}", review.entry_reasoning));
913 } else if matches!(phase, LlmPhase::Selection) {
914 let fingerprints: Vec<String> = pending_entries
915 .iter()
916 .filter_map(|(_, _, s)| candidate_fingerprint(s))
917 .collect();
918 if review.entry_recommendation.eq_ignore_ascii_case("proceed") {
919 state.entry_proceed_cache = Some(EntryProceedCache {
920 at: Utc::now(),
921 candidate_fingerprints: fingerprints,
922 });
923 } else {
924 state.entry_proceed_cache = None;
925 }
926 }
927
928 if rules.llm.allow_llm_exits {
929 for pos in review.urgent_close_positions() {
930 llm_close_ids.push(pos.position_id.clone());
931 }
932 }
933
934 let notify = should_send_llm_telegram(
935 &review,
936 &rules.notify.telegram,
937 state,
938 Utc::now(),
939 );
940 if notify {
941 notify_llm(
942 telegram,
943 &review,
944 &result.monitored_positions,
945 state,
946 )
947 .await;
948 }
949 }
950 Err(e) => {
951 result.skipped.push(format!("LLM review failed: {e:#}"));
952 if rules.llm.veto_entries && matches!(phase, LlmPhase::Selection) {
953 llm_veto_entries = true;
954 state.entry_proceed_cache = None;
955 result
956 .skipped
957 .push("LLM selection failed closed — entries deferred".into());
958 }
959 }
960 }
961 }
962 } else if entry_execution_requires_llm(rules) && has_candidates {
963 llm_veto_entries = true;
964 result
965 .skipped
966 .push("LLM selection unavailable — entries deferred".into());
967 }
968
969 let mut entries_blocked = llm_veto_entries;
970 if !entries_blocked && has_candidates && entry_execution_requires_llm(rules) {
971 let fingerprints: Vec<String> = pending_entries
972 .iter()
973 .filter_map(|(_, _, s)| candidate_fingerprint(s))
974 .collect();
975 let cache_ok = state
976 .entry_proceed_cache
977 .as_ref()
978 .is_some_and(|cache| entry_proceed_cache_valid(&rules.entry_policy, cache, &fingerprints));
979 if !cache_ok {
980 entries_blocked = true;
981 result.skipped.push(
982 "entry deferred — awaiting LLM proceed for current candidate (see proceed_cache_minutes)"
983 .into(),
984 );
985 }
986 }
987
988 if !llm_close_ids.is_empty() && !runtime.dry_run && !runtime.simulate {
990 for account in rules.enabled_accounts() {
991 let legs = list_option_positions(trader, Some(&account.hash)).await?;
992 let groups = group_option_legs(&legs);
993 for group in &groups {
994 let position_id = stable_position_key(&account.hash, group);
995 if !llm_close_ids
996 .iter()
997 .any(|id| id == &position_id || id == &group.id)
998 {
999 continue;
1000 }
1001 if state.has_pending_position(&position_id) {
1002 result
1003 .skipped
1004 .push(format!("LLM exit already pending for {position_id}"));
1005 continue;
1006 }
1007 let exit = json!({
1008 "type": "exit",
1009 "reason": "llm_recommendation",
1010 "position_id": position_id,
1011 "legacy_position_id": group.id,
1012 "underlying": group.underlying,
1013 "expiry": group.expiry,
1014 });
1015 result.signals.push(exit.clone());
1016 if let Ok(action) =
1017 execute_exit(runtime, trader, &account.hash, rules, group, &exit, state).await
1018 {
1019 result.actions.push(action);
1020 notify_action(telegram, "LLM EXIT", &exit).await;
1021 }
1022 }
1023 }
1024 }
1025
1026 if !entries_blocked && !runtime.dry_run {
1028 if runtime.simulate {
1029 for (account_hash, kind, signal) in pending_entries {
1030 match record_sim_entry(rules_path, state, rules, &account_hash, kind, &signal) {
1031 Ok(detail) => {
1032 if detail
1033 .get("fill_status")
1034 .and_then(|v| v.as_str())
1035 == Some("FILLED")
1036 {
1037 result.actions.push(detail.clone());
1038 notify_action(telegram, "SIM ENTRY", &detail).await;
1039 } else {
1040 result.skipped.push(format!(
1041 "sim entry skipped: {}",
1042 detail
1043 .get("reason")
1044 .and_then(|v| v.as_str())
1045 .unwrap_or("unknown")
1046 ));
1047 }
1048 }
1049 Err(err) => result.skipped.push(format!("sim entry failed: {err:#}")),
1050 }
1051 }
1052 } else {
1053 for (account_hash, kind, signal) in pending_entries {
1054 if let Ok(Some(a)) =
1055 maybe_execute_entry(runtime, trader, &account_hash, kind, &signal, rules, state)
1056 .await
1057 {
1058 result.actions.push(a.clone());
1059 let label = a
1060 .pointer("/fill_status")
1061 .and_then(|v| v.as_str())
1062 .unwrap_or("UNKNOWN");
1063 match label {
1064 "FILLED" => notify_action(telegram, "ENTRY FILLED", &a).await,
1065 "WORKING" | "ACCEPTED" | "PENDING_ACTIVATION" | "QUEUED" => {
1066 notify_action(telegram, "ORDER WORKING (limit)", &a).await
1067 }
1068 other if is_failure_status(other) => {
1069 notify_action(telegram, "ORDER REJECTED", &a).await
1070 }
1071 _ => notify_action(telegram, "ORDER", &a).await,
1072 }
1073 }
1074 }
1075 }
1076 }
1077
1078 Ok(result)
1079}
1080
1081fn should_run_llm_review(rules: &RulesConfig, state: &AgentState, has_positions: bool) -> bool {
1082 let every = rules.llm.effective_llm_review_ticks(
1083 has_positions,
1084 min_open_position_dte(state),
1085 rules.exit_rules.dte_close,
1086 );
1087 schedule::should_run_monitor_review(
1088 state.regular_tick_count,
1089 state.last_llm_review_tick,
1090 every,
1091 )
1092}
1093
1094fn min_open_position_dte(state: &AgentState) -> Option<i64> {
1095 let today = Local::now().date_naive();
1096 state
1097 .open_positions
1098 .values()
1099 .filter_map(|p| {
1100 chrono::NaiveDate::parse_from_str(&p.expiry, "%Y-%m-%d")
1101 .ok()
1102 .map(|exp| days_to_expiry(exp, today))
1103 })
1104 .min()
1105}
1106
1107async fn tick_overnight(
1108 _runtime: &RuntimeConfig,
1109 rules: &RulesConfig,
1110 state: &mut AgentState,
1111 llm_client: Option<&OpenRouterClient>,
1112 telegram: Option<&TelegramNotifier>,
1113 result: &mut TickResult,
1114) -> Result<TickResult> {
1115 let today = Local::now().date_naive();
1116 result.monitored_positions = overnight_position_snapshots(state);
1117
1118 if result.monitored_positions.is_empty() {
1119 result.skipped.push("overnight — no open positions".into());
1120 } else {
1121 result.skipped.push(format!(
1122 "overnight — monitoring {} open position(s) (no live marks)",
1123 result.monitored_positions.len()
1124 ));
1125 }
1126
1127 let digest_due =
1128 schedule::should_run_overnight_digest(state, &rules.schedule.overnight, Utc::now());
1129
1130 if !digest_due {
1131 result.skipped.push(format!(
1132 "overnight digest next in ~{} min",
1133 rules.schedule.overnight.tick_interval_seconds / 60
1134 ));
1135 return Ok(result.clone());
1136 }
1137
1138 if !rules.llm.enabled {
1139 result
1140 .skipped
1141 .push("overnight digest skipped (llm.enabled false)".into());
1142 return Ok(result.clone());
1143 }
1144
1145 let Some(client) = llm_client else {
1146 result
1147 .skipped
1148 .push("overnight digest skipped (no LLM client)".into());
1149 return Ok(result.clone());
1150 };
1151
1152 let context = json!({
1153 "agent_id": rules.agent_id,
1154 "date": today.to_string(),
1155 "phase": "overnight_digest",
1156 "market_closed": true,
1157 "open_positions": result.monitored_positions,
1158 "prior_open_playbook": state.open_playbook,
1159 "watchlist": rules.watchlist_items(),
1160 "exit_rules": super::exits::exit_rules_summary(&rules.exit_rules),
1161 "note": "Build open playbook for next session. No chain data. new_entries must be skip.",
1162 });
1163
1164 match client
1165 .review(&rules.llm, LlmPhase::OvernightDigest, &context, true)
1166 .await
1167 {
1168 Ok(review) => {
1169 let review_json = review.to_json();
1170 result.llm_review = Some(review_json.clone());
1171 state.last_overnight_digest_at = Some(Utc::now());
1172 state.open_playbook = Some(json!({
1173 "updated_at": Utc::now(),
1174 "market_commentary": review.market_commentary,
1175 "positions": review.position_reviews,
1176 "risk_alerts": review.risk_alerts,
1177 "open_actions": review.entry_reasoning,
1178 }));
1179 state.last_llm_summary = Some(review_json.clone());
1180 state.record_action("overnight_digest", review_json);
1181
1182 let should_notify = if rules.schedule.overnight.alert_on_risk_only {
1183 !review.risk_alerts.is_empty() || is_llm_urgent_for_overnight(&review)
1184 } else {
1185 should_send_llm_telegram(&review, &rules.notify.telegram, state, Utc::now())
1186 };
1187 if should_notify {
1188 notify_overnight_alert(telegram, &review, state).await;
1189 }
1190 }
1191 Err(e) => result
1192 .skipped
1193 .push(format!("overnight digest failed: {e:#}")),
1194 }
1195
1196 Ok(result.clone())
1197}
1198
1199fn overnight_position_snapshots(state: &AgentState) -> Vec<Value> {
1200 state
1201 .open_positions
1202 .values()
1203 .map(|p| {
1204 json!({
1205 "position_id": p.position_id,
1206 "underlying": p.underlying,
1207 "expiry": p.expiry,
1208 "strategy": p.strategy,
1209 "contracts": p.contracts.max(1),
1210 "entry_credit": p.entry_credit,
1211 "max_loss_usd": p.max_loss_usd,
1212 "status": "overnight (reconciled, no live marks)",
1213 })
1214 })
1215 .collect()
1216}
1217
1218async fn check_auth_reminder(state: &mut AgentState, telegram: Option<&TelegramNotifier>) {
1219 let Ok(config) = schwab_api::ClientConfig::from_env() else {
1220 return;
1221 };
1222 let oauth = schwab_api::OAuthClient::new(config);
1223 let Ok(Some(tokens)) = oauth.status().await else {
1224 return;
1225 };
1226 let reminder = assess_refresh_token(&tokens);
1227 maybe_notify_auth_reminder(telegram, state, &reminder).await;
1228}
1229
1230async fn poll_pending_orders(
1231 trader: &Arc<TraderApi>,
1232 state: &mut AgentState,
1233 rules: &RulesConfig,
1234 result: &mut TickResult,
1235) -> Result<()> {
1236 let pending = state.pending_orders.clone();
1237 for pending_order in pending {
1238 let order = match trader
1239 .orders()
1240 .get(&pending_order.account_hash, &pending_order.order_id)
1241 .await
1242 {
1243 Ok(order) => order,
1244 Err(e) => {
1245 result.skipped.push(format!(
1246 "pending order {} status unavailable: {e:#}",
1247 pending_order.order_id
1248 ));
1249 continue;
1250 }
1251 };
1252 let status = order_status(&order).unwrap_or_else(|| "UNKNOWN".into());
1253 if let Some(stored) = state
1254 .pending_orders
1255 .iter_mut()
1256 .find(|p| p.order_id == pending_order.order_id)
1257 {
1258 stored.last_status = Some(status.clone());
1259 }
1260
1261 match pending_order.action {
1262 PendingOrderAction::Entry => {
1263 if status == "FILLED" {
1264 state.remove_pending_order(&pending_order.order_id);
1265 if let Some(detail) = pending_order.detail.as_ref() {
1266 track_filled_entry_from_pending(state, detail, &pending_order);
1267 }
1268 state.trades_today = state.trades_today.saturating_add(1);
1269 state.record_action(
1270 "entry_filled",
1271 json!({
1272 "order_id": pending_order.order_id,
1273 "position_id": pending_order.position_id,
1274 "status": status,
1275 }),
1276 );
1277 trade_audio::speak(TradeAudioEvent::EntryOpened);
1278 } else if is_failure_status(&status) || is_terminal_status(&status) {
1279 state.remove_pending_order(&pending_order.order_id);
1280 state.record_action(
1281 "entry_terminal",
1282 json!({
1283 "order_id": pending_order.order_id,
1284 "position_id": pending_order.position_id,
1285 "status": status,
1286 }),
1287 );
1288 } else if pending_is_stale(&pending_order, rules) {
1289 match trader
1290 .orders()
1291 .cancel(&pending_order.account_hash, &pending_order.order_id)
1292 .await
1293 {
1294 Ok(cancel) => {
1295 state.remove_pending_order(&pending_order.order_id);
1296 state.record_action(
1297 "entry_cancelled_stale",
1298 json!({
1299 "order_id": pending_order.order_id,
1300 "position_id": pending_order.position_id,
1301 "status": status,
1302 "cancel": {
1303 "status": cancel.status,
1304 "location": cancel.location,
1305 },
1306 }),
1307 );
1308 trade_audio::speak(TradeAudioEvent::EntryCancelled);
1309 }
1310 Err(e) => result.skipped.push(format!(
1311 "stale entry order {} cancel failed: {e:#}",
1312 pending_order.order_id
1313 )),
1314 }
1315 }
1316 }
1317 PendingOrderAction::Exit => {
1318 if status == "FILLED" {
1319 state.remove_pending_order(&pending_order.order_id);
1320 if let Some(tracked) = state
1321 .open_positions
1322 .get(&pending_order.position_id)
1323 .cloned()
1324 {
1325 let debit = pending_order
1326 .detail
1327 .as_ref()
1328 .and_then(|d| d.pointer("/signal/mark/debit_to_close"))
1329 .or_else(|| {
1330 pending_order
1331 .detail
1332 .as_ref()
1333 .and_then(|d| d.get("limit_price"))
1334 })
1335 .and_then(|v| v.as_f64())
1336 .unwrap_or(0.0);
1337 record_live_realized_pnl(state, &tracked, debit);
1338 }
1339 state.open_positions.remove(&pending_order.position_id);
1340 state.record_action(
1341 "exit_filled",
1342 json!({
1343 "order_id": pending_order.order_id,
1344 "position_id": pending_order.position_id,
1345 "status": status,
1346 }),
1347 );
1348 let reason = pending_order
1349 .detail
1350 .as_ref()
1351 .and_then(|d| d.pointer("/signal/reason"))
1352 .and_then(|v| v.as_str())
1353 .unwrap_or("");
1354 trade_audio::speak_exit_reason(reason);
1355 } else if is_failure_status(&status) || is_terminal_status(&status) {
1356 state.remove_pending_order(&pending_order.order_id);
1357 state.record_action(
1358 "exit_terminal_position_kept",
1359 json!({
1360 "order_id": pending_order.order_id,
1361 "position_id": pending_order.position_id,
1362 "status": status,
1363 }),
1364 );
1365 }
1366 }
1367 }
1368 }
1369 state.clear_legacy_pending_ids();
1370 Ok(())
1371}
1372
1373fn pending_is_stale(pending: &PendingOrder, rules: &RulesConfig) -> bool {
1374 let timeout = rules.execution.fill_timeout_seconds.max(1) as i64;
1375 (Utc::now() - pending.submitted_at).num_seconds() >= timeout
1376}
1377
1378fn track_filled_entry_from_pending(state: &mut AgentState, detail: &Value, pending: &PendingOrder) {
1379 let Some(signal) = detail.get("signal") else {
1380 return;
1381 };
1382 let Some(params) = signal.get("params") else {
1383 return;
1384 };
1385 let underlying = params
1386 .get("underlying")
1387 .and_then(|v| v.as_str())
1388 .unwrap_or("")
1389 .to_string();
1390 let expiry = params
1391 .get("expiry")
1392 .and_then(|v| v.as_str())
1393 .unwrap_or("")
1394 .to_string();
1395 let strategy = signal
1396 .get("strategy")
1397 .and_then(|v| v.as_str())
1398 .unwrap_or("vertical")
1399 .to_string();
1400 let contracts = params
1401 .get("contracts")
1402 .and_then(|v| v.as_f64())
1403 .unwrap_or(1.0)
1404 .round()
1405 .max(1.0) as u32;
1406 let entry_credit = signal.get("estimated_credit").and_then(|v| v.as_f64());
1407
1408 state
1409 .open_positions
1410 .entry(pending.position_id.clone())
1411 .or_insert_with(|| TrackedPosition {
1412 position_id: pending.position_id.clone(),
1413 account_hash: pending.account_hash.clone(),
1414 underlying,
1415 expiry,
1416 strategy,
1417 opened_at: Utc::now(),
1418 entry_credit,
1419 max_loss_usd: pending.reserved_risk_usd,
1420 contracts,
1421 entry_params: None,
1422 peak_profit_pct: None,
1423 entry_pop_pct: signal
1424 .pointer("/market_context/spread_pop_pct")
1425 .and_then(|v| v.as_f64()),
1426 entry_short_delta: signal
1427 .pointer("/market_context/short_delta")
1428 .and_then(|v| v.as_f64())
1429 .map(f64::abs),
1430 ..Default::default()
1431 });
1432}
1433
1434fn watchlist_for_scan(rules: &RulesConfig, state: &AgentState) -> Vec<WatchlistItemConfig> {
1435 let mut items = rules.watchlist_items();
1436 if let Some(sig) = &state.redeploy_signal {
1437 if let Some(u) = &sig.underlying {
1438 let key = u.to_uppercase();
1439 if redeploy_cooldown_active(rules, sig) {
1440 items.retain(|i| !i.symbol.eq_ignore_ascii_case(&key));
1441 } else if rules.entry_policy.promote_redeploy_symbol {
1442 if let Some(pos) = items.iter().position(|i| i.symbol.eq_ignore_ascii_case(&key)) {
1443 let item = items.remove(pos);
1444 items.insert(0, item);
1445 }
1446 }
1447 }
1448 }
1449 items
1450}
1451
1452struct ScanEntriesResult {
1453 entries: Vec<(String, StrategyKind, Value)>,
1454 skipped: Vec<String>,
1455}
1456
1457async fn scan_entries_for_account(
1458 market: &MarketDataApi,
1459 rules: &RulesConfig,
1460 state: &AgentState,
1461 account_hash: &str,
1462 today: NaiveDate,
1463 scan_watchlist: &[WatchlistItemConfig],
1464 want_vertical: bool,
1465 want_condor: bool,
1466 vertical_type: &str,
1467) -> Result<ScanEntriesResult> {
1468 let mut result = ScanEntriesResult {
1469 entries: Vec::new(),
1470 skipped: Vec::new(),
1471 };
1472 let policy = &rules.entry_policy;
1473
1474 let mut primary_items: Vec<&WatchlistItemConfig> = Vec::new();
1475 let mut fallback_items: Vec<&WatchlistItemConfig> = Vec::new();
1476 for item in scan_watchlist {
1477 match item.role {
1478 WatchlistRole::Primary => primary_items.push(item),
1479 WatchlistRole::Fallback => fallback_items.push(item),
1480 }
1481 }
1482
1483 scan_watchlist_tier(
1484 market,
1485 rules,
1486 state,
1487 account_hash,
1488 today,
1489 &primary_items,
1490 want_vertical,
1491 want_condor,
1492 vertical_type,
1493 &mut result,
1494 )
1495 .await?;
1496
1497 let need_fallback = result.entries.is_empty()
1498 && policy.fallback_only_after_primary_exhausted
1499 && !fallback_items.is_empty();
1500 let scan_all_fallbacks = !policy.fallback_only_after_primary_exhausted;
1501
1502 if need_fallback || scan_all_fallbacks {
1503 scan_watchlist_tier(
1504 market,
1505 rules,
1506 state,
1507 account_hash,
1508 today,
1509 &fallback_items,
1510 want_vertical,
1511 want_condor,
1512 vertical_type,
1513 &mut result,
1514 )
1515 .await?;
1516 }
1517
1518 if policy.mode == EntryScanMode::FirstQualifying && result.entries.len() > 1 {
1519 result.entries.truncate(1);
1520 }
1521
1522 Ok(result)
1523}
1524
1525async fn scan_watchlist_tier(
1526 market: &MarketDataApi,
1527 rules: &RulesConfig,
1528 state: &AgentState,
1529 account_hash: &str,
1530 today: NaiveDate,
1531 items: &[&WatchlistItemConfig],
1532 want_vertical: bool,
1533 want_condor: bool,
1534 vertical_type: &str,
1535 result: &mut ScanEntriesResult,
1536) -> Result<()> {
1537 let policy = &rules.entry_policy;
1538
1539 for item in items {
1540 if policy.mode == EntryScanMode::FirstQualifying && !result.entries.is_empty() {
1541 break;
1542 }
1543
1544 let sym = item.symbol.to_uppercase();
1545 if !rules.risk.allowed_underlyings.is_empty()
1546 && !rules
1547 .risk
1548 .allowed_underlyings
1549 .iter()
1550 .any(|u| u.eq_ignore_ascii_case(&sym))
1551 {
1552 continue;
1553 }
1554 if underlying_entry_cap_reached(rules, state, account_hash, &sym) {
1555 continue;
1556 }
1557 if let Some(group_name) = correlation_group_cap_reached(rules, state, account_hash, &sym) {
1558 result.skipped.push(format!(
1559 "correlation group cap — skip {sym} (group {group_name})"
1560 ));
1561 continue;
1562 }
1563 if let Some(days) = stop_loss_re_entry_blocked(rules, state, &sym) {
1564 result.skipped.push(format!(
1565 "stop-loss re-entry cooldown — skip {sym} for ~{days}d"
1566 ));
1567 continue;
1568 }
1569
1570 let vertical_rules = rules.effective_vertical_entry(&sym);
1571
1572 if rules.strategies.vertical.enabled && want_vertical {
1573 match evaluate_vertical_entry(
1574 market,
1575 rules,
1576 &vertical_rules,
1577 &sym,
1578 today,
1579 state,
1580 account_hash,
1581 vertical_type,
1582 )
1583 .await
1584 {
1585 Ok(Some(signal)) => {
1586 result.entries.push((
1587 account_hash.to_string(),
1588 StrategyKind::Vertical,
1589 signal,
1590 ));
1591 if policy.mode == EntryScanMode::FirstQualifying {
1592 break;
1593 }
1594 }
1595 Ok(None) => {}
1596 Err(e) => result.skipped.push(format!("{sym} vertical: {e:#}")),
1597 }
1598 }
1599
1600 if policy.mode == EntryScanMode::FirstQualifying && !result.entries.is_empty() {
1601 break;
1602 }
1603
1604 if rules.strategies.iron_condor.enabled && want_condor {
1605 match evaluate_condor_entry(market, rules, &sym, today, state, account_hash).await {
1606 Ok(Some(signal)) => {
1607 result.entries.push((
1608 account_hash.to_string(),
1609 StrategyKind::IronCondor,
1610 signal,
1611 ));
1612 if policy.mode == EntryScanMode::FirstQualifying {
1613 break;
1614 }
1615 }
1616 Ok(None) => {}
1617 Err(e) => result.skipped.push(format!("{sym} iron_condor: {e:#}")),
1618 }
1619 }
1620 }
1621
1622 Ok(())
1623}
1624
1625fn entry_execution_requires_llm(rules: &RulesConfig) -> bool {
1626 rules.llm.enabled && rules.llm.veto_entries && rules.entry_policy.require_llm_proceed
1627}
1628
1629fn redeploy_cooldown_active(rules: &RulesConfig, sig: &RedeploySignal) -> bool {
1630 let cooldown = rules
1631 .exit_rules
1632 .thesis
1633 .redeploy_cooldown_minutes
1634 .unwrap_or(0);
1635 if cooldown == 0 {
1636 return false;
1637 }
1638 Utc::now().signed_duration_since(sig.at).num_minutes() < cooldown as i64
1639}
1640
1641fn underlying_entry_cap_reached(
1642 rules: &RulesConfig,
1643 state: &AgentState,
1644 account_hash: &str,
1645 underlying: &str,
1646) -> bool {
1647 let Some(cap) = rules.risk.max_open_for_underlying(underlying) else {
1648 return false;
1649 };
1650 let open = state.count_open_for_underlying(account_hash, underlying);
1651 let pending = state.pending_entry_count_for_underlying(account_hash, underlying);
1652 open + pending >= cap
1653}
1654
1655fn correlation_group_cap_reached(
1657 rules: &RulesConfig,
1658 state: &AgentState,
1659 account_hash: &str,
1660 underlying: &str,
1661) -> Option<String> {
1662 let group = rules.risk.correlation_group_for(underlying)?;
1663 if group.max_open == 0 {
1664 return None;
1665 }
1666 let open = state.count_open_in_symbol_set(account_hash, &group.symbols);
1667 let pending = state.pending_entry_count_in_symbol_set(account_hash, &group.symbols);
1668 if open + pending >= group.max_open {
1669 Some(group.name.clone())
1670 } else {
1671 None
1672 }
1673}
1674
1675fn maybe_clear_stale_redeploy(state: &mut AgentState) {
1676 let Some(sig) = &state.redeploy_signal else {
1677 return;
1678 };
1679 if Utc::now().signed_duration_since(sig.at).num_hours() >= 4 {
1680 state.redeploy_signal = None;
1681 }
1682}
1683
1684fn clear_redeploy_after_entry(state: &mut AgentState) {
1685 state.redeploy_signal = None;
1686}
1687
1688async fn notify_trading_halted(
1689 telegram: Option<&TelegramNotifier>,
1690 rules: &RulesConfig,
1691 reason: &str,
1692) {
1693 let Some(tg) = telegram else { return };
1694 if !tg.wants_actions() {
1695 return;
1696 }
1697 let _ = tg
1698 .send(&format!(
1699 "schwab [{}]\n⚠ TRADING HALTED\n{reason}\nExits still armed.",
1700 rules.agent_id
1701 ))
1702 .await;
1703}
1704
1705async fn notify_trading_recovered(telegram: Option<&TelegramNotifier>, rules: &RulesConfig) {
1706 let Some(tg) = telegram else { return };
1707 if !tg.wants_actions() {
1708 return;
1709 }
1710 let _ = tg
1711 .send(&format!(
1712 "schwab [{}]\n✓ TRADING RESUMED\nDrawdown halt cleared — new entries allowed.",
1713 rules.agent_id
1714 ))
1715 .await;
1716}
1717
1718async fn notify_at_open(
1719 telegram: Option<&TelegramNotifier>,
1720 playbook: Option<&Value>,
1721 open_position_count: usize,
1722) {
1723 let Some(tg) = telegram else { return };
1724 if !tg.wants_actions() {
1725 return;
1726 }
1727 let body = format_market_open_telegram(playbook, open_position_count);
1728 let _ = tg.send(&body).await;
1729}
1730
1731async fn notify_overnight_alert(
1732 telegram: Option<&TelegramNotifier>,
1733 review: &super::llm::LlmReview,
1734 state: &mut AgentState,
1735) {
1736 let Some(tg) = telegram else { return };
1737 if !tg.wants_actions() {
1738 return;
1739 }
1740 let now = Utc::now();
1741 let _ = tg
1742 .send(&format_overnight_telegram(review))
1743 .await;
1744 record_llm_telegram_sent(state, review, now);
1745}
1746
1747fn is_llm_urgent_for_overnight(review: &super::llm::LlmReview) -> bool {
1748 super::telegram_format::is_llm_urgent(review)
1749 || review
1750 .position_reviews
1751 .iter()
1752 .any(|p| p.urgency.eq_ignore_ascii_case("high"))
1753}
1754
1755async fn fetch_option_market_status(market: &MarketDataApi) -> Result<(bool, Option<Value>)> {
1756 let hours = market.markets().hours("option", None).await?;
1757 let open = crate::market_hours::option_market_open_from_hours(&hours, chrono::Utc::now())
1758 .unwrap_or(false);
1759 Ok((open, Some(hours)))
1760}
1761
1762fn resolve_llm_phase(
1763 rules: &RulesConfig,
1764 state: &AgentState,
1765 has_candidates: bool,
1766 has_positions: bool,
1767 at_open: bool,
1768 mechanical_alert: bool,
1769) -> Option<LlmPhase> {
1770 if !rules.llm.enabled {
1771 return None;
1772 }
1773 if !has_candidates && !has_positions {
1774 return None;
1775 }
1776 if at_open && !has_candidates && !mechanical_alert {
1777 return None;
1778 }
1779 if !should_run_llm_review(rules, state, has_positions) {
1780 return None;
1781 }
1782 if has_candidates {
1783 return Some(LlmPhase::Selection);
1784 }
1785 Some(LlmPhase::Monitor)
1786}
1787
1788fn mechanical_alert_from_monitored(monitored_positions: &[Value], dte_close: u32) -> bool {
1790 monitored_positions.iter().any(|pos| {
1791 if let Some(rules) = pos.get("mechanical_rules") {
1792 if rules
1793 .get("stop_triggered")
1794 .and_then(|v| v.as_bool())
1795 .unwrap_or(false)
1796 {
1797 return true;
1798 }
1799 if rules
1800 .get("profit_target_triggered")
1801 .and_then(|v| v.as_bool())
1802 .unwrap_or(false)
1803 {
1804 return true;
1805 }
1806 }
1807 pos.get("dte")
1808 .and_then(|v| v.as_i64())
1809 .is_some_and(|dte| dte <= dte_close as i64)
1810 })
1811}
1812
1813fn any_entry_slots_available(rules: &RulesConfig, state: &AgentState) -> bool {
1814 let pending = state.pending_entry_count();
1815 for account in rules.enabled_accounts() {
1816 if rules.strategies.vertical.enabled
1817 && state.count_open_for_strategy(&account.hash, StrategyKind::Vertical) + pending
1818 < rules.entry_rules.vertical.max_open_positions
1819 {
1820 return true;
1821 }
1822 if rules.strategies.iron_condor.enabled
1823 && state.count_open_for_strategy(&account.hash, StrategyKind::IronCondor) + pending
1824 < rules.entry_rules.iron_condor.max_open_positions
1825 {
1826 return true;
1827 }
1828 }
1829 false
1830}
1831
1832fn should_use_web_research(rules: &RulesConfig, state: &AgentState) -> bool {
1834 if rules.llm.web_research_every_reviews == 0 {
1835 return false;
1836 }
1837 let next_review = state.llm_review_count + 1;
1838 next_review % rules.llm.web_research_every_reviews.max(1) == 0
1839}
1840
1841async fn notify_tick(
1842 telegram: Option<&TelegramNotifier>,
1843 rules: &RulesConfig,
1844 result: &TickResult,
1845 dry_run: bool,
1846) {
1847 let Some(tg) = telegram else { return };
1848 if !tg.wants_tick_summary() {
1849 return;
1850 }
1851 let prefix = if dry_run { "[DRY RUN] " } else { "" };
1852 let msg = format!(
1853 "{prefix}Agent `{}` tick\nsignals: {}\nactions: {}\nskipped: {}",
1854 rules.agent_id,
1855 result.signals.len(),
1856 result.actions.len(),
1857 result.skipped.len()
1858 );
1859 let _ = tg.send(&msg).await;
1860}
1861
1862async fn notify_action(telegram: Option<&TelegramNotifier>, kind: &str, detail: &Value) {
1863 trade_audio::speak_from_action(kind, detail);
1864 let Some(tg) = telegram else { return };
1865 if !tg.wants_actions() {
1866 return;
1867 }
1868 let Some(msg) = format_action_telegram(kind, detail) else {
1869 return;
1870 };
1871 let _ = tg.send(&msg).await;
1872}
1873
1874async fn notify_llm(
1875 telegram: Option<&TelegramNotifier>,
1876 review: &super::llm::LlmReview,
1877 monitored: &[Value],
1878 state: &mut AgentState,
1879) {
1880 let Some(tg) = telegram else { return };
1881 if !tg.wants_actions() {
1882 return;
1883 }
1884 let now = Utc::now();
1885 let _ = tg
1886 .send(&format_llm_review_telegram(review, monitored))
1887 .await;
1888 record_llm_telegram_sent(state, review, now);
1889}
1890
1891async fn evaluate_vertical_entry(
1892 market: &MarketDataApi,
1893 rules: &RulesConfig,
1894 entry: &VerticalEntryRules,
1895 underlying: &str,
1896 today: NaiveDate,
1897 state: &AgentState,
1898 account_hash: &str,
1899 spread_type: &str,
1900) -> Result<Option<Value>> {
1901 let open_count = state.count_open_for_strategy(account_hash, StrategyKind::Vertical);
1902 if open_count + state.pending_entry_count() >= entry.max_open_positions {
1903 return Ok(None);
1904 }
1905
1906 let is_put = !spread_type.eq_ignore_ascii_case("call_credit");
1907 let contract_type = if is_put { "PUT" } else { "CALL" };
1908 let map_key = if is_put {
1909 "putExpDateMap"
1910 } else {
1911 "callExpDateMap"
1912 };
1913
1914 let chain = market
1915 .chains()
1916 .get(&ChainQuery {
1917 symbol: underlying,
1918 contract_type: Some(contract_type),
1919 strike_count: Some(120),
1920 include_underlying_quote: Some(true),
1921 ..Default::default()
1922 })
1923 .await?;
1924
1925 let (expiry, strike_map) =
1926 pick_expiry_map(&chain, map_key, entry.dte_min, entry.dte_max, today)?;
1927 let underlying_price = chain
1928 .pointer("/underlying/last")
1929 .or_else(|| chain.pointer("/underlyingPrice"))
1930 .and_then(|v| v.as_f64())
1931 .unwrap_or(0.0);
1932
1933 if underlying_price <= 0.0 {
1934 return Ok(None);
1935 }
1936
1937 let short_strike = pick_strike_by_delta(
1938 &strike_map,
1939 entry.short_delta_min,
1940 entry.short_delta_max,
1941 is_put,
1942 )
1943 .or_else(|| pick_otm_strike(&strike_map, underlying_price, 0.05, is_put).ok())
1944 .context("no suitable short strike")?;
1945 let long_strike = pick_wing_strike(&strike_map, short_strike, entry.max_width, is_put)?;
1946 let width = (short_strike - long_strike).abs();
1947 if width < entry.max_width * 0.5 {
1948 return Ok(None);
1949 }
1950 let credit = estimate_spread_credit(&strike_map, short_strike, long_strike)?;
1951 if credit < entry.min_credit {
1952 return Ok(None);
1953 }
1954 if !entry_quality_ok(&strike_map, short_strike, long_strike, width, credit, entry) {
1955 return Ok(None);
1956 }
1957
1958 let lookback = rules.regime.realized_vol_lookback.max(5);
1959 let realized_vol_pct = fetch_realized_vol_pct(market, underlying, lookback)
1960 .await
1961 .ok()
1962 .flatten();
1963
1964 let market_context = vertical_entry_market_context(
1965 &chain,
1966 underlying,
1967 expiry,
1968 today,
1969 &strike_map,
1970 short_strike,
1971 long_strike,
1972 width,
1973 credit,
1974 entry.max_contracts_per_trade as f64,
1975 is_put,
1976 realized_vol_pct,
1977 );
1978
1979 let analytics = analytics_from_json(market_context.get("analytics").unwrap_or(&json!({})));
1980 if let Some(ref a) = analytics {
1981 if !entry_analytics_pass(entry, a) {
1982 return Ok(None);
1983 }
1984 if candidate_fails_thesis_gates(rules, a).is_some() {
1985 return Ok(None);
1986 }
1987 } else if entry.reject_short_inside_1sigma || entry.min_iv_rv_ratio.is_some() {
1988 return Ok(None);
1990 }
1991
1992 let right = if is_put { 'P' } else { 'C' };
1993 let candidate_id = candidate_position_id(
1994 account_hash,
1995 underlying,
1996 &expiry.to_string(),
1997 StrategyKind::Vertical.as_str(),
1998 vec![
1999 (right, short_strike, "S"),
2000 (right, long_strike, "L"),
2001 ],
2002 );
2003 if state.open_positions.contains_key(&candidate_id)
2004 || state.has_pending_position(&candidate_id)
2005 || has_legacy_duplicate(state, account_hash, underlying, &expiry.to_string())
2006 {
2007 return Ok(None);
2008 }
2009
2010 let resolved_type = if is_put { "put_credit" } else { "call_credit" };
2011 let params = VerticalParams {
2012 underlying: underlying.to_string(),
2013 expiry: expiry.to_string(),
2014 spread_type: resolved_type.to_string(),
2015 short_strike,
2016 long_strike,
2017 contracts: entry.max_contracts_per_trade as f64,
2018 limit_credit: Some(credit),
2019 limit_debit: None,
2020 duration: None,
2021 session: None,
2022 };
2023
2024 Ok(Some(json!({
2025 "type": "entry",
2026 "strategy": "vertical",
2027 "account_hash": account_hash,
2028 "position_id": candidate_id,
2029 "params": params,
2030 "estimated_credit": credit,
2031 "market_context": market_context,
2032 "regime_preferred": resolved_type,
2033 })))
2034}
2035
2036async fn evaluate_condor_entry(
2037 market: &MarketDataApi,
2038 rules: &RulesConfig,
2039 underlying: &str,
2040 today: NaiveDate,
2041 state: &AgentState,
2042 account_hash: &str,
2043) -> Result<Option<Value>> {
2044 let entry = &rules.entry_rules.iron_condor;
2045 let open_count = state.count_open_for_strategy(account_hash, StrategyKind::IronCondor);
2046 if open_count + state.pending_entry_count() >= entry.max_open_positions {
2047 return Ok(None);
2048 }
2049
2050 let chain = market
2051 .chains()
2052 .get(&ChainQuery {
2053 symbol: underlying,
2054 contract_type: Some("ALL"),
2055 strike_count: Some(40),
2056 include_underlying_quote: Some(true),
2057 ..Default::default()
2058 })
2059 .await?;
2060
2061 let underlying_price = chain
2062 .pointer("/underlying/last")
2063 .or_else(|| chain.pointer("/underlyingPrice"))
2064 .and_then(|v| v.as_f64())
2065 .unwrap_or(0.0);
2066 if underlying_price <= 0.0 {
2067 return Ok(None);
2068 }
2069
2070 let (expiry, put_map) =
2071 pick_expiry_map(&chain, "putExpDateMap", entry.dte_min, entry.dte_max, today)?;
2072 let (_, call_map) = pick_expiry_map(
2073 &chain,
2074 "callExpDateMap",
2075 entry.dte_min,
2076 entry.dte_max,
2077 today,
2078 )?;
2079
2080 let put_short = pick_otm_strike(&put_map, underlying_price, entry.short_delta, true)?;
2081 let put_long = put_short - entry.wing_width;
2082 let call_short = pick_otm_strike(&call_map, underlying_price, entry.short_delta, false)?;
2083 let call_long = call_short + entry.wing_width;
2084
2085 let put_credit = estimate_spread_credit(&put_map, put_short, put_long)?;
2086 let call_credit = estimate_spread_credit(&call_map, call_short, call_long)?;
2087 let total_credit = put_credit + call_credit;
2088 if total_credit < entry.min_credit {
2089 return Ok(None);
2090 }
2091
2092 if entry.min_iv_rv_ratio.is_some() {
2093 let lookback = rules.regime.realized_vol_lookback.max(5);
2094 let realized_vol_pct = fetch_realized_vol_pct(market, underlying, lookback)
2095 .await
2096 .ok()
2097 .flatten();
2098 let chain_iv = chain.get("volatility").and_then(|v| v.as_f64());
2099 let ratio = iv_rv_ratio(chain_iv, realized_vol_pct);
2100 if !passes_min_iv_rv_ratio(entry.min_iv_rv_ratio, ratio) {
2101 return Ok(None);
2102 }
2103 }
2104
2105 let candidate_id = candidate_position_id(
2106 account_hash,
2107 underlying,
2108 &expiry.to_string(),
2109 StrategyKind::IronCondor.as_str(),
2110 vec![
2111 ('P', put_short, "S"),
2112 ('P', put_long, "L"),
2113 ('C', call_short, "S"),
2114 ('C', call_long, "L"),
2115 ],
2116 );
2117 if state.open_positions.contains_key(&candidate_id)
2118 || state.has_pending_position(&candidate_id)
2119 || has_legacy_duplicate(state, account_hash, underlying, &expiry.to_string())
2120 {
2121 return Ok(None);
2122 }
2123
2124 let params = IronCondorParams {
2125 underlying: underlying.to_string(),
2126 expiry: expiry.to_string(),
2127 put_short,
2128 put_long,
2129 call_short,
2130 call_long,
2131 contracts: entry.max_contracts_per_trade as f64,
2132 limit_credit: total_credit,
2133 duration: None,
2134 session: None,
2135 };
2136
2137 Ok(Some(json!({
2138 "type": "entry",
2139 "strategy": "iron_condor",
2140 "account_hash": account_hash,
2141 "position_id": candidate_id,
2142 "params": params,
2143 "estimated_credit": total_credit,
2144 })))
2145}
2146
2147fn pick_expiry_map(
2148 chain: &Value,
2149 map_key: &str,
2150 dte_min: u32,
2151 dte_max: u32,
2152 today: NaiveDate,
2153) -> Result<(NaiveDate, Value)> {
2154 let map = chain
2155 .get(map_key)
2156 .context("chain missing exp date map")?
2157 .as_object()
2158 .context("exp date map not an object")?;
2159
2160 for key in map.keys() {
2161 let date_part = key.split(':').next().unwrap_or(key);
2162 if let Ok(expiry) = parse_expiry(date_part) {
2163 let dte = days_to_expiry(expiry, today);
2164 if dte >= dte_min as i64 && dte <= dte_max as i64 {
2165 if let Some(strikes) = map.get(key) {
2166 return Ok((expiry, strikes.clone()));
2167 }
2168 }
2169 }
2170 }
2171 anyhow::bail!("no expiry found in DTE window {dte_min}-{dte_max}")
2172}
2173
2174fn pick_otm_strike(strike_map: &Value, underlying: f64, otm_pct: f64, puts: bool) -> Result<f64> {
2175 let target = if puts {
2176 underlying * (1.0 - otm_pct)
2177 } else {
2178 underlying * (1.0 + otm_pct)
2179 };
2180 pick_nearest_strike(strike_map, target)
2181}
2182
2183fn pick_wing_strike(strike_map: &Value, short_strike: f64, width: f64, puts: bool) -> Result<f64> {
2185 let target = if puts {
2186 short_strike - width
2187 } else {
2188 short_strike + width
2189 };
2190 let obj = strike_map.as_object().context("strike map not object")?;
2191 let candidates: Vec<f64> = obj
2192 .keys()
2193 .filter_map(|k| k.parse::<f64>().ok())
2194 .filter(|s| {
2195 if puts {
2196 *s < short_strike - f64::EPSILON
2197 } else {
2198 *s > short_strike + f64::EPSILON
2199 }
2200 })
2201 .collect();
2202 if candidates.is_empty() {
2203 anyhow::bail!("no wing strikes beyond short {short_strike}");
2204 }
2205 candidates
2206 .into_iter()
2207 .min_by(|a, b| {
2208 ((*a - target).abs())
2209 .partial_cmp(&(*b - target).abs())
2210 .unwrap()
2211 })
2212 .context("no wing strike candidates")
2213}
2214
2215fn pick_nearest_strike(strike_map: &Value, target: f64) -> Result<f64> {
2216 let obj = strike_map.as_object().context("strike map not object")?;
2217 let candidates: Vec<f64> = obj.keys().filter_map(|k| k.parse::<f64>().ok()).collect();
2218 if candidates.is_empty() {
2219 anyhow::bail!("no strikes in chain");
2220 }
2221 candidates
2222 .into_iter()
2223 .min_by(|a, b| {
2224 ((*a - target).abs())
2225 .partial_cmp(&(*b - target).abs())
2226 .unwrap()
2227 })
2228 .context("no strike candidates")
2229}
2230
2231fn pick_strike_by_delta(
2233 strike_map: &Value,
2234 delta_min: f64,
2235 delta_max: f64,
2236 puts: bool,
2237) -> Option<f64> {
2238 let obj = strike_map.as_object()?;
2239 let target = (delta_min + delta_max) / 2.0;
2240 let mut best: Option<(f64, f64)> = None;
2241 for (key, contracts) in obj {
2242 let strike = key.parse::<f64>().ok()?;
2243 let delta = contracts.as_array()?.first()?.get("delta")?.as_f64()?;
2244 if puts && delta > 0.0 {
2246 continue;
2247 }
2248 if !puts && delta < 0.0 {
2249 continue;
2250 }
2251 let abs_delta = delta.abs();
2252 if abs_delta < delta_min || abs_delta > delta_max {
2253 continue;
2254 }
2255 let dist = (abs_delta - target).abs();
2256 if best.is_none() || dist < best.unwrap().1 {
2257 best = Some((strike, dist));
2258 }
2259 }
2260 best.map(|(s, _)| s)
2261}
2262
2263fn estimate_spread_credit(put_map: &Value, short: f64, long: f64) -> Result<f64> {
2264 let short_bid = strike_quote_field(put_map, short, "bid")?;
2265 let long_ask = strike_quote_field(put_map, long, "ask")?;
2266 Ok((short_bid - long_ask).max(0.0))
2267}
2268
2269fn strike_quote_field(strike_map: &Value, strike: f64, field: &str) -> Result<f64> {
2270 for key in strike_key_candidates(strike) {
2271 if let Some(val) = strike_map
2272 .get(&key)
2273 .and_then(|contracts| contracts.as_array()?.first())
2274 .and_then(|c| c.get(field))
2275 .and_then(|v| v.as_f64())
2276 {
2277 return Ok(val);
2278 }
2279 }
2280 anyhow::bail!("missing {field} for strike {strike}")
2281}
2282
2283fn strike_key_candidates(strike: f64) -> Vec<String> {
2284 vec![
2285 format!("{strike:.1}"),
2286 format!("{strike:.0}"),
2287 strike.to_string(),
2288 ]
2289}
2290
2291fn entry_quality_ok(
2292 strike_map: &Value,
2293 short_strike: f64,
2294 long_strike: f64,
2295 width: f64,
2296 credit: f64,
2297 entry: &VerticalEntryRules,
2298) -> bool {
2299 if width <= f64::EPSILON || credit <= f64::EPSILON {
2300 return false;
2301 }
2302 let min_ctw = entry.min_credit_to_width_pct.unwrap_or(12.5);
2303 let credit_to_width_pct = (credit / width) * 100.0;
2304 if credit_to_width_pct < min_ctw {
2305 return false;
2306 }
2307 let short_quote_width = quote_width(strike_map, short_strike).unwrap_or(f64::INFINITY);
2308 let long_quote_width = quote_width(strike_map, long_strike).unwrap_or(f64::INFINITY);
2309 (short_quote_width + long_quote_width) <= credit * MAX_ENTRY_QUOTE_WIDTH_RATIO
2310}
2311
2312fn quote_width(strike_map: &Value, strike: f64) -> Option<f64> {
2313 let bid = strike_quote_field(strike_map, strike, "bid").ok()?;
2314 let ask = strike_quote_field(strike_map, strike, "ask").ok()?;
2315 if bid < 0.0 || ask <= 0.0 || ask < bid {
2316 return None;
2317 }
2318 Some(ask - bid)
2319}
2320
2321fn has_legacy_duplicate(
2322 state: &AgentState,
2323 account_hash: &str,
2324 underlying: &str,
2325 expiry: &str,
2326) -> bool {
2327 state
2328 .open_positions
2329 .values()
2330 .any(|p| p.account_hash == account_hash && p.underlying == underlying && p.expiry == expiry)
2331}
2332
2333fn candidate_id_from_params(account_hash: &str, kind: StrategyKind, params: &Value) -> String {
2334 let underlying = params
2335 .get("underlying")
2336 .and_then(|v| v.as_str())
2337 .unwrap_or("");
2338 let expiry = params.get("expiry").and_then(|v| v.as_str()).unwrap_or("");
2339 match kind {
2340 StrategyKind::Vertical => {
2341 let spread_type = params
2342 .get("type")
2343 .and_then(|v| v.as_str())
2344 .unwrap_or("put_credit");
2345 let put_call = if spread_type.starts_with("call") {
2346 'C'
2347 } else {
2348 'P'
2349 };
2350 let short = params
2351 .get("short_strike")
2352 .and_then(|v| v.as_f64())
2353 .unwrap_or(0.0);
2354 let long = params
2355 .get("long_strike")
2356 .and_then(|v| v.as_f64())
2357 .unwrap_or(0.0);
2358 candidate_position_id(
2359 account_hash,
2360 underlying,
2361 expiry,
2362 kind.as_str(),
2363 vec![(put_call, short, "S"), (put_call, long, "L")],
2364 )
2365 }
2366 StrategyKind::IronCondor => candidate_position_id(
2367 account_hash,
2368 underlying,
2369 expiry,
2370 kind.as_str(),
2371 vec![
2372 (
2373 'P',
2374 params
2375 .get("put_short")
2376 .and_then(|v| v.as_f64())
2377 .unwrap_or(0.0),
2378 "S",
2379 ),
2380 (
2381 'P',
2382 params
2383 .get("put_long")
2384 .and_then(|v| v.as_f64())
2385 .unwrap_or(0.0),
2386 "L",
2387 ),
2388 (
2389 'C',
2390 params
2391 .get("call_short")
2392 .and_then(|v| v.as_f64())
2393 .unwrap_or(0.0),
2394 "S",
2395 ),
2396 (
2397 'C',
2398 params
2399 .get("call_long")
2400 .and_then(|v| v.as_f64())
2401 .unwrap_or(0.0),
2402 "L",
2403 ),
2404 ],
2405 ),
2406 }
2407}
2408
2409async fn maybe_execute_entry(
2410 runtime: &RuntimeConfig,
2411 trader: &Arc<TraderApi>,
2412 account_hash: &str,
2413 kind: StrategyKind,
2414 signal: &Value,
2415 rules: &RulesConfig,
2416 state: &mut AgentState,
2417) -> Result<Option<Value>> {
2418 if runtime.dry_run {
2419 return Ok(None);
2420 }
2421
2422 let roll_replacement = signal
2423 .get("roll_replacement")
2424 .and_then(|v| v.as_bool())
2425 .unwrap_or(false);
2426
2427 if !roll_replacement
2428 && rules.risk.max_trades_per_day > 0
2429 && state.trades_capacity_used() >= rules.risk.max_trades_per_day
2430 {
2431 return Ok(Some(json!({
2432 "fill_status": "SKIPPED",
2433 "reason": "max_trades_per_day reached or reserved by pending entries",
2434 "trades_today": state.trades_today,
2435 "pending_entries": state.pending_entry_count(),
2436 "max_trades_per_day": rules.risk.max_trades_per_day,
2437 "signal": signal,
2438 })));
2439 }
2440
2441 let params = signal
2442 .get("params")
2443 .cloned()
2444 .context("signal missing params")?;
2445 let margin = crate::options::validate::estimate_order_margin(&json!({}), kind, ¶ms)?;
2446 if margin > rules.risk.max_risk_per_trade_usd {
2447 return Ok(Some(json!({
2448 "fill_status": "SKIPPED",
2449 "reason": "max_risk_per_trade_usd exceeded",
2450 "required_margin_usd": margin,
2451 "max_risk_per_trade_usd": rules.risk.max_risk_per_trade_usd,
2452 "signal": signal,
2453 })));
2454 }
2455 let reserved = state.reserved_risk_usd();
2456 if reserved + margin > rules.risk.max_portfolio_risk_usd {
2457 return Ok(Some(json!({
2458 "fill_status": "SKIPPED",
2459 "reason": "max_portfolio_risk_usd exceeded",
2460 "reserved_risk_usd": reserved,
2461 "new_order_margin_usd": margin,
2462 "projected_reserved_risk_usd": reserved + margin,
2463 "max_portfolio_risk_usd": rules.risk.max_portfolio_risk_usd,
2464 "signal": signal,
2465 })));
2466 }
2467 let position_id = signal
2468 .get("position_id")
2469 .and_then(|v| v.as_str())
2470 .map(str::to_string)
2471 .unwrap_or_else(|| candidate_id_from_params(account_hash, kind, ¶ms));
2472 if state.open_positions.contains_key(&position_id) || state.has_pending_position(&position_id) {
2473 return Ok(Some(json!({
2474 "fill_status": "SKIPPED",
2475 "reason": "position already open or pending",
2476 "position_id": position_id,
2477 "signal": signal,
2478 })));
2479 }
2480
2481 if let Some(remaining) =
2482 entry_attempt_cooldown_active(&rules.entry_policy, state, &position_id)
2483 {
2484 if !roll_replacement {
2485 return Ok(Some(json!({
2486 "fill_status": "SKIPPED",
2487 "reason": "entry_attempt_cooldown",
2488 "remaining_minutes": remaining,
2489 "position_id": position_id,
2490 "signal": signal,
2491 })));
2492 }
2493 }
2494
2495 require_trading_approval(
2496 runtime,
2497 "agent entry",
2498 &format!("Open {kind:?} on {account_hash}"),
2499 )?;
2500
2501 ensure_option_buying_power(trader, account_hash, margin).await?;
2502 let order = build_order_for_strategy(kind, ¶ms)?;
2503 runtime.safety.validate_order(&order, None, None)?;
2504
2505 record_entry_attempt(state, &position_id);
2506
2507 let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
2508
2509 let order_id = place
2510 .get("order_id")
2511 .and_then(|v| v.as_str())
2512 .map(str::to_string);
2513
2514 let wait_result = if let Some(ref order_id) = order_id {
2515 let condition = if rules.execution.wait_for_fill {
2516 WaitCondition::Terminal
2517 } else {
2518 WaitCondition::Accepted
2519 };
2520 Some(
2521 wait_for_order(
2522 trader,
2523 account_hash,
2524 order_id,
2525 WaitOptions {
2526 condition,
2527 timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
2528 interval: std::time::Duration::from_secs(5),
2529 proceed_on_partial_fill: false,
2530 requested_quantity: None,
2531 },
2532 )
2533 .await?,
2534 )
2535 } else {
2536 None
2537 };
2538
2539 let fill_status = wait_result
2540 .as_ref()
2541 .and_then(|w| w.final_status.as_deref())
2542 .unwrap_or("ACCEPTED");
2543
2544 if is_failure_status(fill_status) {
2545 let detail = json!({
2546 "signal": signal,
2547 "place": place,
2548 "wait": wait_result.as_ref().map(wait_result_json),
2549 "fill_status": fill_status,
2550 });
2551 state.record_action("entry_rejected", detail.clone());
2552 return Ok(Some(detail));
2553 }
2554
2555 if fill_status != "FILLED" && rules.execution.wait_for_fill {
2556 if let Some(order_id) = order_id.as_ref() {
2557 state.add_pending_order(PendingOrder {
2558 order_id: order_id.clone(),
2559 account_hash: account_hash.to_string(),
2560 action: PendingOrderAction::Entry,
2561 position_id: position_id.clone(),
2562 reserved_risk_usd: margin,
2563 submitted_at: Utc::now(),
2564 last_status: Some(fill_status.to_string()),
2565 detail: Some(json!({
2566 "signal": signal,
2567 "place": place.clone(),
2568 "wait": wait_result.as_ref().map(wait_result_json),
2569 })),
2570 });
2571 }
2572 let detail = json!({
2573 "signal": signal,
2574 "place": place,
2575 "wait": wait_result.as_ref().map(wait_result_json),
2576 "fill_status": fill_status,
2577 "position_id": position_id,
2578 "reserved_risk_usd": margin,
2579 "note": "Limit order working; risk and trade capacity reserved until terminal status",
2580 });
2581 state.record_action("entry_working", detail.clone());
2582 return Ok(Some(detail));
2583 }
2584
2585 if !roll_replacement {
2586 state.trades_today += 1;
2587 }
2588 let underlying = params
2589 .get("underlying")
2590 .and_then(|v| v.as_str())
2591 .unwrap_or("")
2592 .to_string();
2593 let expiry = params
2594 .get("expiry")
2595 .and_then(|v| v.as_str())
2596 .unwrap_or("")
2597 .to_string();
2598 let order_contracts = params
2599 .get("contracts")
2600 .and_then(|v| v.as_f64())
2601 .unwrap_or(1.0)
2602 .round()
2603 .max(1.0) as u32;
2604 let new_credit = signal.get("estimated_credit").and_then(|v| v.as_f64());
2605 let rolls_used = signal
2606 .get("rolls_used")
2607 .and_then(|v| v.as_u64())
2608 .unwrap_or(0) as u32;
2609 let last_roll_at = if roll_replacement {
2610 Some(Utc::now())
2611 } else {
2612 None
2613 };
2614
2615 let total_contracts;
2616 if let Some(existing) = state.open_positions.get_mut(&position_id) {
2617 let prev_contracts = existing.contracts.max(1);
2618 existing.contracts = prev_contracts + order_contracts;
2619 existing.max_loss_usd += margin;
2620 if let Some(credit) = new_credit {
2621 let blended = existing.entry_credit.unwrap_or(credit) * prev_contracts as f64
2622 + credit * order_contracts as f64;
2623 existing.entry_credit = Some(blended / existing.contracts as f64);
2624 }
2625 total_contracts = existing.contracts;
2626 } else {
2627 total_contracts = order_contracts;
2628 state.open_positions.insert(
2629 position_id.clone(),
2630 TrackedPosition {
2631 position_id: position_id.clone(),
2632 account_hash: account_hash.to_string(),
2633 underlying,
2634 expiry,
2635 strategy: kind.as_str().to_string(),
2636 opened_at: Utc::now(),
2637 entry_credit: new_credit,
2638 max_loss_usd: margin,
2639 contracts: order_contracts,
2640 entry_params: None,
2641 peak_profit_pct: None,
2642 entry_pop_pct: signal
2643 .pointer("/market_context/spread_pop_pct")
2644 .and_then(|v| v.as_f64()),
2645 entry_short_delta: signal
2646 .pointer("/market_context/short_delta")
2647 .and_then(|v| v.as_f64())
2648 .map(f64::abs),
2649 rolls_used,
2650 last_roll_at,
2651 ..Default::default()
2652 },
2653 );
2654 }
2655 clear_redeploy_after_entry(state);
2656 state.entry_proceed_cache = None;
2657 state.record_action("entry", signal.clone());
2658
2659 let mut full_params = params.clone();
2664 full_params["contracts"] = json!(total_contracts);
2665 if let Some(p) = state.open_positions.get_mut(&position_id) {
2666 p.entry_params = Some(full_params.clone());
2667 }
2668 match build_order_for_strategy(kind, &full_params) {
2669 Ok(full_order) => {
2670 place_or_replace_protective_order(
2671 runtime,
2672 trader,
2673 account_hash,
2674 rules,
2675 &full_order,
2676 &position_id,
2677 state,
2678 )
2679 .await;
2680 }
2681 Err(e) => {
2682 tracing::warn!(
2683 "could not rebuild full-size order for protective placement on \
2684 {position_id}: {e:#}"
2685 );
2686 }
2687 }
2688
2689 Ok(Some(json!({
2690 "entry": place,
2691 "signal": signal,
2692 "wait": wait_result.as_ref().map(wait_result_json),
2693 "position_id": position_id,
2694 "fill_status": fill_status,
2695 })))
2696}
2697
2698async fn place_or_replace_protective_order(
2705 runtime: &RuntimeConfig,
2706 trader: &Arc<TraderApi>,
2707 account_hash: &str,
2708 rules: &RulesConfig,
2709 entry_order: &Value,
2710 position_id: &str,
2711 state: &mut AgentState,
2712) {
2713 if !rules.execution.protective_order.enabled {
2714 return;
2715 }
2716
2717 if let Some(stale_id) = state
2718 .open_positions
2719 .get(position_id)
2720 .and_then(|p| p.protective_order_id.clone())
2721 {
2722 if let Err(e) =
2723 protective::cancel_protective_order(runtime, trader, account_hash, &stale_id).await
2724 {
2725 tracing::warn!("failed to cancel stale protective order {stale_id}: {e:#}");
2726 }
2727 if let Some(p) = state.open_positions.get_mut(position_id) {
2728 p.protective_order_id = None;
2729 p.protective_order_status = None;
2730 }
2731 }
2732
2733 let Some(entry_credit) = state
2734 .open_positions
2735 .get(position_id)
2736 .and_then(|p| p.entry_credit)
2737 else {
2738 return;
2739 };
2740
2741 let cfg = &rules.execution.protective_order;
2742 match protective::place_profit_target_order_with_retry(
2743 runtime,
2744 trader,
2745 account_hash,
2746 entry_order,
2747 entry_credit,
2748 rules.exit_rules.profit_target_pct,
2749 cfg.max_attempts,
2750 cfg.max_seconds,
2751 )
2752 .await
2753 {
2754 Ok(result) => {
2755 if let Some(p) = state.open_positions.get_mut(position_id) {
2756 p.protective_order_id = result.order_id.clone();
2757 p.protective_order_status = Some("WORKING".to_string());
2758 p.protective_order_attempts = 0;
2759 }
2760 state.record_action(
2761 "protective_order_placed",
2762 json!({
2763 "position_id": position_id,
2764 "order_id": result.order_id,
2765 "attempts": result.attempts,
2766 "order": result.order,
2767 }),
2768 );
2769 }
2770 Err(e) => {
2771 if let Some(p) = state.open_positions.get_mut(position_id) {
2772 p.protective_order_attempts = p.protective_order_attempts.saturating_add(1);
2773 }
2774 tracing::warn!("protective order placement failed for {position_id}: {e:#}");
2775 state.record_action(
2776 "protective_order_failed",
2777 json!({
2778 "position_id": position_id,
2779 "error": format!("{e:#}"),
2780 }),
2781 );
2782 }
2783 }
2784}
2785
2786enum DefensiveRollOutcome {
2787 Success(Value),
2789 NotAttempted { reason: String },
2791 StopCompleted {
2793 detail: Option<Value>,
2794 reason: String,
2795 },
2796}
2797
2798#[allow(clippy::too_many_arguments)]
2800async fn try_defensive_roll(
2801 runtime: &RuntimeConfig,
2802 trader: &Arc<TraderApi>,
2803 market: &MarketDataApi,
2804 rules_path: &std::path::Path,
2805 rules: &RulesConfig,
2806 state: &mut AgentState,
2807 today: NaiveDate,
2808 account_hash: &str,
2809 position_id: &str,
2810 tracked: &TrackedPosition,
2811 group: &crate::options::OptionPositionGroup,
2812 eval: &ExitEvaluation,
2813 short_otm_pct: Option<f64>,
2814 simulate: bool,
2815 _telegram: Option<&TelegramNotifier>,
2816) -> Result<DefensiveRollOutcome> {
2817 let roll_cfg = &rules.exit_rules.roll;
2818 if let Err(skip) = roll_eligible(
2819 roll_cfg,
2820 &RollEligibility {
2821 tracked,
2822 mark_dte: eval.mark.dte,
2823 short_otm_pct,
2824 rolls_today: state.rolls_today,
2825 reserved_risk_usd: state.reserved_risk_usd(),
2826 max_portfolio_risk_usd: rules.risk.max_portfolio_risk_usd,
2827 },
2828 ) {
2829 return Ok(DefensiveRollOutcome::NotAttempted {
2830 reason: skip.as_str().to_string(),
2831 });
2832 }
2833
2834 let Some(spread_type) = spread_type_from_tracked(tracked) else {
2835 return Ok(DefensiveRollOutcome::NotAttempted {
2836 reason: "roll_missing_spread_type".into(),
2837 });
2838 };
2839 if !spread_type.eq_ignore_ascii_case("put_credit")
2840 && !spread_type.eq_ignore_ascii_case("call_credit")
2841 {
2842 return Ok(DefensiveRollOutcome::NotAttempted {
2843 reason: "roll_not_credit_vertical".into(),
2844 });
2845 }
2846
2847 let close_debit = eval.mark.debit_to_close;
2848 let entry_credit = tracked
2849 .entry_credit
2850 .unwrap_or(eval.mark.entry_credit)
2851 .max(0.0);
2852 if entry_credit <= f64::EPSILON {
2853 return Ok(DefensiveRollOutcome::NotAttempted {
2854 reason: "roll_missing_entry_credit".into(),
2855 });
2856 }
2857
2858 let exit_signal = exit_signal_json_for_account(account_hash, group, eval);
2860 let close_detail = if simulate {
2861 match record_sim_exit(
2862 rules_path,
2863 state,
2864 rules,
2865 position_id,
2866 "defensive_roll",
2867 &eval.mark,
2868 &exit_signal,
2869 ) {
2870 Ok(d) => d,
2871 Err(e) => {
2872 return Ok(DefensiveRollOutcome::NotAttempted {
2873 reason: format!("roll_close_failed:{e:#}"),
2874 });
2875 }
2876 }
2877 } else {
2878 match execute_exit(
2879 runtime,
2880 trader,
2881 account_hash,
2882 rules,
2883 group,
2884 &exit_signal,
2885 state,
2886 )
2887 .await
2888 {
2889 Ok(d) => d,
2890 Err(e) => {
2891 return Ok(DefensiveRollOutcome::NotAttempted {
2892 reason: format!("roll_close_failed:{e:#}"),
2893 });
2894 }
2895 }
2896 };
2897
2898 let close_fill = close_detail
2899 .get("fill_status")
2900 .and_then(|v| v.as_str())
2901 .unwrap_or("")
2902 .to_string();
2903 let close_ok = close_fill.eq_ignore_ascii_case("FILLED")
2904 || (!rules.execution.wait_for_fill && !close_fill.eq_ignore_ascii_case("SKIPPED"));
2905 if !close_ok {
2906 return Ok(DefensiveRollOutcome::StopCompleted {
2907 detail: Some(close_detail),
2908 reason: format!("close_not_filled:{close_fill}"),
2909 });
2910 }
2911
2912 let original_width = tracked
2913 .entry_params
2914 .as_ref()
2915 .and_then(original_width_from_params);
2916 let biased = roll_biased_entry_rules(
2917 &rules.entry_rules.vertical,
2918 roll_cfg,
2919 eval.mark.dte,
2920 tracked.entry_short_delta,
2921 original_width,
2922 tracked.contracts.max(1),
2923 );
2924
2925 let candidate = match evaluate_vertical_entry(
2926 market,
2927 rules,
2928 &biased,
2929 &tracked.underlying,
2930 today,
2931 state,
2932 account_hash,
2933 &spread_type,
2934 )
2935 .await
2936 {
2937 Ok(Some(mut signal)) => {
2938 let new_credit = signal
2939 .get("estimated_credit")
2940 .and_then(|v| v.as_f64())
2941 .unwrap_or(0.0);
2942 if !roll_money_ok(
2943 entry_credit,
2944 close_debit,
2945 new_credit,
2946 roll_cfg.max_debit_pct_of_entry_credit,
2947 ) {
2948 return Ok(DefensiveRollOutcome::StopCompleted {
2949 detail: Some(close_detail),
2950 reason: format!(
2951 "roll_debit_too_large:net={:.3}",
2952 roll_net(close_debit, new_credit)
2953 ),
2954 });
2955 }
2956 let next_rolls = tracked.rolls_used.saturating_add(1);
2957 if let Some(obj) = signal.as_object_mut() {
2958 obj.insert("roll_replacement".into(), json!(true));
2959 obj.insert("rolls_used".into(), json!(next_rolls));
2960 obj.insert("closed_position_id".into(), json!(position_id));
2961 obj.insert("roll_close_debit".into(), json!(close_debit));
2962 obj.insert(
2963 "roll_net".into(),
2964 json!(roll_net(close_debit, new_credit)),
2965 );
2966 }
2967 signal
2968 }
2969 Ok(None) => {
2970 return Ok(DefensiveRollOutcome::StopCompleted {
2971 detail: Some(close_detail),
2972 reason: "no_roll_candidate".into(),
2973 });
2974 }
2975 Err(e) => {
2976 return Ok(DefensiveRollOutcome::StopCompleted {
2977 detail: Some(close_detail),
2978 reason: format!("roll_candidate_error:{e:#}"),
2979 });
2980 }
2981 };
2982
2983 let new_credit = candidate
2984 .get("estimated_credit")
2985 .and_then(|v| v.as_f64())
2986 .unwrap_or(0.0);
2987 let new_id = candidate
2988 .get("position_id")
2989 .and_then(|v| v.as_str())
2990 .unwrap_or("")
2991 .to_string();
2992
2993 let open_detail = if simulate {
2994 match record_sim_entry(
2995 rules_path,
2996 state,
2997 rules,
2998 account_hash,
2999 StrategyKind::Vertical,
3000 &candidate,
3001 ) {
3002 Ok(d) => d,
3003 Err(e) => {
3004 return Ok(DefensiveRollOutcome::StopCompleted {
3005 detail: Some(close_detail),
3006 reason: format!("roll_open_failed:{e:#}"),
3007 });
3008 }
3009 }
3010 } else {
3011 match maybe_execute_entry(
3012 runtime,
3013 trader,
3014 account_hash,
3015 StrategyKind::Vertical,
3016 &candidate,
3017 rules,
3018 state,
3019 )
3020 .await
3021 {
3022 Ok(Some(d)) => d,
3023 Ok(None) => {
3024 return Ok(DefensiveRollOutcome::StopCompleted {
3025 detail: Some(close_detail),
3026 reason: "roll_open_dry_or_none".into(),
3027 });
3028 }
3029 Err(e) => {
3030 return Ok(DefensiveRollOutcome::StopCompleted {
3031 detail: Some(close_detail),
3032 reason: format!("roll_open_failed:{e:#}"),
3033 });
3034 }
3035 }
3036 };
3037
3038 let open_fill = open_detail
3039 .get("fill_status")
3040 .and_then(|v| v.as_str())
3041 .unwrap_or("");
3042 if !open_fill.eq_ignore_ascii_case("FILLED")
3043 && !(open_fill.is_empty() && !rules.execution.wait_for_fill)
3044 {
3045 if open_fill.eq_ignore_ascii_case("SKIPPED")
3047 || open_fill.eq_ignore_ascii_case("REJECTED")
3048 || open_fill.eq_ignore_ascii_case("CANCELED")
3049 {
3050 return Ok(DefensiveRollOutcome::StopCompleted {
3051 detail: Some(json!({
3052 "close": close_detail,
3053 "open": open_detail,
3054 })),
3055 reason: format!("roll_open_status:{open_fill}"),
3056 });
3057 }
3058 if !open_fill.eq_ignore_ascii_case("FILLED") {
3061 return Ok(DefensiveRollOutcome::StopCompleted {
3062 detail: Some(json!({
3063 "close": close_detail,
3064 "open": open_detail,
3065 })),
3066 reason: format!("roll_open_not_filled:{open_fill}"),
3067 });
3068 }
3069 }
3070
3071 state.rolls_today = state.rolls_today.saturating_add(1);
3072 let net = roll_net(close_debit, new_credit);
3073 let detail = json!({
3074 "type": "defensive_roll",
3075 "fill_status": "FILLED",
3076 "reason": "rolled",
3077 "closed_id": position_id,
3078 "new_id": new_id,
3079 "close_debit": close_debit,
3080 "new_credit": new_credit,
3081 "net": net,
3082 "underlying": tracked.underlying,
3083 "rolls_used": tracked.rolls_used.saturating_add(1),
3084 "close": close_detail,
3085 "open": open_detail,
3086 "signal": candidate,
3087 });
3088 state.record_action("defensive_roll", detail.clone());
3089 let _ = journal::append_event(rules_path, simulate, "defensive_roll", detail.clone());
3090 Ok(DefensiveRollOutcome::Success(detail))
3091}
3092
3093async fn execute_exit(
3094 runtime: &RuntimeConfig,
3095 trader: &Arc<TraderApi>,
3096 account_hash: &str,
3097 rules: &RulesConfig,
3098 group: &crate::options::OptionPositionGroup,
3099 signal: &Value,
3100 state: &mut AgentState,
3101) -> Result<Value> {
3102 require_trading_approval(
3103 runtime,
3104 "agent exit",
3105 &format!("Close position {}", group.id),
3106 )?;
3107
3108 let position_id = stable_position_key(account_hash, group);
3109 if state.has_pending_position(&position_id) {
3110 return Ok(json!({
3111 "fill_status": "SKIPPED",
3112 "reason": "exit already pending",
3113 "position_id": position_id,
3114 "signal": signal,
3115 }));
3116 }
3117
3118 if let Some(protective_id) = state
3121 .open_positions
3122 .get(&position_id)
3123 .and_then(|p| p.protective_order_id.clone())
3124 {
3125 match protective::cancel_protective_order(runtime, trader, account_hash, &protective_id)
3126 .await
3127 {
3128 Ok(_) => {
3129 if let Some(p) = state.open_positions.get_mut(&position_id) {
3130 p.protective_order_id = None;
3131 p.protective_order_status = Some("CANCELED".to_string());
3132 }
3133 }
3134 Err(e) => {
3135 tracing::warn!(
3136 "failed to cancel protective order {protective_id} before exit \
3137 (it may have already filled): {e:#}"
3138 );
3139 }
3140 }
3141 }
3142
3143 let close_limit = close_limit_from_signal(signal)
3144 .or_else(|| close_limit_from_group_mark(group))
3145 .context("could not derive close limit price for spread exit")?;
3146 let order = build_close_order_for_group_with_limit(group, Some(close_limit))?;
3147 runtime.safety.validate_order(&order, None, None)?;
3148 let place = execute_trading_order(runtime, trader, account_hash, &order).await?;
3149
3150 let mut wait_json = None;
3151 let mut fill_status = "ACCEPTED".to_string();
3152 let order_id = place
3153 .get("order_id")
3154 .and_then(|v| v.as_str())
3155 .map(str::to_string);
3156
3157 if rules.execution.wait_for_fill {
3158 if let Some(order_id) = order_id.as_ref() {
3159 let wait = wait_for_order(
3160 trader,
3161 account_hash,
3162 order_id,
3163 WaitOptions {
3164 condition: WaitCondition::Filled,
3165 timeout: std::time::Duration::from_secs(rules.execution.fill_timeout_seconds),
3166 interval: std::time::Duration::from_secs(5),
3167 proceed_on_partial_fill: false,
3168 requested_quantity: None,
3169 },
3170 )
3171 .await;
3172 match wait {
3173 Ok(wait) => {
3174 fill_status = wait
3175 .final_status
3176 .as_deref()
3177 .unwrap_or("UNKNOWN")
3178 .to_string();
3179 wait_json = Some(wait_result_json(&wait));
3180 }
3181 Err(e) => {
3182 fill_status = "WAIT_ERROR".into();
3183 wait_json = Some(json!({ "error": e.to_string() }));
3184 }
3185 }
3186 }
3187 }
3188
3189 let detail = json!({
3190 "exit": place,
3191 "signal": signal,
3192 "position_id": position_id,
3193 "limit_price": close_limit,
3194 "wait": wait_json,
3195 "fill_status": fill_status.clone(),
3196 });
3197
3198 if fill_status == "FILLED" || !rules.execution.wait_for_fill {
3199 if let Some(tracked) = state.open_positions.get(&position_id).cloned() {
3200 let debit = signal
3201 .pointer("/mark/debit_to_close")
3202 .and_then(|v| v.as_f64())
3203 .unwrap_or(close_limit);
3204 record_live_realized_pnl(state, &tracked, debit);
3205 }
3206 state.open_positions.remove(&position_id);
3207 state.open_positions.remove(&group.id);
3208 state.record_action("exit", signal.clone());
3209 } else {
3210 if let Some(order_id) = order_id {
3211 state.add_pending_order(PendingOrder {
3212 order_id,
3213 account_hash: account_hash.to_string(),
3214 action: PendingOrderAction::Exit,
3215 position_id,
3216 reserved_risk_usd: 0.0,
3217 submitted_at: Utc::now(),
3218 last_status: Some(fill_status),
3219 detail: Some(detail.clone()),
3220 });
3221 }
3222 state.record_action("exit_working_position_kept", detail.clone());
3223 }
3224
3225 Ok(detail)
3226}
3227
3228fn close_limit_from_signal(signal: &Value) -> Option<f64> {
3229 let debit = signal
3230 .pointer("/mark/debit_to_close")
3231 .and_then(|v| v.as_f64())?;
3232 Some((debit + EXIT_LIMIT_SLIPPAGE).max(0.01))
3233}
3234
3235fn close_limit_from_group_mark(group: &crate::options::OptionPositionGroup) -> Option<f64> {
3236 let contracts = crate::options::spread_contract_count(group) as f64;
3237 if contracts <= 0.0 {
3238 return None;
3239 }
3240 Some((group.net_market_value.abs() / contracts / 100.0 + EXIT_LIMIT_SLIPPAGE).max(0.01))
3241}
3242
3243#[cfg(test)]
3244mod llm_schedule_tests {
3245 use super::*;
3246 use crate::agent::llm::LlmReview;
3247 use crate::agent::state::{AgentState, TrackedPosition};
3248 use crate::rules::{
3249 AccountType, EntryPolicyConfig, EntryRules, ExecutionConfig, ExitRules, LlmConfig,
3250 NotifyConfig, RiskConfig, RulesAccount, RulesConfig, ScheduleConfig, StrategiesToggle,
3251 StrategyEnabled, VerticalEntryRules, WatchlistEntry,
3252 };
3253
3254 fn test_rules(max_open: u32) -> RulesConfig {
3255 RulesConfig {
3256 version: 1,
3257 agent_id: "test".into(),
3258 accounts: vec![RulesAccount {
3259 hash: "ACC".into(),
3260 label: Some("test".into()),
3261 r#type: AccountType::Ira,
3262 enabled: true,
3263 }],
3264 schedule: ScheduleConfig {
3265 tick_interval_seconds: 120,
3266 ..Default::default()
3267 },
3268 strategies: StrategiesToggle {
3269 vertical: StrategyEnabled { enabled: true },
3270 iron_condor: StrategyEnabled { enabled: false },
3271 },
3272 watchlist: vec![WatchlistEntry::from("IWM")],
3273 entry_policy: EntryPolicyConfig::default(),
3274 entry_rules: EntryRules {
3275 vertical: VerticalEntryRules {
3276 max_open_positions: max_open,
3277 ..Default::default()
3278 },
3279 ..Default::default()
3280 },
3281 exit_rules: ExitRules {
3282 dte_close: 21,
3283 ..Default::default()
3284 },
3285 risk: RiskConfig::default(),
3286 regime: Default::default(),
3287 execution: ExecutionConfig::default(),
3288 llm: LlmConfig {
3289 enabled: true,
3290 review_every_ticks: 15,
3291 monitor_review_every_ticks: Some(30),
3292 ..Default::default()
3293 },
3294 notify: NotifyConfig::default(),
3295 simulation: None,
3296 }
3297 }
3298
3299 fn state_with_open_position() -> AgentState {
3300 let mut state = AgentState::default();
3301 state.open_positions.insert(
3302 "pos".into(),
3303 TrackedPosition {
3304 position_id: "pos".into(),
3305 account_hash: "ACC".into(),
3306 underlying: "IWM".into(),
3307 expiry: "2026-07-31".into(),
3308 strategy: "vertical".into(),
3309 opened_at: chrono::Utc::now(),
3310 entry_credit: Some(0.30),
3311 max_loss_usd: 170.0,
3312 contracts: 1,
3313 entry_params: None,
3314 peak_profit_pct: None,
3315 entry_pop_pct: None,
3316 entry_short_delta: None,
3317 ..Default::default()
3318 },
3319 );
3320 state
3321 }
3322
3323 #[test]
3324 fn selection_llm_is_throttled_when_candidates_exist() {
3325 let rules = test_rules(2);
3326 let mut state = state_with_open_position();
3327 state.regular_tick_count = 100;
3328 state.last_llm_review_tick = Some(95);
3329
3330 assert!(resolve_llm_phase(&rules, &state, true, true, false, false).is_none());
3331
3332 state.regular_tick_count = 125;
3333 assert!(matches!(
3334 resolve_llm_phase(&rules, &state, true, true, false, false),
3335 Some(LlmPhase::Selection)
3336 ));
3337 }
3338
3339 #[test]
3340 fn monitor_runs_when_no_candidates_and_due() {
3341 let rules = test_rules(2);
3342 let mut state = state_with_open_position();
3343 state.regular_tick_count = 125;
3344 state.last_llm_review_tick = Some(95);
3345
3346 assert!(matches!(
3347 resolve_llm_phase(&rules, &state, false, true, false, false),
3348 Some(LlmPhase::Monitor)
3349 ));
3350 }
3351
3352 #[test]
3353 fn monitor_skipped_at_open_without_candidates_or_mechanical_alert() {
3354 let rules = test_rules(2);
3355 let mut state = state_with_open_position();
3356 state.regular_tick_count = 125;
3357 state.last_llm_review_tick = Some(95);
3358
3359 assert!(resolve_llm_phase(&rules, &state, false, true, true, false).is_none());
3360 }
3361
3362 #[test]
3363 fn monitor_runs_at_open_when_mechanical_alert() {
3364 let rules = test_rules(2);
3365 let mut state = state_with_open_position();
3366 state.regular_tick_count = 125;
3367 state.last_llm_review_tick = Some(95);
3368
3369 assert!(matches!(
3370 resolve_llm_phase(&rules, &state, false, true, true, true),
3371 Some(LlmPhase::Monitor)
3372 ));
3373 }
3374
3375 #[test]
3376 fn mechanical_alert_detects_stop_triggered() {
3377 let monitored = vec![json!({
3378 "mechanical_rules": { "stop_triggered": true }
3379 })];
3380 assert!(mechanical_alert_from_monitored(&monitored, 21));
3381 }
3382
3383 #[test]
3384 fn entry_scan_skipped_when_all_accounts_at_capacity() {
3385 let rules = test_rules(1);
3386 let state = state_with_open_position();
3387 assert!(!any_entry_slots_available(&rules, &state));
3388 }
3389
3390 #[test]
3391 fn redeploy_cooldown_removes_underlying_from_scan() {
3392 let mut rules = test_rules(2);
3393 rules.watchlist = vec![WatchlistEntry::from("SPY"), WatchlistEntry::from("IWM")];
3394 rules.exit_rules.thesis.redeploy_cooldown_minutes = Some(120);
3395 let mut state = AgentState::default();
3396 state.redeploy_signal = Some(RedeploySignal {
3397 at: Utc::now(),
3398 reason: "thesis_near_strike".into(),
3399 underlying: Some("IWM".into()),
3400 });
3401 let wl = watchlist_for_scan(&rules, &state);
3402 assert!(!wl.iter().any(|i| i.symbol.eq_ignore_ascii_case("IWM")));
3403
3404 state.redeploy_signal = Some(RedeploySignal {
3405 at: Utc::now() - chrono::Duration::minutes(121),
3406 reason: "thesis_near_strike".into(),
3407 underlying: Some("IWM".into()),
3408 });
3409 let wl = watchlist_for_scan(&rules, &state);
3410 assert_eq!(wl.first().map(|i| i.symbol.as_str()), Some("SPY"));
3411
3412 rules.entry_policy.promote_redeploy_symbol = true;
3413 let wl = watchlist_for_scan(&rules, &state);
3414 assert_eq!(wl.first().map(|i| i.symbol.as_str()), Some("IWM"));
3415 }
3416
3417 #[test]
3418 fn underlying_entry_cap_blocks_second_iwm_spread() {
3419 let mut rules = test_rules(2);
3420 rules
3421 .risk
3422 .max_open_per_underlying
3423 .insert("IWM".into(), 1);
3424 let state = state_with_open_position();
3425 assert!(underlying_entry_cap_reached(
3426 &rules,
3427 &state,
3428 "ACC",
3429 "IWM"
3430 ));
3431 assert!(!underlying_entry_cap_reached(
3432 &rules,
3433 &state,
3434 "ACC",
3435 "SPY"
3436 ));
3437 }
3438
3439 #[test]
3440 fn correlation_group_cap_blocks_second_index() {
3441 let mut rules = test_rules(2);
3442 rules.risk.correlation_groups = vec![crate::rules::CorrelationGroupConfig {
3443 name: "broad_market".into(),
3444 symbols: vec!["QQQ".into(), "IWM".into()],
3445 max_open: 1,
3446 }];
3447 let state = state_with_open_position(); assert_eq!(
3449 correlation_group_cap_reached(&rules, &state, "ACC", "QQQ").as_deref(),
3450 Some("broad_market")
3451 );
3452 assert!(correlation_group_cap_reached(&rules, &AgentState::default(), "ACC", "QQQ").is_none());
3453 }
3454
3455 #[test]
3456 fn selection_telegram_only_on_proceed_or_urgent_close() {
3457 use crate::agent::telegram_format::is_llm_urgent;
3458
3459 let defer = LlmReview {
3460 phase: "selection".into(),
3461 model: "test".into(),
3462 used_web: false,
3463 raw: serde_json::json!({}),
3464 market_commentary: "ok".into(),
3465 web_insights: vec![],
3466 position_reviews: vec![],
3467 entry_recommendation: "defer".into(),
3468 entry_reasoning: "wait".into(),
3469 risk_alerts: vec!["noise".into()],
3470 };
3471 assert!(!is_llm_urgent(&defer));
3472
3473 let proceed = LlmReview {
3474 entry_recommendation: "proceed".into(),
3475 ..defer.clone()
3476 };
3477 assert!(is_llm_urgent(&proceed));
3478 }
3479}