Skip to content

Commit 797ddca

Browse files
authored
Merge pull request #115 from QuantStrategyLab/codex/strategy-plugin-ai-runtime-20260528
Enable IBKR option and plugin runtime support
2 parents 460e503 + ed8cb79 commit 797ddca

9 files changed

Lines changed: 788 additions & 24 deletions

application/execution_service.py

Lines changed: 280 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
474715
def _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)

application/ibkr_order_execution.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ def submit_order_intent(
4242
account_id: str | None = None,
4343
wait_seconds: float = 1.0,
4444
stock_factory: Callable[..., Any] | None = None,
45+
option_factory: Callable[..., Any] | None = None,
46+
combo_contract_factory: Callable[..., Any] | None = None,
47+
combo_leg_factory: Callable[..., Any] | None = None,
4548
market_order_factory: Callable[..., Any] | None = None,
4649
limit_order_factory: Callable[..., Any] | None = None,
4750
) -> ExecutionReport:
@@ -54,6 +57,9 @@ def submit_order_intent(
5457
account_id=account_id,
5558
wait_seconds=wait_seconds,
5659
stock_factory=stock_factory,
60+
option_factory=option_factory,
61+
combo_contract_factory=combo_contract_factory,
62+
combo_leg_factory=combo_leg_factory,
5763
market_order_factory=_market_order_factory_with_time_in_force(
5864
market_order_factory,
5965
time_in_force=intent.time_in_force or DEFAULT_TIME_IN_FORCE,

application/runtime_strategy_adapters.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -221,16 +221,19 @@ def get_historical_candles(self, ib, symbol, duration="2 Y", bar_size="1 day"):
221221
)
222222
return candles
223223

224-
def compute_signals(self, ib, current_holdings):
225-
evaluation = self.strategy_runtime.evaluate(
226-
ib=ib,
227-
current_holdings=current_holdings,
228-
historical_close_loader=self.get_historical_close,
229-
historical_candle_loader=self.get_historical_candles,
230-
run_as_of=self.resolve_run_as_of_date_fn(),
231-
translator=self.translator,
232-
pacing_sec=self.pacing_sec,
233-
)
224+
def compute_signals(self, ib, current_holdings, *, strategy_plugin_signals=()):
225+
evaluate_kwargs = {
226+
"ib": ib,
227+
"current_holdings": current_holdings,
228+
"historical_close_loader": self.get_historical_close,
229+
"historical_candle_loader": self.get_historical_candles,
230+
"run_as_of": self.resolve_run_as_of_date_fn(),
231+
"translator": self.translator,
232+
"pacing_sec": self.pacing_sec,
233+
}
234+
if strategy_plugin_signals:
235+
evaluate_kwargs["strategy_plugin_signals"] = tuple(strategy_plugin_signals or ())
236+
evaluation = self.strategy_runtime.evaluate(**evaluate_kwargs)
234237
return self.map_strategy_decision_fn(
235238
evaluation.decision,
236239
strategy_profile=self.strategy_profile,

0 commit comments

Comments
 (0)