webull-openapi-mcp 0.1.0__py3-none-any.whl

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 (33) hide show
  1. webull_openapi_mcp/__init__.py +3 -0
  2. webull_openapi_mcp/__main__.py +6 -0
  3. webull_openapi_mcp/audit.py +161 -0
  4. webull_openapi_mcp/cli.py +451 -0
  5. webull_openapi_mcp/config.py +164 -0
  6. webull_openapi_mcp/constants.py +94 -0
  7. webull_openapi_mcp/errors.py +160 -0
  8. webull_openapi_mcp/formatters.py +1059 -0
  9. webull_openapi_mcp/guards.py +539 -0
  10. webull_openapi_mcp/region_config.py +124 -0
  11. webull_openapi_mcp/sdk_client.py +251 -0
  12. webull_openapi_mcp/server.py +146 -0
  13. webull_openapi_mcp/tools/__init__.py +41 -0
  14. webull_openapi_mcp/tools/market_data/__init__.py +20 -0
  15. webull_openapi_mcp/tools/market_data/crypto.py +83 -0
  16. webull_openapi_mcp/tools/market_data/event.py +139 -0
  17. webull_openapi_mcp/tools/market_data/futures.py +172 -0
  18. webull_openapi_mcp/tools/market_data/stock.py +209 -0
  19. webull_openapi_mcp/tools/trading/__init__.py +56 -0
  20. webull_openapi_mcp/tools/trading/account.py +129 -0
  21. webull_openapi_mcp/tools/trading/assets.py +65 -0
  22. webull_openapi_mcp/tools/trading/crypto_order.py +171 -0
  23. webull_openapi_mcp/tools/trading/event_order.py +177 -0
  24. webull_openapi_mcp/tools/trading/futures_order.py +198 -0
  25. webull_openapi_mcp/tools/trading/instrument.py +282 -0
  26. webull_openapi_mcp/tools/trading/option_order.py +467 -0
  27. webull_openapi_mcp/tools/trading/order.py +158 -0
  28. webull_openapi_mcp/tools/trading/stock_order.py +591 -0
  29. webull_openapi_mcp-0.1.0.dist-info/METADATA +356 -0
  30. webull_openapi_mcp-0.1.0.dist-info/RECORD +33 -0
  31. webull_openapi_mcp-0.1.0.dist-info/WHEEL +4 -0
  32. webull_openapi_mcp-0.1.0.dist-info/entry_points.txt +2 -0
  33. webull_openapi_mcp-0.1.0.dist-info/licenses/LICENSE +191 -0
@@ -0,0 +1,164 @@
1
+ """Configuration management for Webull MCP Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ from dataclasses import dataclass, field
8
+
9
+ from dotenv import load_dotenv
10
+
11
+ from webull_openapi_mcp.errors import ConfigError
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class ServerConfig:
18
+ """Immutable server configuration loaded from environment variables / .env file."""
19
+
20
+ app_key: str
21
+ app_secret: str
22
+ region_id: str = "us"
23
+ environment: str = "uat" # "uat" | "prod"
24
+ # Market-specific notional limits (by currency)
25
+ max_order_notional_usd: float = 10_000.0 # For US market
26
+ max_order_notional_hkd: float = 80_000.0 # For HK market
27
+ max_order_notional_cnh: float = 70_000.0 # For CN market
28
+ max_order_quantity: float = 1_000.0
29
+ symbol_whitelist: list[str] | None = None
30
+ audit_log_file: str | None = None
31
+ token_dir: str | None = None # Passed to SDK's api_client.set_token_dir(), None uses SDK's built-in priority resolution
32
+ toolsets: frozenset[str] | None = None # None = all toolsets enabled
33
+
34
+ # Legacy property for backward compatibility
35
+ @property
36
+ def max_order_notional(self) -> float:
37
+ """Return USD limit for backward compatibility."""
38
+ return self.max_order_notional_usd
39
+
40
+ def get_max_notional_for_market(self, market: str | None) -> tuple[float, str]:
41
+ """Get max notional limit and currency for a specific market.
42
+
43
+ Parameters
44
+ ----------
45
+ market
46
+ Market code: US, HK, or CN
47
+
48
+ Returns
49
+ -------
50
+ tuple[float, str]
51
+ (max_notional, currency) for the market
52
+ """
53
+ if market == "HK":
54
+ return (self.max_order_notional_hkd, "HKD")
55
+ elif market == "CN":
56
+ return (self.max_order_notional_cnh, "CNH")
57
+ else: # US or default
58
+ return (self.max_order_notional_usd, "USD")
59
+
60
+
61
+ def _parse_whitelist(raw: str | None) -> list[str] | None:
62
+ """Parse comma-separated whitelist string into a list, or None if empty."""
63
+ if not raw or not raw.strip():
64
+ return None
65
+ items = [s.strip() for s in raw.split(",") if s.strip()]
66
+ return items if items else None
67
+
68
+
69
+ def _parse_toolsets(raw: str | None) -> frozenset[str] | None:
70
+ """Parse comma-separated toolsets string into a frozenset, or None if empty."""
71
+ if not raw or not raw.strip():
72
+ return None
73
+ items = frozenset(s.strip() for s in raw.split(",") if s.strip())
74
+ return items if items else None
75
+
76
+
77
+ def _parse_float(raw: str | None, default: float) -> float:
78
+ """Parse a string to float, returning *default* on None or invalid input."""
79
+ if raw is None:
80
+ return default
81
+ try:
82
+ return float(raw)
83
+ except (ValueError, TypeError):
84
+ return default
85
+
86
+
87
+ def load_config(env_file: str | None = None) -> ServerConfig:
88
+ """Load configuration from environment variables / .env file.
89
+
90
+ Parameters
91
+ ----------
92
+ env_file:
93
+ Optional path to a ``.env`` file. When provided the file is loaded
94
+ **before** reading ``os.environ`` so that file values act as defaults
95
+ that real env-vars can override (``python-dotenv`` default behaviour).
96
+ """
97
+ if env_file is not None:
98
+ load_dotenv(env_file, override=False)
99
+ else:
100
+ # Try loading a .env in the current directory as a convenience default
101
+ load_dotenv(override=False)
102
+
103
+ app_key = os.environ.get("WEBULL_APP_KEY", "")
104
+ app_secret = os.environ.get("WEBULL_APP_SECRET", "")
105
+ region_id = os.environ.get("WEBULL_REGION_ID", "us")
106
+ environment = os.environ.get("WEBULL_ENVIRONMENT", "uat")
107
+ # Market-specific notional limits
108
+ max_order_notional_usd = _parse_float(os.environ.get("WEBULL_MAX_ORDER_NOTIONAL_USD"), 10_000.0)
109
+ max_order_notional_hkd = _parse_float(os.environ.get("WEBULL_MAX_ORDER_NOTIONAL_HKD"), 80_000.0)
110
+ max_order_notional_cnh = _parse_float(os.environ.get("WEBULL_MAX_ORDER_NOTIONAL_CNH"), 70_000.0)
111
+ max_order_quantity = _parse_float(os.environ.get("WEBULL_MAX_ORDER_QUANTITY"), 1_000.0)
112
+ symbol_whitelist = _parse_whitelist(os.environ.get("WEBULL_SYMBOL_WHITELIST"))
113
+ audit_log_file = os.environ.get("WEBULL_AUDIT_LOG_FILE") or None
114
+ token_dir = os.environ.get("WEBULL_TOKEN_DIR") or None
115
+ toolsets = _parse_toolsets(os.environ.get("WEBULL_TOOLSETS"))
116
+
117
+ return ServerConfig(
118
+ app_key=app_key,
119
+ app_secret=app_secret,
120
+ region_id=region_id,
121
+ environment=environment,
122
+ max_order_notional_usd=max_order_notional_usd,
123
+ max_order_notional_hkd=max_order_notional_hkd,
124
+ max_order_notional_cnh=max_order_notional_cnh,
125
+ max_order_quantity=max_order_quantity,
126
+ symbol_whitelist=symbol_whitelist,
127
+ audit_log_file=audit_log_file,
128
+ token_dir=token_dir,
129
+ toolsets=toolsets,
130
+ )
131
+
132
+
133
+ def validate_config(config: ServerConfig) -> None:
134
+ """Validate that required credentials are present.
135
+
136
+ Raises
137
+ ------
138
+ ConfigError
139
+ If ``app_key`` or ``app_secret`` is empty / missing.
140
+ """
141
+ if not config.app_key:
142
+ raise ConfigError(
143
+ "WEBULL_APP_KEY is required but not set. "
144
+ "Provide it via environment variable or .env file."
145
+ )
146
+ if not config.app_secret:
147
+ raise ConfigError(
148
+ "WEBULL_APP_SECRET is required but not set. "
149
+ "Provide it via environment variable or .env file."
150
+ )
151
+
152
+ # Validate region configuration
153
+ from webull_openapi_mcp.region_config import get_region_config, SUPPORTED_REGIONS
154
+ from webull_openapi_mcp.errors import UnsupportedRegionError
155
+
156
+ try:
157
+ region_config = get_region_config(config.region_id)
158
+ logger.info(
159
+ "Region configuration validated: region=%s, environment=%s",
160
+ region_config.region_id,
161
+ config.environment,
162
+ )
163
+ except UnsupportedRegionError as e:
164
+ raise ConfigError(str(e)) from e
@@ -0,0 +1,94 @@
1
+ """Enum constants used throughout the Webull MCP Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Trading sides
6
+ VALID_SIDES: frozenset[str] = frozenset({"BUY", "SELL", "SHORT"})
7
+
8
+ # Stock/crypto/futures order types
9
+ VALID_ORDER_TYPES: frozenset[str] = frozenset({
10
+ "MARKET", "LIMIT", "STOP_LOSS", "STOP_LOSS_LIMIT", "TRAILING_STOP_LOSS",
11
+ })
12
+
13
+ # Option order types (subset of stock order types)
14
+ VALID_OPTION_ORDER_TYPES: frozenset[str] = frozenset({
15
+ "MARKET", "LIMIT", "STOP_LOSS", "STOP_LOSS_LIMIT",
16
+ })
17
+
18
+ # Time-in-force for option orders (subset)
19
+ VALID_OPTION_TIF: frozenset[str] = frozenset({"DAY", "GTC"})
20
+
21
+ # Entrust (order quantity) types
22
+ VALID_ENTRUST_TYPES: frozenset[str] = frozenset({"QTY", "AMOUNT"})
23
+
24
+ # Trading session types
25
+ VALID_TRADING_SESSIONS: frozenset[str] = frozenset({"ALL", "CORE", "NIGHT"})
26
+
27
+ # Option types
28
+ VALID_OPTION_TYPES: frozenset[str] = frozenset({"CALL", "PUT"})
29
+
30
+ # Stock categories
31
+ VALID_STOCK_CATEGORIES: frozenset[str] = frozenset({"US_STOCK", "US_ETF"})
32
+
33
+ # K-line timeframes for stocks
34
+ VALID_TIMEFRAMES: frozenset[str] = frozenset({
35
+ "1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w",
36
+ })
37
+
38
+ # K-line timeframes for crypto
39
+ VALID_CRYPTO_TIMEFRAMES: frozenset[str] = frozenset({
40
+ "1m", "5m", "1h", "4h", "1d",
41
+ })
42
+
43
+ # Option strategies
44
+ VALID_OPTION_STRATEGIES: frozenset[str] = frozenset({
45
+ "SINGLE", "COVERED_STOCK", "STRADDLE", "STRANGLE", "VERTICAL",
46
+ "CALENDAR", "BUTTERFLY", "CONDOR", "COLLAR_WITH_STOCK",
47
+ "IRON_BUTTERFLY", "IRON_CONDOR", "DIAGONAL",
48
+ })
49
+
50
+ # Min/max leg count per option strategy
51
+ STRATEGY_LEG_COUNT: dict[str, tuple[int, int]] = {
52
+ "SINGLE": (1, 1),
53
+ "COVERED_STOCK": (2, 2),
54
+ "STRADDLE": (2, 2),
55
+ "STRANGLE": (2, 2),
56
+ "VERTICAL": (2, 2),
57
+ "CALENDAR": (2, 2),
58
+ "BUTTERFLY": (3, 4),
59
+ "CONDOR": (4, 4),
60
+ "COLLAR_WITH_STOCK": (3, 3),
61
+ "IRON_BUTTERFLY": (4, 4),
62
+ "IRON_CONDOR": (4, 4),
63
+ "DIAGONAL": (2, 2),
64
+ }
65
+
66
+
67
+ # =============================================================================
68
+ # Account label to asset type mapping (US region)
69
+ # =============================================================================
70
+
71
+ # Account labels that support stock and option trading
72
+ STOCK_OPTION_ACCOUNT_LABELS: frozenset[str] = frozenset({
73
+ "Individual Cash",
74
+ "Individual Margin",
75
+ "Roth IRA",
76
+ "Traditional IRA",
77
+ "Rollover IRA",
78
+ "Managed Roth IRA",
79
+ "Managed Traditional IRA",
80
+ })
81
+
82
+ # Account labels for specific asset types
83
+ FUTURES_ACCOUNT_LABEL = "Futures"
84
+ CRYPTO_ACCOUNT_LABEL = "Crypto"
85
+ EVENT_ACCOUNT_LABEL = "Events Cash"
86
+
87
+ # Mapping from order asset type to valid account labels
88
+ ASSET_TYPE_ACCOUNT_LABELS: dict[str, frozenset[str]] = {
89
+ "stock": STOCK_OPTION_ACCOUNT_LABELS,
90
+ "option": STOCK_OPTION_ACCOUNT_LABELS,
91
+ "futures": frozenset({FUTURES_ACCOUNT_LABEL}),
92
+ "crypto": frozenset({CRYPTO_ACCOUNT_LABEL}),
93
+ "event": frozenset({EVENT_ACCOUNT_LABEL}),
94
+ }
@@ -0,0 +1,160 @@
1
+ """Exception definitions and SDK error handling for Webull MCP Server."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ConfigError(Exception):
7
+ """Configuration error (missing credentials, invalid values, etc.)."""
8
+ pass
9
+
10
+
11
+ class AuthenticationError(Exception):
12
+ """Authentication-related errors (token, 2FA, etc.)."""
13
+ pass
14
+
15
+
16
+ class ValidationError(Exception):
17
+ """Order local validation failure."""
18
+
19
+ def __init__(self, message: str, field: str | None = None):
20
+ self.message = message
21
+ self.field = field
22
+ super().__init__(message)
23
+
24
+
25
+ # =============================================================================
26
+ # Region-Specific Errors
27
+ # =============================================================================
28
+
29
+ class RegionError(Exception):
30
+ """Base class for region-related errors."""
31
+ pass
32
+
33
+
34
+ class UnsupportedRegionError(RegionError):
35
+ """Raised when an unsupported region is configured."""
36
+
37
+ def __init__(self, region_id: str, supported: list[str] | None = None) -> None:
38
+ self.region_id = region_id
39
+ self.supported = supported or ["us", "hk"]
40
+ super().__init__(
41
+ f"Unsupported region '{region_id}'. "
42
+ f"Supported regions: {', '.join(sorted(self.supported))}"
43
+ )
44
+
45
+
46
+ class RegionValidationError(ValidationError):
47
+ """Raised when a parameter is invalid for the configured region."""
48
+
49
+ def __init__(
50
+ self,
51
+ param_name: str,
52
+ value: str,
53
+ region_id: str,
54
+ valid_values: set[str] | frozenset[str],
55
+ ) -> None:
56
+ self.param_name = param_name
57
+ self.value = value
58
+ self.region_id = region_id
59
+ self.valid_values = valid_values
60
+ message = (
61
+ f"Invalid {param_name} '{value}' for region '{region_id}'. "
62
+ f"Valid values: {', '.join(sorted(valid_values))}"
63
+ )
64
+ super().__init__(message, field=param_name)
65
+
66
+
67
+ class FeatureNotSupportedError(RegionError):
68
+ """Raised when a feature is not supported in the configured region."""
69
+
70
+ def __init__(self, feature: str, region_id: str) -> None:
71
+ self.feature = feature
72
+ self.region_id = region_id
73
+ super().__init__(
74
+ f"Feature '{feature}' is not supported in region '{region_id}'"
75
+ )
76
+
77
+
78
+ # Market data tools that need subscription special handling
79
+ MARKET_DATA_TOOLS = frozenset({
80
+ "get_stock_snapshot",
81
+ "get_stock_quotes",
82
+ "get_stock_bars",
83
+ "get_stock_bars_single",
84
+ "get_stock_tick",
85
+ "get_stock_footprint",
86
+ "get_futures_tick",
87
+ "get_futures_snapshot",
88
+ "get_futures_depth",
89
+ "get_futures_bars",
90
+ "get_futures_footprint",
91
+ "get_crypto_snapshot",
92
+ "get_crypto_bars",
93
+ "get_event_tick",
94
+ "get_event_snapshot",
95
+ "get_event_depth",
96
+ "get_event_bars",
97
+ })
98
+
99
+ # Region-specific subscription guidance
100
+ MARKET_DATA_SUBSCRIPTION_HINTS: dict[str, str] = {
101
+ "hk": (
102
+ "Market data requires quotes subscription.\n"
103
+ "Subscribe at: https://www.webullapp.hk/quote\n"
104
+ "Guide: https://developer.webull.hk/apis/docs/market-data-api/subscribe-quotes"
105
+ ),
106
+ "us": (
107
+ "Market data requires quotes subscription.\n"
108
+ "Subscribe at: https://www.webullapp.com/quote\n"
109
+ "Guide: https://developer.webull.com/apis/docs/market-data-api/subscribe-quotes"
110
+ ),
111
+ }
112
+
113
+
114
+ def _get_market_data_hint(region_id: str | None = None) -> str:
115
+ """Get market data subscription hint for the given region."""
116
+ if region_id and region_id.lower() in MARKET_DATA_SUBSCRIPTION_HINTS:
117
+ return MARKET_DATA_SUBSCRIPTION_HINTS[region_id.lower()]
118
+ # Fallback: show both
119
+ return (
120
+ "Market data requires quotes subscription.\n\n"
121
+ "HK region:\n"
122
+ " Subscribe: https://www.webullapp.hk/quote\n"
123
+ " Guide: https://developer.webull.hk/apis/docs/market-data-api/subscribe-quotes\n\n"
124
+ "US region:\n"
125
+ " Subscribe: https://www.webullapp.com/quote\n"
126
+ " Guide: https://developer.webull.com/apis/docs/market-data-api/subscribe-quotes"
127
+ )
128
+
129
+
130
+ def handle_sdk_exception(e: Exception, tool_name: str, region_id: str | None = None) -> str:
131
+ """Unified SDK exception handler.
132
+
133
+ Handles:
134
+ - ServerException: extract http_status + error_code + message,
135
+ with special handling for market data 401/403 and cancel_order 404/403.
136
+ - ClientException: extract parameter error description.
137
+ - Other exceptions: generic error message.
138
+ """
139
+ from webull.core.exception.exceptions import ClientException, ServerException
140
+
141
+ if isinstance(e, ServerException):
142
+ http_status = e.http_status
143
+
144
+ # Market data 401/403 → subscription hint
145
+ if http_status in (401, 403) and tool_name in MARKET_DATA_TOOLS:
146
+ return _get_market_data_hint(region_id)
147
+
148
+ # cancel_order special messages
149
+ if tool_name == "cancel_order":
150
+ if http_status == 404:
151
+ return "Order not found; it may have been filled or already cancelled"
152
+ if http_status == 403:
153
+ return "Permission denied; please check account permissions"
154
+
155
+ return f"Server error (HTTP {http_status}): [{e.error_code}] {e.error_msg}"
156
+
157
+ if isinstance(e, ClientException):
158
+ return f"Parameter error: [{e.error_code}] {e.error_msg}"
159
+
160
+ return f"Internal error: {type(e).__name__}: {e}"