Skip to content

Commit 77d439f

Browse files
authored
Merge pull request #69 from QuantStrategyLab/codex/strategy-plugin-ai-runtime-20260528
Add plugin metadata and IBKR option support
2 parents ceb84a3 + 86d7263 commit 77d439f

9 files changed

Lines changed: 757 additions & 7 deletions

File tree

src/quant_platform_kit/common/strategy_plugins.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import tempfile
88
from collections.abc import Mapping, Sequence
99
from dataclasses import dataclass, field
10+
from dataclasses import replace as dataclass_replace
1011
from pathlib import Path
1112
from typing import Any, Callable
1213

@@ -427,6 +428,52 @@ def build_strategy_plugin_report_payload(signals: Sequence[StrategyPluginSignal]
427428
}
428429

429430

431+
def build_strategy_plugin_metadata(signals: Sequence[StrategyPluginSignal]) -> dict[str, Any]:
432+
"""Build portfolio-snapshot metadata consumed by deterministic strategies."""
433+
plugin_payloads: dict[str, Any] = {}
434+
summaries: dict[str, Any] = {}
435+
for signal in signals:
436+
execution_controls = getattr(signal, "execution_controls", {}) or {}
437+
if not isinstance(execution_controls, Mapping) or not _as_bool(
438+
execution_controls.get("strategy_runtime_metadata_allowed"),
439+
default=False,
440+
):
441+
continue
442+
plugin = str(getattr(signal, "plugin", "") or "").strip()
443+
if not plugin:
444+
continue
445+
payload = dict(getattr(signal, "payload", {}) or {})
446+
plugin_payloads[plugin] = payload
447+
summaries[plugin] = signal.report_summary()
448+
if not plugin_payloads:
449+
return {}
450+
metadata: dict[str, Any] = {
451+
"strategy_plugins": plugin_payloads,
452+
"strategy_plugin_summaries": summaries,
453+
}
454+
metadata.update(plugin_payloads)
455+
return metadata
456+
457+
458+
def attach_strategy_plugin_metadata(snapshot: Any, signals: Sequence[StrategyPluginSignal]) -> Any:
459+
"""Return a snapshot copy with plugin payloads attached to metadata."""
460+
plugin_metadata = build_strategy_plugin_metadata(signals)
461+
if not plugin_metadata:
462+
return snapshot
463+
current_metadata = getattr(snapshot, "metadata", {}) or {}
464+
if not isinstance(current_metadata, Mapping):
465+
current_metadata = {}
466+
merged_metadata = {**dict(current_metadata), **plugin_metadata}
467+
try:
468+
return dataclass_replace(snapshot, metadata=merged_metadata)
469+
except TypeError:
470+
try:
471+
snapshot.metadata = merged_metadata
472+
except Exception:
473+
return snapshot
474+
return snapshot
475+
476+
430477
def translate_strategy_plugin_value(
431478
category: str,
432479
raw_value: str | None,
@@ -532,6 +579,41 @@ def build_strategy_plugin_alert_scope_note(
532579
)
533580

534581

582+
def build_strategy_plugin_ai_audit_note(
583+
signal: StrategyPluginSignal,
584+
*,
585+
translator: Callable[..., str] | None = None,
586+
) -> str | None:
587+
payload = getattr(signal, "payload", {}) or {}
588+
if not isinstance(payload, Mapping):
589+
return None
590+
ai_audit = payload.get("ai_audit")
591+
if not isinstance(ai_audit, Mapping) or not _as_bool(ai_audit.get("enabled"), default=False):
592+
return None
593+
status = _normalize_strategy_plugin_field(_optional_string(ai_audit.get("status")))
594+
if status == "ok":
595+
verdict = _optional_string(ai_audit.get("verdict")) or "unknown"
596+
assessment = _optional_string(ai_audit.get("route_assessment")) or "unknown"
597+
summary = _optional_string(ai_audit.get("summary")) or "no summary"
598+
return _translate(
599+
translator,
600+
"strategy_plugin_alert_ai_audit",
601+
fallback="AI audit: {status} | verdict={verdict} | assessment={assessment} | {summary}",
602+
status=status,
603+
verdict=verdict,
604+
assessment=assessment,
605+
summary=summary,
606+
)
607+
reason = _optional_string(ai_audit.get("skip_reason")) or _optional_string(ai_audit.get("error")) or "no detail"
608+
return _translate(
609+
translator,
610+
"strategy_plugin_alert_ai_audit_status",
611+
fallback="AI audit: {status} | {reason}",
612+
status=status,
613+
reason=reason,
614+
)
615+
616+
535617
def build_strategy_plugin_alert_key(
536618
signal: StrategyPluginSignal,
537619
*,
@@ -593,6 +675,7 @@ def build_strategy_plugin_alert_messages(
593675
strategy = str(strategy_label or getattr(signal, "strategy", None) or "").strip() or "unknown"
594676
guidance = build_strategy_plugin_alert_guidance(signal, translator=translator)
595677
scope_note = build_strategy_plugin_alert_scope_note(signal, translator=translator)
678+
ai_audit_note = build_strategy_plugin_ai_audit_note(signal, translator=translator)
596679
subject = _translate(
597680
translator,
598681
"strategy_plugin_alert_subject",
@@ -665,6 +748,8 @@ def build_strategy_plugin_alert_messages(
665748
guidance=guidance,
666749
)
667750
)
751+
if ai_audit_note:
752+
body_lines.append(ai_audit_note)
668753
if scope_note:
669754
body_lines.append(
670755
_translate(
@@ -686,6 +771,9 @@ def build_strategy_plugin_alert_messages(
686771
"context_label": context or None,
687772
"guidance": guidance,
688773
"scope_note": scope_note,
774+
"ai_audit": getattr(signal, "payload", {}).get("ai_audit")
775+
if isinstance(getattr(signal, "payload", {}), Mapping)
776+
else None,
689777
}
690778
messages.append(
691779
StrategyPluginAlertMessage(

src/quant_platform_kit/ibkr/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .market_data import (
44
fetch_historical_price_candles,
55
fetch_historical_price_series,
6+
fetch_option_chain_snapshot,
67
fetch_quote_snapshots,
78
)
89
from .portfolio import fetch_portfolio_snapshot
@@ -23,6 +24,7 @@
2324
"connect_ib",
2425
"ensure_event_loop",
2526
"fetch_historical_price_candles",
27+
"fetch_option_chain_snapshot",
2628
"submit_order_intent",
2729
"fetch_historical_price_series",
2830
"fetch_quote_snapshots",

src/quant_platform_kit/ibkr/execution.py

Lines changed: 193 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from datetime import date, datetime
34
from typing import Any, Callable
45

56
from quant_platform_kit.common.models import ExecutionReport, OrderIntent
@@ -24,23 +25,203 @@ def _build_stock_contract(
2425
return stock_factory(symbol, exchange, currency)
2526

2627

28+
def _normalize_option_expiration(value: Any) -> str:
29+
text = str(value or "").strip()
30+
if len(text) == 8 and text.isdigit():
31+
return text
32+
if not text:
33+
raise ValueError("Option OrderIntent.metadata.expiration is required.")
34+
if isinstance(value, datetime):
35+
return value.date().strftime("%Y%m%d")
36+
if isinstance(value, date):
37+
return value.strftime("%Y%m%d")
38+
try:
39+
return datetime.fromisoformat(text[:10]).date().strftime("%Y%m%d")
40+
except ValueError as exc:
41+
raise ValueError(f"Invalid option expiration: {value!r}") from exc
42+
43+
44+
def _normalize_option_right(value: Any) -> str:
45+
text = str(value or "").strip().upper()
46+
if text in {"CALL", "C"}:
47+
return "C"
48+
if text in {"PUT", "P"}:
49+
return "P"
50+
raise ValueError("Option OrderIntent.metadata.right must be C/call or P/put.")
51+
52+
53+
def _build_option_contract(
54+
order_intent: OrderIntent,
55+
*,
56+
option_factory: Callable[..., Any] | None = None,
57+
exchange: str = "SMART",
58+
currency: str = "USD",
59+
) -> Any:
60+
metadata = dict(order_intent.metadata or {})
61+
underlier = str(metadata.get("underlier") or order_intent.symbol or "").strip().upper()
62+
if not underlier:
63+
raise ValueError("Option OrderIntent requires symbol or metadata.underlier.")
64+
expiration = _normalize_option_expiration(metadata.get("expiration"))
65+
right = _normalize_option_right(metadata.get("right"))
66+
try:
67+
strike = float(metadata.get("strike"))
68+
except (TypeError, ValueError) as exc:
69+
raise ValueError("Option OrderIntent.metadata.strike is required.") from exc
70+
if strike <= 0.0:
71+
raise ValueError("Option OrderIntent.metadata.strike must be positive.")
72+
if option_factory is None:
73+
from ib_insync import Option
74+
75+
option_factory = Option
76+
return option_factory(
77+
underlier,
78+
expiration,
79+
strike,
80+
right,
81+
exchange=exchange,
82+
currency=currency,
83+
)
84+
85+
86+
def _is_option_intent(order_intent: OrderIntent) -> bool:
87+
metadata = dict(order_intent.metadata or {})
88+
return (
89+
str(metadata.get("asset_class") or "").strip().lower() == "option"
90+
or str(metadata.get("security_type") or "").strip().upper() == "OPT"
91+
or str(metadata.get("security_type") or "").strip().upper() == "BAG"
92+
or str(metadata.get("intent_type") or "").strip() == "single_leg_option"
93+
or str(metadata.get("intent_type") or "").strip() == "multi_leg_option"
94+
)
95+
96+
97+
def _is_combo_option_intent(order_intent: OrderIntent) -> bool:
98+
metadata = dict(order_intent.metadata or {})
99+
return (
100+
str(metadata.get("asset_class") or "").strip().lower() == "option"
101+
and str(metadata.get("intent_type") or "").strip() == "multi_leg_option"
102+
)
103+
104+
105+
def _leg_action(value: Any) -> str:
106+
text = str(value or "").strip().lower()
107+
if text.startswith("buy"):
108+
return "BUY"
109+
if text.startswith("sell"):
110+
return "SELL"
111+
raise ValueError(f"Unsupported option combo leg action: {value!r}")
112+
113+
114+
def _build_option_combo_contract(
115+
ib: Any,
116+
order_intent: OrderIntent,
117+
*,
118+
option_factory: Callable[..., Any] | None = None,
119+
combo_contract_factory: Callable[..., Any] | None = None,
120+
combo_leg_factory: Callable[..., Any] | None = None,
121+
exchange: str = "SMART",
122+
currency: str = "USD",
123+
) -> Any:
124+
metadata = dict(order_intent.metadata or {})
125+
underlier = str(metadata.get("underlier") or order_intent.symbol or "").strip().upper()
126+
legs = tuple(metadata.get("legs") or ())
127+
if not underlier or not legs:
128+
raise ValueError("Multi-leg option OrderIntent requires metadata.underlier and metadata.legs.")
129+
if combo_contract_factory is None:
130+
from ib_insync import Contract
131+
132+
combo_contract_factory = Contract
133+
if combo_leg_factory is None:
134+
from ib_insync import ComboLeg
135+
136+
combo_leg_factory = ComboLeg
137+
138+
combo_legs = []
139+
for leg in legs:
140+
if not isinstance(leg, dict):
141+
raise ValueError("Option combo legs must be mappings.")
142+
option_contract = _build_option_contract(
143+
OrderIntent(
144+
symbol=underlier,
145+
side=_leg_action(leg.get("action")),
146+
quantity=1,
147+
metadata={
148+
"underlier": underlier,
149+
"expiration": leg.get("expiration") or metadata.get("expiration"),
150+
"right": leg.get("right"),
151+
"strike": leg.get("strike"),
152+
},
153+
),
154+
option_factory=option_factory,
155+
exchange=exchange,
156+
currency=currency,
157+
)
158+
qualified = ib.qualifyContracts(option_contract)
159+
qualified_contract = qualified[0] if qualified else option_contract
160+
con_id = getattr(qualified_contract, "conId", None)
161+
if con_id is None:
162+
raise ValueError("Qualified option combo leg did not expose conId.")
163+
combo_legs.append(
164+
combo_leg_factory(
165+
conId=con_id,
166+
ratio=int(leg.get("ratio") or 1),
167+
action=_leg_action(leg.get("action")),
168+
exchange=exchange,
169+
)
170+
)
171+
172+
contract = combo_contract_factory()
173+
contract.symbol = underlier
174+
contract.secType = "BAG"
175+
contract.exchange = exchange
176+
contract.currency = currency
177+
contract.comboLegs = combo_legs
178+
return contract
179+
180+
181+
def _normalize_order_side(side: str) -> str:
182+
text = str(side or "").strip().lower()
183+
if text.startswith("buy"):
184+
return "BUY"
185+
if text.startswith("sell"):
186+
return "SELL"
187+
raise ValueError(f"Unsupported order side: {side!r}")
188+
189+
27190
def submit_order_intent(
28191
ib: Any,
29192
order_intent: OrderIntent,
30193
*,
31194
account_id: str | None = None,
32195
wait_seconds: float = 1.0,
33196
stock_factory: Callable[..., Any] | None = None,
197+
option_factory: Callable[..., Any] | None = None,
198+
combo_contract_factory: Callable[..., Any] | None = None,
199+
combo_leg_factory: Callable[..., Any] | None = None,
34200
market_order_factory: Callable[..., Any] | None = None,
35201
limit_order_factory: Callable[..., Any] | None = None,
36202
) -> ExecutionReport:
37-
contract = _build_stock_contract(
38-
order_intent.symbol,
39-
stock_factory=stock_factory,
40-
)
203+
metadata = dict(order_intent.metadata or {})
204+
if _is_combo_option_intent(order_intent):
205+
contract = _build_option_combo_contract(
206+
ib,
207+
order_intent,
208+
option_factory=option_factory,
209+
combo_contract_factory=combo_contract_factory,
210+
combo_leg_factory=combo_leg_factory,
211+
)
212+
elif _is_option_intent(order_intent):
213+
contract = _build_option_contract(
214+
order_intent,
215+
option_factory=option_factory,
216+
)
217+
else:
218+
contract = _build_stock_contract(
219+
order_intent.symbol,
220+
stock_factory=stock_factory,
221+
)
41222
ib.qualifyContracts(contract)
42223

43-
side = order_intent.side.upper()
224+
side = _normalize_order_side(order_intent.side)
44225
order_type = order_intent.order_type.lower()
45226
if order_type == "market":
46227
if market_order_factory is None:
@@ -90,5 +271,12 @@ def submit_order_intent(
90271
"order_type": order_type,
91272
"time_in_force": getattr(order, "tif", None),
92273
"account_id": resolved_account_id,
274+
"asset_class": metadata.get("asset_class"),
275+
"intent_type": metadata.get("intent_type"),
276+
"underlier": metadata.get("underlier"),
277+
"right": metadata.get("right"),
278+
"expiration": metadata.get("expiration"),
279+
"strike": metadata.get("strike"),
280+
"legs": metadata.get("legs"),
93281
},
94282
)

0 commit comments

Comments
 (0)