Skip to content

Commit 1f34c84

Browse files
committed
feat(tools): add place_option_combo_order for multileg orders
1 parent 7418b49 commit 1f34c84

File tree

2 files changed

+93
-2
lines changed

2 files changed

+93
-2
lines changed

src/schwab_mcp/tools/orders.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from schwab.orders.common import first_triggers_second as trigger_builder
99
from schwab.orders.common import one_cancels_other as oco_builder
1010
from schwab.orders.options import OptionSymbol
11+
from schwab.orders.generic import OrderBuilder
12+
from schwab.orders.common import ComplexOrderStrategyType
1113

1214
from schwab_mcp.context import SchwabContext
1315
from schwab_mcp.tools._registration import register_tool
@@ -584,6 +586,72 @@ async def place_bracket_order(
584586
)
585587

586588

589+
async def place_option_combo_order(
590+
ctx: SchwabContext,
591+
account_hash: Annotated[str, "Account hash for the Schwab account"],
592+
legs: Annotated[
593+
list[dict[str, Any]],
594+
"List of option legs. Each leg requires: 'symbol' (str), 'quantity' (int), 'instruction' (BUY_TO_OPEN/SELL_TO_OPEN/BUY_TO_CLOSE/SELL_TO_CLOSE).",
595+
],
596+
order_type: Annotated[str, "Combo order type: NET_CREDIT, NET_DEBIT, NET_ZERO, or MARKET"],
597+
price: Annotated[
598+
float | None,
599+
"Net price for the combo (required for NET_CREDIT/NET_DEBIT; omit for MARKET/NET_ZERO).",
600+
] = None,
601+
session: Annotated[
602+
str | None, "Trading session: NORMAL (default), AM, PM, or SEAMLESS"
603+
] = "NORMAL",
604+
duration: Annotated[
605+
str | None, "Order duration: DAY (default) or GOOD_TILL_CANCEL"
606+
] = "DAY",
607+
complex_order_strategy_type: Annotated[
608+
str | None,
609+
"Optional complex type: IRON_CONDOR, VERTICAL, CALENDAR, CUSTOM, etc. Defaults to CUSTOM.",
610+
] = "CUSTOM",
611+
) -> JSONType:
612+
"""
613+
Places a single multi-leg option order (combo/spread) with a net price.
614+
615+
- Submit multiple option legs in one order payload using a single net
616+
price for LIMIT orders.
617+
- Each leg must include: instruction, symbol, quantity.
618+
- Example legs item: {"instruction": "SELL_TO_OPEN", "symbol": "SPY 251121C500", "quantity": 1}
619+
620+
Notes:
621+
- LIMIT is recommended for combos; MARKET support may vary by account/venue.
622+
- The API infers debit/credit from leg directions; pass a positive price.
623+
*Write operation.*
624+
"""
625+
if not legs or len(legs) < 2:
626+
raise ValueError("Provide at least two option legs for a combo order")
627+
628+
# Build a single order with multiple option legs
629+
builder = OrderBuilder(enforce_enums=False).set_order_strategy_type("SINGLE")
630+
631+
# Apply session/duration consistently with other tools
632+
builder = _apply_order_settings(builder, session, duration)
633+
634+
# complex order type helps the API validate multi-leg intent
635+
if complex_order_strategy_type:
636+
builder = builder.set_complex_order_strategy_type(complex_order_strategy_type.upper())
637+
638+
# Set order type and net price
639+
builder = builder.set_order_type(order_type.upper())
640+
if price is not None:
641+
builder = builder.set_price(price) # net debit/credit as positive number
642+
643+
for leg in legs:
644+
builder = builder.add_option_leg(
645+
leg["instruction"],
646+
leg["symbol"],
647+
leg["quantity"],
648+
)
649+
650+
return await call(
651+
ctx.orders.place_order, account_hash=account_hash, order_spec=builder.build()
652+
)
653+
654+
587655
_READ_ONLY_TOOLS = (
588656
get_order,
589657
get_orders,
@@ -599,6 +667,7 @@ async def place_bracket_order(
599667
place_one_cancels_other_order,
600668
place_first_triggers_second_order,
601669
place_bracket_order,
670+
place_option_combo_order,
602671
)
603672

604673

src/schwab_mcp/tools/utils.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,33 @@
88
JSONType: TypeAlias = JSONPrimitive | dict[str, Any] | list[Any]
99

1010

11+
class SchwabAPIError(Exception):
12+
"""Represents an error response returned from the Schwab API."""
13+
14+
def __init__(
15+
self,
16+
*,
17+
status_code: int,
18+
url: str,
19+
body: str,
20+
) -> None:
21+
super().__init__(
22+
f"Schwab API request failed; status={status_code}; url={url}; body={body}"
23+
)
24+
25+
1126
async def call(func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -> JSONType:
1227
"""Call a Schwab client endpoint and return its JSON payload."""
1328

1429
response = await func(*args, **kwargs)
15-
response.raise_for_status()
30+
try:
31+
response.raise_for_status()
32+
except Exception as exc:
33+
raise SchwabAPIError(
34+
status_code=response.status_code,
35+
url=response.url,
36+
body=response.text,
37+
) from exc
1638

1739
# Handle responses with no content
1840
# 204 No Content: explicit no-content response
@@ -33,4 +55,4 @@ async def call(func: Callable[..., Awaitable[Any]], *args: Any, **kwargs: Any) -
3355
raise ValueError("Expected JSON response from Schwab endpoint") from exc
3456

3557

36-
__all__ = ["call", "JSONType"]
58+
__all__ = ["call", "JSONType", "SchwabAPIError"]

0 commit comments

Comments
 (0)