webull-openapi-mcp 1.2.2__tar.gz → 1.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/PKG-INFO +1 -1
  2. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/pyproject.toml +1 -1
  3. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/__init__.py +1 -1
  4. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/constants.py +3 -0
  5. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/formatters.py +100 -55
  6. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/option_order.py +41 -0
  7. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/stock_order.py +35 -0
  8. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/.env.example +0 -0
  9. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/.github/workflows/pypi-ci.yml +0 -0
  10. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/.gitignore +0 -0
  11. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/DISCLAIMER.md +0 -0
  12. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/LICENSE +0 -0
  13. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/README.md +0 -0
  14. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/mcpb/.mcpbignore +0 -0
  15. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/mcpb/build.sh +0 -0
  16. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/mcpb/build_manifest.py +0 -0
  17. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/mcpb/src/server.py +0 -0
  18. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/server.json +0 -0
  19. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/__init__.py +0 -0
  20. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_account_tools.py +0 -0
  21. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_audit.py +0 -0
  22. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_cli.py +0 -0
  23. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_config.py +0 -0
  24. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_errors.py +0 -0
  25. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_formatters.py +0 -0
  26. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_fundamental_screener_registration.py +0 -0
  27. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_fundamental_tools.py +0 -0
  28. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_guards.py +0 -0
  29. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_instrument_tools.py +0 -0
  30. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_market_data_tools.py +0 -0
  31. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_property_audit.py +0 -0
  32. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_property_config.py +0 -0
  33. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_property_formatters.py +0 -0
  34. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_property_guards.py +0 -0
  35. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_property_sdk.py +0 -0
  36. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_property_server.py +0 -0
  37. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_screener_tools.py +0 -0
  38. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_sdk_client.py +0 -0
  39. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/tests/test_stock_order_tools.py +0 -0
  40. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/__main__.py +0 -0
  41. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/audit.py +0 -0
  42. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/cli.py +0 -0
  43. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/config.py +0 -0
  44. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/errors.py +0 -0
  45. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/guards.py +0 -0
  46. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/region_config.py +0 -0
  47. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/sdk_client.py +0 -0
  48. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/server.py +0 -0
  49. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/__init__.py +0 -0
  50. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/__init__.py +0 -0
  51. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/crypto.py +0 -0
  52. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/event.py +0 -0
  53. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/financial.py +0 -0
  54. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/fundamental.py +0 -0
  55. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/futures.py +0 -0
  56. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/option.py +0 -0
  57. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/screener.py +0 -0
  58. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/stock.py +0 -0
  59. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/market_data/watchlist.py +0 -0
  60. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/__init__.py +0 -0
  61. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/account.py +0 -0
  62. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/assets.py +0 -0
  63. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/crypto_order.py +0 -0
  64. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/event_order.py +0 -0
  65. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/futures_order.py +0 -0
  66. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/instrument.py +0 -0
  67. {webull_openapi_mcp-1.2.2 → webull_openapi_mcp-1.2.3}/webull_openapi_mcp/tools/trading/order.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: webull-openapi-mcp
3
- Version: 1.2.2
3
+ Version: 1.2.3
4
4
  Summary: MCP Server for Webull OpenAPI - enables AI assistants to securely access Webull trading and market data
5
5
  Project-URL: Homepage, https://github.com/webull-inc/webull-openapi-mcp
6
6
  Project-URL: Repository, https://github.com/webull-inc/webull-openapi-mcp
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "webull-openapi-mcp"
7
- version = "1.2.2"
7
+ version = "1.2.3"
8
8
  description = "MCP Server for Webull OpenAPI - enables AI assistants to securely access Webull trading and market data"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,3 +1,3 @@
1
1
  """Webull OpenAPI MCP Server - AI assistant integration for Webull OpenAPI."""
2
2
 
3
- __version__ = "1.2.2"
3
+ __version__ = "1.2.3"
@@ -35,6 +35,9 @@ VALID_POSITION_INTENTS: frozenset[str] = frozenset({
35
35
  "SELL_TO_CLOSE",
36
36
  })
37
37
 
38
+ # Option leg-in / leg-out values (US only)
39
+ VALID_LEG_IN_OUT: frozenset[str] = frozenset({"LEG_IN", "LEG_OUT"})
40
+
38
41
  # Trading session types
39
42
  VALID_TRADING_SESSIONS: frozenset[str] = frozenset({"ALL", "CORE", "NIGHT"})
40
43
 
@@ -215,34 +215,86 @@ def format_account_balance(data: dict | None) -> str:
215
215
 
216
216
  def format_positions(data: list[dict] | None) -> str:
217
217
  """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.
221
237
  """
222
238
  if not data:
223
239
  return _NO_DATA
224
240
  lines: list[str] = ["=== Positions ==="]
225
241
  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.
236
272
  legs = pos.get("legs", [])
237
273
  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))
246
298
  return "\n".join(lines)
247
299
 
248
300
 
@@ -262,45 +314,38 @@ def format_position_details(data: list[dict] | dict | None) -> str:
262
314
  if not isinstance(data, list) or not data:
263
315
  return _NO_DATA
264
316
 
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
+
265
338
  lines: list[str] = ["=== Position Details ==="]
266
339
  for i, detail in enumerate(data, 1):
267
340
  if not isinstance(detail, dict):
268
341
  continue
269
342
  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))
304
349
 
305
350
  if len(lines) == 1:
306
351
  return _NO_DATA
@@ -81,6 +81,9 @@ def _build_option_order(
81
81
  limit_price: float | None,
82
82
  stop_price: float | None,
83
83
  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,
84
87
  ) -> dict:
85
88
  """Build a single-leg option order dict for the SDK."""
86
89
  order: dict = {
@@ -109,6 +112,13 @@ def _build_option_order(
109
112
  _add_optional_str(order, "stop_price", stop_price)
110
113
  if position_intent is not None:
111
114
  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
112
122
  return order
113
123
 
114
124
 
@@ -237,6 +247,10 @@ def register_option_single_tools(
237
247
  "order_type: MARKET, LIMIT, STOP_LOSS, STOP_LOSS_LIMIT.\n"
238
248
  "time_in_force: DAY, GTC. trading_session: CORE only.\n"
239
249
  "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"
240
254
  "Returns: {client_order_id, order_id}"
241
255
  ),
242
256
  )
@@ -254,6 +268,9 @@ def register_option_single_tools(
254
268
  limit_price: Optional[float] = None,
255
269
  stop_price: Optional[float] = None,
256
270
  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,
257
274
  ) -> str:
258
275
  """Place a single-leg option order."""
259
276
  audit.log_tool_call("place_option_single_order", {"symbol": symbol, "side": side})
@@ -283,6 +300,27 @@ def register_option_single_tools(
283
300
  f"must be one of {sorted(VALID_POSITION_INTENTS)}"
284
301
  )
285
302
 
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
+
286
324
  try:
287
325
  validate_client_order_id(client_order_id)
288
326
  except ValidationError as e:
@@ -297,6 +335,9 @@ def register_option_single_tools(
297
335
  time_in_force=time_in_force,
298
336
  limit_price=limit_price, stop_price=stop_price,
299
337
  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,
300
341
  )
301
342
 
302
343
  audit.log_order_attempt(
@@ -128,6 +128,10 @@ def _build_stock_order(params: dict) -> dict:
128
128
  _add_optional_str(order, "position_intent", params.get("position_intent"))
129
129
  if params.get("close_contracts") is not None:
130
130
  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"))
131
135
 
132
136
  return order
133
137
 
@@ -447,6 +451,10 @@ def register_stock_order_tools(
447
451
  "margin_type must be ONE_DAY or INDEFINITE and is margin-account-only; "
448
452
  "position_intent must be BUY_TO_OPEN, BUY_TO_CLOSE, SELL_TO_OPEN, or SELL_TO_CLOSE and is margin-account-only; "
449
453
  "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"
450
458
  "Returns: {client_order_id, order_id}"
451
459
  ),
452
460
  )
@@ -472,6 +480,9 @@ def register_stock_order_tools(
472
480
  margin_type: Optional[MarginType] = None,
473
481
  position_intent: Optional[PositionIntent] = None,
474
482
  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,
475
486
  ) -> str:
476
487
  """Place a single stock order (non-combo).
477
488
 
@@ -531,6 +542,27 @@ def register_stock_order_tools(
531
542
  except ValidationError as e:
532
543
  return f"Validation error: {e.message}"
533
544
 
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
+
534
566
  coid = client_order_id or _generate_client_order_id()
535
567
 
536
568
  order = _build_stock_order({
@@ -546,6 +578,9 @@ def register_stock_order_tools(
546
578
  "margin_type": margin_type,
547
579
  "position_intent": position_intent,
548
580
  "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,
549
584
  })
550
585
 
551
586
  audit.log_order_attempt(