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.
- webull_openapi_mcp/__init__.py +3 -0
- webull_openapi_mcp/__main__.py +6 -0
- webull_openapi_mcp/audit.py +161 -0
- webull_openapi_mcp/cli.py +451 -0
- webull_openapi_mcp/config.py +164 -0
- webull_openapi_mcp/constants.py +94 -0
- webull_openapi_mcp/errors.py +160 -0
- webull_openapi_mcp/formatters.py +1059 -0
- webull_openapi_mcp/guards.py +539 -0
- webull_openapi_mcp/region_config.py +124 -0
- webull_openapi_mcp/sdk_client.py +251 -0
- webull_openapi_mcp/server.py +146 -0
- webull_openapi_mcp/tools/__init__.py +41 -0
- webull_openapi_mcp/tools/market_data/__init__.py +20 -0
- webull_openapi_mcp/tools/market_data/crypto.py +83 -0
- webull_openapi_mcp/tools/market_data/event.py +139 -0
- webull_openapi_mcp/tools/market_data/futures.py +172 -0
- webull_openapi_mcp/tools/market_data/stock.py +209 -0
- webull_openapi_mcp/tools/trading/__init__.py +56 -0
- webull_openapi_mcp/tools/trading/account.py +129 -0
- webull_openapi_mcp/tools/trading/assets.py +65 -0
- webull_openapi_mcp/tools/trading/crypto_order.py +171 -0
- webull_openapi_mcp/tools/trading/event_order.py +177 -0
- webull_openapi_mcp/tools/trading/futures_order.py +198 -0
- webull_openapi_mcp/tools/trading/instrument.py +282 -0
- webull_openapi_mcp/tools/trading/option_order.py +467 -0
- webull_openapi_mcp/tools/trading/order.py +158 -0
- webull_openapi_mcp/tools/trading/stock_order.py +591 -0
- webull_openapi_mcp-0.1.0.dist-info/METADATA +356 -0
- webull_openapi_mcp-0.1.0.dist-info/RECORD +33 -0
- webull_openapi_mcp-0.1.0.dist-info/WHEEL +4 -0
- webull_openapi_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- 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}"
|