Skip to content

Commit d21199d

Browse files
author
方佳
committed
Merge branch 'main_merge_20260904' into 'main'
[API-1623]: support option leg-in/leg-out on single-leg orders (US only) See merge request webull/webull-openapi-mcp!21
2 parents eeb8d5c + 744a0d0 commit d21199d

6 files changed

Lines changed: 181 additions & 57 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "webull-openapi-mcp"
7-
version = "1.2.2"
7+
version = "1.2.3"
88
description = "MCP Server for Webull OpenAPI - enables AI assistants to securely access Webull trading and market data"
99
readme = "README.md"
1010
license = "Apache-2.0"

‎webull_openapi_mcp/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Webull OpenAPI MCP Server - AI assistant integration for Webull OpenAPI."""
22

3-
__version__ = "1.2.2"
3+
__version__ = "1.2.3"

‎webull_openapi_mcp/constants.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
"SELL_TO_CLOSE",
3636
})
3737

38+
# Option leg-in / leg-out values (US only)
39+
VALID_LEG_IN_OUT: frozenset[str] = frozenset({"LEG_IN", "LEG_OUT"})
40+
3841
# Trading session types
3942
VALID_TRADING_SESSIONS: frozenset[str] = frozenset({"ALL", "CORE", "NIGHT"})
4043

‎webull_openapi_mcp/formatters.py‎

Lines changed: 100 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -215,34 +215,86 @@ def format_account_balance(data: dict | None) -> str:
215215

216216
def format_positions(data: list[dict] | None) -> str:
217217
"""Format account positions response.
218-
219-
API returns: [{position_id, currency, quantity, symbol, option_strategy,
220-
instrument_type, last_price, cost_price, unrealized_profit_loss, legs: [...]}]
218+
219+
Top-level core (all regions): position_id, currency, quantity, symbol,
220+
option_strategy, instrument_type, last_price, cost_price,
221+
unrealized_profit_loss, legs: [...].
222+
Top-level region-specific:
223+
- US: event_outcome (event orders only)
224+
- JP: available_quantity, market_value, symbol_name, exchange_code,
225+
instrument_id, account_tax_type, base_currency, fx_rate,
226+
base_currency_market_value
227+
228+
Leg fields differ by region:
229+
- US: side, quantity, market, instrument_type, symbol, strike_price,
230+
option_expire_date, option_type (no leg_id)
231+
- HK: symbol, quantity, option_type, option_expire_date,
232+
option_exercise_price, option_contract_multiplier,
233+
option_contract_deliverable, expiration_type (no leg_id)
234+
- JP: same as HK plus leg_id
235+
236+
The formatter reads whichever fields are present so it works across regions.
221237
"""
222238
if not data:
223239
return _NO_DATA
224240
lines: list[str] = ["=== Positions ==="]
225241
for pos in data:
226-
lines.append(
227-
f" {_get(pos, 'symbol'):>8s} "
228-
f"Qty: {_get(pos, 'quantity'):>8s} "
229-
f"Type: {_get(pos, 'instrument_type'):>6s} "
230-
f"Cost: {_get(pos, 'cost_price'):>10s} "
231-
f"Last: {_get(pos, 'last_price'):>10s} "
232-
f"Unrealized P&L: {_get(pos, 'unrealized_profit_loss'):>10s} "
233-
f"Currency: {_get(pos, 'currency')}"
234-
)
235-
# Option legs
242+
# Top-level fields — show only present fields (varies by region).
243+
head_fields: list[tuple[str, str]] = [
244+
("Symbol", "symbol"),
245+
("Position ID", "position_id"),
246+
("Qty", "quantity"),
247+
("Available Qty", "available_quantity"),
248+
("Type", "instrument_type"),
249+
("Strategy", "option_strategy"),
250+
("Name", "symbol_name"),
251+
("Exchange", "exchange_code"),
252+
("Instrument ID", "instrument_id"),
253+
("Cost", "cost_price"),
254+
("Last", "last_price"),
255+
("Market Value", "market_value"),
256+
("Unrealized P&L", "unrealized_profit_loss"),
257+
("Currency", "currency"),
258+
("Event Outcome", "event_outcome"),
259+
("Tax Type", "account_tax_type"),
260+
("Base Currency", "base_currency"),
261+
("FX Rate", "fx_rate"),
262+
("Base Market Value", "base_currency_market_value"),
263+
]
264+
head_parts = [
265+
f"{label}: {pos[key]}"
266+
for label, key in head_fields
267+
if pos.get(key) is not None
268+
]
269+
lines.append(" " + " ".join(head_parts))
270+
271+
# Option legs — field set varies by region; show only present fields.
236272
legs = pos.get("legs", [])
237273
for leg in legs:
238-
lines.append(
239-
f"{'':>10s} "
240-
f"Leg: {_get(leg, 'symbol')} "
241-
f"Qty: {_get(leg, 'quantity')} "
242-
f"Type: {_get(leg, 'option_type')} "
243-
f"Strike: {_get(leg, 'option_exercise_price')} "
244-
f"Exp: {_get(leg, 'option_expire_date')}"
245-
)
274+
parts: list[str] = [f"Leg: {_get(leg, 'symbol')}"]
275+
if leg.get("leg_id") is not None:
276+
parts.append(f"Leg ID: {leg['leg_id']}")
277+
if leg.get("side") is not None:
278+
parts.append(f"Side: {leg['side']}")
279+
parts.append(f"Qty: {_get(leg, 'quantity')}")
280+
if leg.get("instrument_type") is not None:
281+
parts.append(f"Type: {leg['instrument_type']}")
282+
if leg.get("option_type") is not None:
283+
parts.append(f"Option: {leg['option_type']}")
284+
# Strike: US uses strike_price, HK/JP use option_exercise_price
285+
strike = leg.get("strike_price") or leg.get("option_exercise_price")
286+
if strike is not None:
287+
parts.append(f"Strike: {strike}")
288+
if leg.get("option_expire_date") is not None:
289+
parts.append(f"Exp: {leg['option_expire_date']}")
290+
# HK/JP-only option contract fields
291+
if leg.get("option_contract_multiplier") is not None:
292+
parts.append(f"Multiplier: {leg['option_contract_multiplier']}")
293+
if leg.get("option_contract_deliverable") is not None:
294+
parts.append(f"Deliverable: {leg['option_contract_deliverable']}")
295+
if leg.get("expiration_type") is not None:
296+
parts.append(f"ExpType: {leg['expiration_type']}")
297+
lines.append(f"{'':>10s} " + " ".join(parts))
246298
return "\n".join(lines)
247299

248300

@@ -262,45 +314,38 @@ def format_position_details(data: list[dict] | dict | None) -> str:
262314
if not isinstance(data, list) or not data:
263315
return _NO_DATA
264316

317+
# Field labels in display order; only present fields are shown.
318+
fields: list[tuple[str, str]] = [
319+
("Position ID", "id"),
320+
("Symbol", "symbol"),
321+
("Name", "symbol_name"),
322+
("Exchange", "exchange_code"),
323+
("Instrument ID", "instrument_id"),
324+
("Contract ID", "contract_id"),
325+
("Qty", "quantity"),
326+
("Hold Type", "hold_type"),
327+
("Margin Type", "margin_type"),
328+
("Average Price", "average_price"),
329+
("Market Value", "market_value"),
330+
("Unrealized P&L", "unrealized_pl"),
331+
("Currency", "currency"),
332+
("Account Tax Type", "account_tax_type"),
333+
("Base Currency", "base_currency"),
334+
("FX Rate", "fx_rate"),
335+
("Base Currency Market Value", "base_currency_market_value"),
336+
]
337+
265338
lines: list[str] = ["=== Position Details ==="]
266339
for i, detail in enumerate(data, 1):
267340
if not isinstance(detail, dict):
268341
continue
269342
lines.append(f"\n[Position Detail {i}]")
270-
lines.append(
271-
f" {_get(detail, 'symbol'):>8s} "
272-
f"Qty: {_get(detail, 'quantity'):>8s} "
273-
f"Hold: {_get(detail, 'hold_type'):>6s} "
274-
f"Market Value: {_get(detail, 'market_value'):>10s} "
275-
f"Currency: {_get(detail, 'currency')}"
276-
)
277-
lines.append(
278-
f"{'':>10s} "
279-
f"Name: {_get(detail, 'symbol_name')} "
280-
f"Exchange: {_get(detail, 'exchange_code')}"
281-
)
282-
lines.append(
283-
f"{'':>10s} "
284-
f"Average Price: {_get(detail, 'average_price'):>10s} "
285-
f"Unrealized P&L: {_get(detail, 'unrealized_pl'):>10s}"
286-
)
287-
lines.append(
288-
f"{'':>10s} "
289-
f"Account Tax Type: {_get(detail, 'account_tax_type')} "
290-
f"Margin Type: {_get(detail, 'margin_type')}"
291-
)
292-
lines.append(
293-
f"{'':>10s} "
294-
f"Instrument ID: {_get(detail, 'instrument_id')} "
295-
f"Contract ID: {_get(detail, 'contract_id')} "
296-
f"Position ID: {_get(detail, 'id')}"
297-
)
298-
lines.append(
299-
f"{'':>10s} "
300-
f"Base Currency: {_get(detail, 'base_currency')} "
301-
f"FX Rate: {_get(detail, 'fx_rate')} "
302-
f"Base Currency Market Value: {_get(detail, 'base_currency_market_value')}"
303-
)
343+
parts = [
344+
f"{label}: {detail[key]}"
345+
for label, key in fields
346+
if detail.get(key) is not None
347+
]
348+
lines.append(" " + " ".join(parts))
304349

305350
if len(lines) == 1:
306351
return _NO_DATA

‎webull_openapi_mcp/tools/trading/option_order.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ def _build_option_order(
8181
limit_price: float | None,
8282
stop_price: float | None,
8383
position_intent: str | None = None,
84+
leg_in_or_out: str | None = None,
85+
position_id: str | None = None,
86+
leg_in_strategy: str | None = None,
8487
) -> dict:
8588
"""Build a single-leg option order dict for the SDK."""
8689
order: dict = {
@@ -109,6 +112,13 @@ def _build_option_order(
109112
_add_optional_str(order, "stop_price", stop_price)
110113
if position_intent is not None:
111114
order["position_intent"] = position_intent
115+
# Option leg-in / leg-out fields (US only)
116+
if leg_in_or_out is not None:
117+
order["leg_in_or_out"] = leg_in_or_out
118+
if position_id is not None:
119+
order["position_id"] = position_id
120+
if leg_in_strategy is not None:
121+
order["leg_in_strategy"] = leg_in_strategy
112122
return order
113123

114124

@@ -237,6 +247,10 @@ def register_option_single_tools(
237247
"order_type: MARKET, LIMIT, STOP_LOSS, STOP_LOSS_LIMIT.\n"
238248
"time_in_force: DAY, GTC. trading_session: CORE only.\n"
239249
"position_intent: BUY_TO_OPEN, BUY_TO_CLOSE, SELL_TO_OPEN, SELL_TO_CLOSE (optional, US only).\n"
250+
"Leg-in / leg-out (US only, single-leg option): "
251+
"leg_in_or_out=LEG_IN adds this leg to an existing position; LEG_OUT closes a leg from a multi-leg position. "
252+
"position_id (from Account Positions API) is required when leg_in_or_out is set. "
253+
"leg_in_strategy (COVERED_STOCK/VERTICAL/STRADDLE/STRANGLE/CALENDAR/DIAGONAL) is required for LEG_IN, not allowed for LEG_OUT.\n"
240254
"Returns: {client_order_id, order_id}"
241255
),
242256
)
@@ -254,6 +268,9 @@ async def place_option_single_order(
254268
limit_price: Optional[float] = None,
255269
stop_price: Optional[float] = None,
256270
position_intent: Optional[str] = None,
271+
leg_in_or_out: Optional[str] = None,
272+
position_id: Optional[str] = None,
273+
leg_in_strategy: Optional[str] = None,
257274
) -> str:
258275
"""Place a single-leg option order."""
259276
audit.log_tool_call("place_option_single_order", {"symbol": symbol, "side": side})
@@ -283,6 +300,27 @@ async def place_option_single_order(
283300
f"must be one of {sorted(VALID_POSITION_INTENTS)}"
284301
)
285302

303+
# Leg-in / leg-out validation (US only)
304+
if leg_in_or_out is not None or position_id is not None or leg_in_strategy is not None:
305+
from webull_openapi_mcp.constants import VALID_LEG_IN_OUT
306+
if config.region_id != "us":
307+
return "Validation error: leg-in / leg-out is only supported for US region"
308+
if leg_in_or_out is None:
309+
return "Validation error: leg_in_or_out is required when position_id or leg_in_strategy is provided"
310+
if leg_in_or_out not in VALID_LEG_IN_OUT:
311+
return (
312+
f"Validation error: Invalid leg_in_or_out '{leg_in_or_out}', "
313+
f"must be one of {sorted(VALID_LEG_IN_OUT)}"
314+
)
315+
if position_id is None:
316+
return "Validation error: position_id is required when leg_in_or_out is specified"
317+
if leg_in_or_out == "LEG_IN":
318+
if leg_in_strategy is None:
319+
return "Validation error: leg_in_strategy is required when leg_in_or_out is LEG_IN"
320+
else: # LEG_OUT
321+
if leg_in_strategy is not None:
322+
return "Validation error: leg_in_strategy is not allowed when leg_in_or_out is LEG_OUT"
323+
286324
try:
287325
validate_client_order_id(client_order_id)
288326
except ValidationError as e:
@@ -297,6 +335,9 @@ async def place_option_single_order(
297335
time_in_force=time_in_force,
298336
limit_price=limit_price, stop_price=stop_price,
299337
position_intent=position_intent,
338+
leg_in_or_out=leg_in_or_out,
339+
position_id=position_id,
340+
leg_in_strategy=leg_in_strategy,
300341
)
301342

302343
audit.log_order_attempt(

‎webull_openapi_mcp/tools/trading/stock_order.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,10 @@ def _build_stock_order(params: dict) -> dict:
128128
_add_optional_str(order, "position_intent", params.get("position_intent"))
129129
if params.get("close_contracts") is not None:
130130
order["close_contracts"] = params["close_contracts"]
131+
# Option leg-in / leg-out fields (US only) — e.g. legging a stock leg out of a combo
132+
_add_optional_str(order, "leg_in_or_out", params.get("leg_in_or_out"))
133+
_add_optional_str(order, "position_id", params.get("position_id"))
134+
_add_optional_str(order, "leg_in_strategy", params.get("leg_in_strategy"))
131135

132136
return order
133137

@@ -447,6 +451,10 @@ def register_stock_order_tools(
447451
"margin_type must be ONE_DAY or INDEFINITE and is margin-account-only; "
448452
"position_intent must be BUY_TO_OPEN, BUY_TO_CLOSE, SELL_TO_OPEN, or SELL_TO_CLOSE and is margin-account-only; "
449453
"close_contracts account eligibility is checked by the API backend.\n"
454+
"Leg-in / leg-out (US only): use to leg a STOCK leg into/out of an option combo position. "
455+
"leg_in_or_out=LEG_IN adds this stock leg to an existing position; LEG_OUT closes the stock leg from a multi-leg combo. "
456+
"position_id (from Account Positions API) is required when leg_in_or_out is set. "
457+
"leg_in_strategy (COVERED_STOCK/VERTICAL/STRADDLE/STRANGLE/CALENDAR/DIAGONAL) is required for LEG_IN, not allowed for LEG_OUT.\n"
450458
"Returns: {client_order_id, order_id}"
451459
),
452460
)
@@ -472,6 +480,9 @@ async def place_stock_order(
472480
margin_type: Optional[MarginType] = None,
473481
position_intent: Optional[PositionIntent] = None,
474482
close_contracts: Optional[list[dict]] = None,
483+
leg_in_or_out: Optional[str] = None,
484+
position_id: Optional[str] = None,
485+
leg_in_strategy: Optional[str] = None,
475486
) -> str:
476487
"""Place a single stock order (non-combo).
477488
@@ -531,6 +542,27 @@ async def place_stock_order(
531542
except ValidationError as e:
532543
return f"Validation error: {e.message}"
533544

545+
# Leg-in / leg-out validation (US only) — e.g. legging a stock leg into/out of a combo
546+
if leg_in_or_out is not None or position_id is not None or leg_in_strategy is not None:
547+
from webull_openapi_mcp.constants import VALID_LEG_IN_OUT
548+
if config.region_id != "us":
549+
return "Validation error: leg-in / leg-out is only supported for US region"
550+
if leg_in_or_out is None:
551+
return "Validation error: leg_in_or_out is required when position_id or leg_in_strategy is provided"
552+
if leg_in_or_out not in VALID_LEG_IN_OUT:
553+
return (
554+
f"Validation error: Invalid leg_in_or_out '{leg_in_or_out}', "
555+
f"must be one of {sorted(VALID_LEG_IN_OUT)}"
556+
)
557+
if position_id is None:
558+
return "Validation error: position_id is required when leg_in_or_out is specified"
559+
if leg_in_or_out == "LEG_IN":
560+
if leg_in_strategy is None:
561+
return "Validation error: leg_in_strategy is required when leg_in_or_out is LEG_IN"
562+
else: # LEG_OUT
563+
if leg_in_strategy is not None:
564+
return "Validation error: leg_in_strategy is not allowed when leg_in_or_out is LEG_OUT"
565+
534566
coid = client_order_id or _generate_client_order_id()
535567

536568
order = _build_stock_order({
@@ -546,6 +578,9 @@ async def place_stock_order(
546578
"margin_type": margin_type,
547579
"position_intent": position_intent,
548580
"close_contracts": close_contracts,
581+
"leg_in_or_out": leg_in_or_out,
582+
"position_id": position_id,
583+
"leg_in_strategy": leg_in_strategy,
549584
})
550585

551586
audit.log_order_attempt(

0 commit comments

Comments
 (0)