@@ -471,6 +471,247 @@ def _resolve_weight_allocation(signal_metadata: dict[str, Any] | None) -> dict[s
471471 }
472472
473473
474+ def _normalize_option_order_intents (signal_metadata : dict [str , Any ] | None ) -> tuple [dict [str , Any ], ...]:
475+ metadata = dict (signal_metadata or {})
476+ raw_payload = metadata .get ("option_order_intents" )
477+ if isinstance (raw_payload , dict ):
478+ raw_intents = raw_payload .get ("intents" )
479+ else :
480+ raw_intents = raw_payload
481+ intents = []
482+ for intent in raw_intents or ():
483+ if isinstance (intent , dict ):
484+ intents .append (dict (intent ))
485+ return tuple (intents )
486+
487+
488+ def _option_intent_underliers (option_intents : tuple [dict [str , Any ], ...]) -> tuple [str , ...]:
489+ underliers = []
490+ for intent in option_intents :
491+ underlier = str (intent .get ("underlier" ) or intent .get ("symbol" ) or "" ).strip ().upper ()
492+ if underlier :
493+ underliers .append (underlier )
494+ return tuple (sorted (dict .fromkeys (underliers )))
495+
496+
497+ def _is_executable_option_intent (intent : dict [str , Any ]) -> bool :
498+ intent_type = str (intent .get ("intent_type" ) or "" ).strip ()
499+ action = str (intent .get ("action" ) or "" ).strip ().lower ()
500+ if (
501+ str (intent .get ("asset_class" ) or "" ).strip ().lower () == "option"
502+ and intent_type == "single_leg_option"
503+ and action in {"buy_to_open" , "sell_to_close" }
504+ ):
505+ return True
506+ return (
507+ str (intent .get ("asset_class" ) or "" ).strip ().lower () == "option"
508+ and intent_type == "multi_leg_option"
509+ and action == "sell_to_open_put_credit_spread"
510+ )
511+
512+
513+ def _has_executable_option_plan (option_intents : tuple [dict [str , Any ], ...]) -> bool :
514+ return any (_is_executable_option_intent (intent ) for intent in option_intents )
515+
516+
517+ def _format_option_intent_symbol (intent : dict [str , Any ]) -> str :
518+ underlier = str (intent .get ("underlier" ) or intent .get ("symbol" ) or "" ).strip ().upper ()
519+ if str (intent .get ("intent_type" ) or "" ).strip () == "multi_leg_option" :
520+ expiration = str (intent .get ("expiration" ) or "" ).strip ()
521+ return f"{ underlier } { expiration } PCS" .strip ()
522+ right = str (intent .get ("right" ) or "" ).strip ().upper ()
523+ expiration = str (intent .get ("expiration" ) or "" ).strip ()
524+ strike = intent .get ("strike" )
525+ if underlier and right and expiration and strike not in {None , "" }:
526+ return f"{ underlier } { expiration } { float (strike ):g} { right } "
527+ return underlier or "<option>"
528+
529+
530+ def _record_unsupported_option_intents (
531+ execution_summary : dict [str , Any ],
532+ option_intents : tuple [dict [str , Any ], ...],
533+ ) -> None :
534+ for intent in option_intents :
535+ if _is_executable_option_intent (intent ):
536+ continue
537+ symbol = _format_option_intent_symbol (intent )
538+ reason = "unsupported_option_intent_type"
539+ execution_summary ["option_orders_skipped" ].append (
540+ {
541+ "symbol" : symbol ,
542+ "action" : str (intent .get ("action" ) or "" ),
543+ "intent_type" : str (intent .get ("intent_type" ) or "" ),
544+ "reason" : reason ,
545+ }
546+ )
547+ execution_summary ["skipped_reasons" ].append (f"{ reason } :{ symbol } " )
548+
549+
550+ def _build_single_leg_option_order_intent (
551+ order_intent_cls ,
552+ intent : dict [str , Any ],
553+ * ,
554+ account_id : str | None ,
555+ ):
556+ action = str (intent .get ("action" ) or "" ).strip ().lower ()
557+ side = "buy" if action .startswith ("buy" ) else "sell"
558+ return order_intent_cls (
559+ symbol = str (intent .get ("underlier" ) or intent .get ("symbol" ) or "" ).strip ().upper (),
560+ side = side ,
561+ quantity = float (intent .get ("quantity" ) or 0.0 ),
562+ order_type = str (intent .get ("order_type" ) or "limit" ).strip ().lower (),
563+ limit_price = (
564+ float (intent ["limit_price" ])
565+ if intent .get ("limit_price" ) not in {None , "" }
566+ else None
567+ ),
568+ time_in_force = str (intent .get ("time_in_force" ) or "DAY" ).strip ().upper (),
569+ account_id = account_id ,
570+ metadata = {
571+ ** intent ,
572+ "asset_class" : "option" ,
573+ "intent_type" : "single_leg_option" ,
574+ "security_type" : "OPT" ,
575+ },
576+ )
577+
578+
579+ def _build_multi_leg_option_order_intent (
580+ order_intent_cls ,
581+ intent : dict [str , Any ],
582+ * ,
583+ account_id : str | None ,
584+ ):
585+ return order_intent_cls (
586+ symbol = str (intent .get ("underlier" ) or intent .get ("symbol" ) or "" ).strip ().upper (),
587+ side = "sell" ,
588+ quantity = float (intent .get ("quantity" ) or 0.0 ),
589+ order_type = str (intent .get ("order_type" ) or "limit" ).strip ().lower (),
590+ limit_price = (
591+ float (intent ["limit_price" ])
592+ if intent .get ("limit_price" ) not in {None , "" }
593+ else None
594+ ),
595+ time_in_force = str (intent .get ("time_in_force" ) or "DAY" ).strip ().upper (),
596+ account_id = account_id ,
597+ metadata = {
598+ ** intent ,
599+ "asset_class" : "option" ,
600+ "intent_type" : "multi_leg_option" ,
601+ "security_type" : "BAG" ,
602+ },
603+ )
604+
605+
606+ def _execute_option_order_intents (
607+ ib ,
608+ option_intents : tuple [dict [str , Any ], ...],
609+ * ,
610+ submit_order_intent ,
611+ order_intent_cls ,
612+ translator ,
613+ execution_summary : dict [str , Any ],
614+ trade_logs : list [str ],
615+ dry_run_only : bool ,
616+ order_account_id : str | None ,
617+ buying_power : float ,
618+ ) -> float :
619+ if not option_intents :
620+ return buying_power
621+ _record_unsupported_option_intents (execution_summary , option_intents )
622+ for intent in option_intents :
623+ if not _is_executable_option_intent (intent ):
624+ continue
625+ symbol = _format_option_intent_symbol (intent )
626+ quantity = float (intent .get ("quantity" ) or 0.0 )
627+ if quantity <= 0.0 :
628+ execution_summary ["option_orders_skipped" ].append (
629+ {"symbol" : symbol , "action" : intent .get ("action" ), "reason" : "quantity_zero" }
630+ )
631+ continue
632+ limit_price = float (intent .get ("limit_price" ) or 0.0 )
633+ multiplier = float (intent .get ("contract_multiplier" ) or 100.0 )
634+ action = str (intent .get ("action" ) or "" ).strip ().lower ()
635+ intent_type = str (intent .get ("intent_type" ) or "" ).strip ()
636+ estimated_notional = float (intent .get ("max_notional_usd" ) or (quantity * limit_price * multiplier ))
637+ max_loss = float (intent .get ("max_loss_usd" ) or 0.0 )
638+ if action .startswith ("buy" ) and estimated_notional > max (0.0 , buying_power * 0.95 ):
639+ execution_summary ["option_orders_skipped" ].append (
640+ {
641+ "symbol" : symbol ,
642+ "action" : action ,
643+ "quantity" : quantity ,
644+ "limit_price" : limit_price ,
645+ "reason" : "insufficient_buying_power" ,
646+ }
647+ )
648+ execution_summary ["skipped_reasons" ].append (f"option_insufficient_buying_power:{ symbol } " )
649+ continue
650+ if intent_type == "multi_leg_option" and max_loss > max (0.0 , buying_power * 0.95 ):
651+ execution_summary ["option_orders_skipped" ].append (
652+ {
653+ "symbol" : symbol ,
654+ "action" : action ,
655+ "quantity" : quantity ,
656+ "limit_price" : limit_price ,
657+ "reason" : "insufficient_buying_power_for_defined_risk" ,
658+ }
659+ )
660+ execution_summary ["skipped_reasons" ].append (f"option_insufficient_buying_power:{ symbol } " )
661+ continue
662+ payload = {
663+ "symbol" : symbol ,
664+ "underlier" : str (intent .get ("underlier" ) or "" ).strip ().upper (),
665+ "action" : action ,
666+ "quantity" : quantity ,
667+ "limit_price" : limit_price ,
668+ "status" : "dry_run" if dry_run_only else "pending" ,
669+ }
670+ if dry_run_only :
671+ execution_summary ["option_orders_submitted" ].append (payload )
672+ trade_logs .append (f"DRY_RUN option { action } { symbol } { format_quantity (quantity )} @{ limit_price :.2f} " )
673+ if action .startswith ("buy" ):
674+ buying_power -= estimated_notional
675+ elif intent_type == "multi_leg_option" :
676+ buying_power -= max_loss
677+ continue
678+ if intent_type == "multi_leg_option" :
679+ order_intent = _build_multi_leg_option_order_intent (
680+ order_intent_cls ,
681+ intent ,
682+ account_id = order_account_id ,
683+ )
684+ else :
685+ order_intent = _build_single_leg_option_order_intent (
686+ order_intent_cls ,
687+ intent ,
688+ account_id = order_account_id ,
689+ )
690+ report = submit_order_intent (ib , order_intent )
691+ ok , status_msg = check_order_submitted (report , translator = translator )
692+ status = str (getattr (report , "status" , "" ) or "" )
693+ order_payload = {
694+ ** payload ,
695+ "status" : status ,
696+ "broker_order_id" : getattr (report , "broker_order_id" , None ),
697+ }
698+ if status == "Filled" :
699+ execution_summary ["option_orders_filled" ].append (order_payload )
700+ elif status in {"PartiallyFilled" , "Partial" }:
701+ execution_summary ["option_orders_partially_filled" ].append (order_payload )
702+ elif ok :
703+ execution_summary ["option_orders_submitted" ].append (order_payload )
704+ else :
705+ execution_summary ["option_orders_skipped" ].append ({** order_payload , "reason" : status or "submit_failed" })
706+ execution_summary ["skipped_reasons" ].append (f"option_submit_failed:{ symbol } :{ status or 'unknown' } " )
707+ trade_logs .append (f"option { action } { symbol } { format_quantity (quantity )} @{ limit_price :.2f} { status_msg } " )
708+ if ok and action .startswith ("buy" ):
709+ buying_power -= estimated_notional
710+ elif ok and intent_type == "multi_leg_option" :
711+ buying_power -= max_loss
712+ return buying_power
713+
714+
474715def _apply_snapshot_price_fallbacks (
475716 prices : dict [str , float ],
476717 symbols ,
@@ -753,6 +994,9 @@ def execute_rebalance(
753994 signal_metadata = signal_metadata or {}
754995 allocation = _resolve_weight_allocation (signal_metadata )
755996 target_weights = dict (allocation ["targets" ])
997+ option_order_intents = _normalize_option_order_intents (signal_metadata )
998+ option_underliers = _option_intent_underliers (option_order_intents )
999+ has_executable_option_plan = _has_executable_option_plan (option_order_intents )
7561000 strategy_symbols = tuple (allocation ["strategy_symbols" ])
7571001 trade_date = str (signal_metadata .get ("trade_date" ) or "" ).strip () or None
7581002 snapshot_date = _normalize_date_like (signal_metadata .get ("snapshot_as_of" ))
@@ -779,6 +1023,12 @@ def execute_rebalance(
7791023 "orders_filled" : [],
7801024 "orders_partially_filled" : [],
7811025 "orders_skipped" : [],
1026+ "option_order_intent_count" : len (option_order_intents ),
1027+ "option_order_underliers" : list (option_underliers ),
1028+ "option_orders_submitted" : [],
1029+ "option_orders_filled" : [],
1030+ "option_orders_partially_filled" : [],
1031+ "option_orders_skipped" : [],
7821032 "skipped_reasons" : [],
7831033 "target_vs_current" : [],
7841034 "execution_status" : "not_started" ,
@@ -927,7 +1177,11 @@ def execute_rebalance(
9271177 (sum (current_mv .values ()) - current_safe_haven_mv ) / equity
9281178 )
9291179
930- pending_symbols = _collect_pending_symbols (ib , set (all_symbols ), account_ids = normalized_account_ids )
1180+ pending_symbols = _collect_pending_symbols (
1181+ ib ,
1182+ set (all_symbols ) | set (option_underliers ),
1183+ account_ids = normalized_account_ids ,
1184+ )
9311185 if pending_symbols :
9321186 reason = f"pending_orders_detected:{ ',' .join (pending_symbols )} "
9331187 execution_summary ["execution_status" ] = "blocked"
@@ -1118,17 +1372,22 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
11181372 symbols = "," .join (sorted (dict .fromkeys (quantity_zero_symbols )))
11191373 reason = f"quantity_zero:{ symbols } "
11201374
1375+ if not (reason == "target_diff_below_threshold" and has_executable_option_plan ):
1376+ _record_unsupported_option_intents (execution_summary , option_order_intents )
1377+ if execution_summary ["option_orders_skipped" ] and reason == "target_diff_below_threshold" :
1378+ reason = "option_orders_skipped"
11211379 execution_summary ["execution_status" ] = status
11221380 execution_summary ["no_op_reason" ] = reason
11231381 if reason != "target_diff_below_threshold" :
11241382 execution_summary ["skipped_reasons" ].append (reason )
11251383 if status == "blocked" :
11261384 trade_logs .append (translator ("failed" , reason = reason ))
1127- return _finalize_result (trade_logs , execution_summary , return_summary = return_summary )
1385+ if not (reason == "target_diff_below_threshold" and has_executable_option_plan ):
1386+ return _finalize_result (trade_logs , execution_summary , return_summary = return_summary )
11281387
11291388 same_day_filled_symbols = _collect_same_day_filled_symbols (
11301389 ib ,
1131- set (all_symbols ),
1390+ set (all_symbols ) | set ( option_underliers ) ,
11321391 trade_date ,
11331392 account_ids = normalized_account_ids ,
11341393 )
@@ -1363,14 +1622,32 @@ def cash_sweep_sale_quantity_to_fund_buy(max_quantity: int, candidate_symbols: t
13631622 if ok :
13641623 buying_power -= qty * limit_price
13651624
1625+ buying_power = _execute_option_order_intents (
1626+ ib ,
1627+ option_order_intents ,
1628+ submit_order_intent = submit_order_intent ,
1629+ order_intent_cls = order_intent_cls ,
1630+ translator = translator ,
1631+ execution_summary = execution_summary ,
1632+ trade_logs = trade_logs ,
1633+ dry_run_only = dry_run_only ,
1634+ order_account_id = order_account_id ,
1635+ buying_power = buying_power ,
1636+ )
1637+
13661638 execution_summary ["execution_status" ] = (
13671639 "executed"
13681640 if (
13691641 execution_summary ["orders_submitted" ]
13701642 or execution_summary ["orders_filled" ]
13711643 or execution_summary ["orders_partially_filled" ]
1644+ or execution_summary ["option_orders_submitted" ]
1645+ or execution_summary ["option_orders_filled" ]
1646+ or execution_summary ["option_orders_partially_filled" ]
13721647 )
13731648 else "no_op"
13741649 )
1650+ if execution_summary ["execution_status" ] == "executed" :
1651+ execution_summary ["no_op_reason" ] = None
13751652 execution_summary ["residual_cash_estimate" ] = float (max (buying_power , 0.0 ))
13761653 return _finalize_result (trade_logs , execution_summary , return_summary = return_summary )
0 commit comments