Skip to content

Commit e9690eb

Browse files
authored
Merge pull request modelcontextprotocol#19 from jkoelker/jk/multileg
feat(tools): add place_option_combo_order for multileg orders
2 parents 715b688 + 1f34c84 commit e9690eb

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
@@ -606,6 +608,72 @@ async def place_bracket_order(
606608
)
607609

608610

611+
async def place_option_combo_order(
612+
ctx: SchwabContext,
613+
account_hash: Annotated[str, "Account hash for the Schwab account"],
614+
legs: Annotated[
615+
list[dict[str, Any]],
616+
"List of option legs. Each leg requires: 'symbol' (str), 'quantity' (int), 'instruction' (BUY_TO_OPEN/SELL_TO_OPEN/BUY_TO_CLOSE/SELL_TO_CLOSE).",
617+
],
618+
order_type: Annotated[str, "Combo order type: NET_CREDIT, NET_DEBIT, NET_ZERO, or MARKET"],
619+
price: Annotated[
620+
float | None,
621+
"Net price for the combo (required for NET_CREDIT/NET_DEBIT; omit for MARKET/NET_ZERO).",
622+
] = None,
623+
session: Annotated[
624+
str | None, "Trading session: NORMAL (default), AM, PM, or SEAMLESS"
625+
] = "NORMAL",
626+
duration: Annotated[
627+
str | None, "Order duration: DAY (default) or GOOD_TILL_CANCEL"
628+
] = "DAY",
629+
complex_order_strategy_type: Annotated[
630+
str | None,
631+
"Optional complex type: IRON_CONDOR, VERTICAL, CALENDAR, CUSTOM, etc. Defaults to CUSTOM.",
632+
] = "CUSTOM",
633+
) -> JSONType:
634+
"""
635+
Places a single multi-leg option order (combo/spread) with a net price.
636+
637+
- Submit multiple option legs in one order payload using a single net
638+
price for LIMIT orders.
639+
- Each leg must include: instruction, symbol, quantity.
640+
- Example legs item: {"instruction": "SELL_TO_OPEN", "symbol": "SPY 251121C500", "quantity": 1}
641+
642+
Notes:
643+
- LIMIT is recommended for combos; MARKET support may vary by account/venue.
644+
- The API infers debit/credit from leg directions; pass a positive price.
645+
*Write operation.*
646+
"""
647+
if not legs or len(legs) < 2:
648+
raise ValueError("Provide at least two option legs for a combo order")
649+
650+
# Build a single order with multiple option legs
651+
builder = OrderBuilder(enforce_enums=False).set_order_strategy_type("SINGLE")
652+
653+
# Apply session/duration consistently with other tools
654+
builder = _apply_order_settings(builder, session, duration)
655+
656+
# complex order type helps the API validate multi-leg intent
657+
if complex_order_strategy_type:
658+
builder = builder.set_complex_order_strategy_type(complex_order_strategy_type.upper())
659+
660+
# Set order type and net price
661+
builder = builder.set_order_type(order_type.upper())
662+
if price is not None:
663+
builder = builder.set_price(price) # net debit/credit as positive number
664+
665+
for leg in legs:
666+
builder = builder.add_option_leg(
667+
leg["instruction"],
668+
leg["symbol"],
669+
leg["quantity"],
670+
)
671+
672+
return await call(
673+
ctx.orders.place_order, account_hash=account_hash, order_spec=builder.build()
674+
)
675+
676+
609677
_READ_ONLY_TOOLS = (
610678
get_order,
611679
get_orders,
@@ -621,6 +689,7 @@ async def place_bracket_order(
621689
place_one_cancels_other_order,
622690
place_first_triggers_second_order,
623691
place_bracket_order,
692+
place_option_combo_order,
624693
)
625694

626695

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)