comparison schwab_trader/schwab_client.py @ 220:eb8b4230fdb9

[schwab-trader] Add guarded trading experiment
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 08:52:13 -0700
parents
children
comparison
equal deleted inserted replaced
214:4c725fde6999 220:eb8b4230fdb9
1 from __future__ import annotations
2
3 import base64
4 import json
5 import os
6 import stat
7 import time
8 import urllib.error
9 import urllib.parse
10 import urllib.request
11 from dataclasses import dataclass
12 from pathlib import Path
13 from typing import Any
14
15
16 AUTH_URL = "https://api.schwabapi.com/v1/oauth/authorize"
17 TOKEN_URL = "https://api.schwabapi.com/v1/oauth/token"
18 TRADER_BASE_URL = "https://api.schwabapi.com/trader/v1"
19 DEFAULT_TOKEN_FILE = "~/.config/zenbu/schwab_tokens.json"
20
21
22 class SchwabError(RuntimeError):
23 pass
24
25
26 @dataclass(frozen=True)
27 class SchwabConfig:
28 app_key: str
29 app_secret: str
30 redirect_uri: str
31 token_file: Path
32
33 @classmethod
34 def from_env(cls) -> "SchwabConfig":
35 app_key = os.environ.get("SCHWAB_APP_KEY", "").strip()
36 app_secret = os.environ.get("SCHWAB_APP_SECRET", "").strip()
37 redirect_uri = os.environ.get("SCHWAB_REDIRECT_URI", "").strip()
38 token_file = Path(os.environ.get("SCHWAB_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser()
39
40 missing = [
41 name
42 for name, value in (
43 ("SCHWAB_APP_KEY", app_key),
44 ("SCHWAB_APP_SECRET", app_secret),
45 ("SCHWAB_REDIRECT_URI", redirect_uri),
46 )
47 if not value
48 ]
49 if missing:
50 raise SchwabError("Missing required environment variables: " + ", ".join(missing))
51
52 return cls(
53 app_key=app_key,
54 app_secret=app_secret,
55 redirect_uri=redirect_uri,
56 token_file=token_file,
57 )
58
59
60 @dataclass(frozen=True)
61 class ApiResponse:
62 status: int
63 headers: dict[str, str]
64 body: Any
65 raw_body: str
66
67
68 def build_authorization_url(app_key: str, redirect_uri: str, state: str | None = None) -> str:
69 params = {
70 "response_type": "code",
71 "client_id": app_key,
72 "redirect_uri": redirect_uri,
73 }
74 if state:
75 params["state"] = state
76 return AUTH_URL + "?" + urllib.parse.urlencode(params)
77
78
79 def extract_authorization_code(code_or_url: str) -> str:
80 value = code_or_url.strip()
81 if not value:
82 raise SchwabError("Authorization code is empty")
83
84 parsed = urllib.parse.urlparse(value)
85 if parsed.scheme and parsed.netloc:
86 query = urllib.parse.parse_qs(parsed.query)
87 codes = query.get("code")
88 if not codes or not codes[0]:
89 raise SchwabError("No code= parameter found in callback URL")
90 return codes[0]
91
92 return urllib.parse.unquote(value)
93
94
95 def exchange_code_for_tokens(config: SchwabConfig, code_or_url: str) -> dict[str, Any]:
96 code = extract_authorization_code(code_or_url)
97 return _token_request(
98 config,
99 {
100 "grant_type": "authorization_code",
101 "code": code,
102 "redirect_uri": config.redirect_uri,
103 },
104 )
105
106
107 def refresh_tokens(config: SchwabConfig, refresh_token: str | None = None) -> dict[str, Any]:
108 token_value = refresh_token
109 if token_value is None:
110 existing = load_tokens(config.token_file)
111 token_value = existing.get("refresh_token")
112 if not token_value:
113 raise SchwabError("No refresh token available")
114
115 return _token_request(
116 config,
117 {
118 "grant_type": "refresh_token",
119 "refresh_token": token_value,
120 },
121 )
122
123
124 def save_tokens(path: Path, tokens: dict[str, Any]) -> None:
125 payload = dict(tokens)
126 payload["saved_at"] = int(time.time())
127 path.parent.mkdir(parents=True, exist_ok=True)
128 path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
129 path.chmod(stat.S_IRUSR | stat.S_IWUSR)
130
131
132 def load_tokens(path: Path) -> dict[str, Any]:
133 if not path.exists():
134 raise SchwabError(f"Token file does not exist: {path}")
135 return json.loads(path.read_text(encoding="utf-8"))
136
137
138 def get_account_numbers(access_token: str) -> ApiResponse:
139 return _api_request("GET", "/accounts/accountNumbers", access_token)
140
141
142 def get_accounts(access_token: str, fields: str | None = None) -> ApiResponse:
143 query = ""
144 if fields:
145 query = "?" + urllib.parse.urlencode({"fields": fields})
146 return _api_request("GET", "/accounts" + query, access_token)
147
148
149 def get_account(access_token: str, account_hash: str, fields: str | None = None) -> ApiResponse:
150 query = ""
151 if fields:
152 query = "?" + urllib.parse.urlencode({"fields": fields})
153 return _api_request("GET", f"/accounts/{urllib.parse.quote(account_hash)}{query}", access_token)
154
155
156 def build_equity_order(
157 action: str,
158 symbol: str,
159 quantity: float,
160 order_type: str = "MARKET",
161 price: float | None = None,
162 duration: str = "DAY",
163 session: str = "NORMAL",
164 ) -> dict[str, Any]:
165 normalized_action = action.upper()
166 normalized_symbol = symbol.upper()
167 normalized_order_type = order_type.upper()
168 normalized_duration = duration.upper()
169 normalized_session = session.upper()
170
171 if normalized_action not in {"BUY", "SELL"}:
172 raise SchwabError("action must be BUY or SELL")
173 if not normalized_symbol:
174 raise SchwabError("symbol is required")
175 if quantity <= 0:
176 raise SchwabError("quantity must be greater than zero")
177 if normalized_order_type not in {"MARKET", "LIMIT"}:
178 raise SchwabError("order_type must be MARKET or LIMIT")
179 if normalized_order_type == "LIMIT" and price is None:
180 raise SchwabError("LIMIT orders require --price")
181 if normalized_order_type == "MARKET" and price is not None:
182 raise SchwabError("MARKET orders cannot include --price")
183
184 order: dict[str, Any] = {
185 "orderType": normalized_order_type,
186 "session": normalized_session,
187 "duration": normalized_duration,
188 "orderStrategyType": "SINGLE",
189 "orderLegCollection": [
190 {
191 "instruction": normalized_action,
192 "quantity": quantity,
193 "instrument": {
194 "symbol": normalized_symbol,
195 "assetType": "EQUITY",
196 },
197 }
198 ],
199 }
200 if price is not None:
201 order["price"] = f"{price:.2f}"
202 return order
203
204
205 def place_order(access_token: str, account_hash: str, order: dict[str, Any]) -> ApiResponse:
206 path = f"/accounts/{urllib.parse.quote(account_hash)}/orders"
207 return _api_request("POST", path, access_token, order)
208
209
210 def _token_request(config: SchwabConfig, form: dict[str, str]) -> dict[str, Any]:
211 credentials = f"{config.app_key}:{config.app_secret}".encode("utf-8")
212 headers = {
213 "Authorization": "Basic " + base64.b64encode(credentials).decode("ascii"),
214 "Content-Type": "application/x-www-form-urlencoded",
215 "Accept": "application/json",
216 }
217 data = urllib.parse.urlencode(form).encode("utf-8")
218 request = urllib.request.Request(TOKEN_URL, data=data, headers=headers, method="POST")
219 response = _open_request(request)
220 if not isinstance(response.body, dict):
221 raise SchwabError("Token endpoint did not return a JSON object")
222 return response.body
223
224
225 def _api_request(
226 method: str,
227 path: str,
228 access_token: str,
229 body: dict[str, Any] | None = None,
230 ) -> ApiResponse:
231 headers = {
232 "Authorization": f"Bearer {access_token}",
233 "Accept": "application/json",
234 }
235 data = None
236 if body is not None:
237 data = json.dumps(body).encode("utf-8")
238 headers["Content-Type"] = "application/json"
239
240 request = urllib.request.Request(
241 TRADER_BASE_URL + path,
242 data=data,
243 headers=headers,
244 method=method,
245 )
246 return _open_request(request)
247
248
249 def _open_request(request: urllib.request.Request) -> ApiResponse:
250 try:
251 with urllib.request.urlopen(request, timeout=30) as response:
252 raw = response.read().decode("utf-8")
253 return ApiResponse(
254 status=response.status,
255 headers=dict(response.headers.items()),
256 body=_parse_json_or_text(raw),
257 raw_body=raw,
258 )
259 except urllib.error.HTTPError as error:
260 raw = error.read().decode("utf-8", errors="replace")
261 raise SchwabError(
262 f"Schwab API request failed with HTTP {error.code}: {raw or error.reason}"
263 ) from error
264 except urllib.error.URLError as error:
265 raise SchwabError(f"Schwab API request failed: {error.reason}") from error
266
267
268 def _parse_json_or_text(raw: str) -> Any:
269 if not raw:
270 return None
271 try:
272 return json.loads(raw)
273 except json.JSONDecodeError:
274 return raw
275