wealthsim 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ # Credentials — NEVER commit. Holds live Wealthsimple access/refresh tokens.
2
+ .env
3
+ .env.*
4
+ *.token
5
+ tokens.json
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *.egg-info/
11
+ build/
12
+ dist/
13
+ .venv/
14
+ venv/
15
+
16
+ # Tooling
17
+ .mypy_cache/
18
+ .ruff_cache/
19
+ .pytest_cache/
20
+ .vscode/
21
+ .idea/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.5
2
+ Name: wealthsim
3
+ Version: 0.1.0
4
+ Summary: Unofficial Python client for Wealthsimple: quotes, accounts, positions, activity.
5
+ Project-URL: Homepage, https://github.com/eugland/wealthsim
6
+ Project-URL: Source, https://github.com/eugland/wealthsim
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: curl-cffi>=0.7
11
+ Provides-Extra: browser
12
+ Requires-Dist: playwright>=1.40; extra == 'browser'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # wealthsim
16
+
17
+ **Unofficial Python client for Wealthsimple** — quotes, accounts, positions, activity. Read-only.
18
+
19
+ > Not affiliated with or endorsed by Wealthsimple. Uses the private GraphQL API behind the web app. Automated access may violate Wealthsimple's terms — use at your own risk. No order placement, by design.
20
+
21
+ ```python
22
+ from wealthsim import login_via_browser, load_cached
23
+
24
+ ws = login_via_browser() # opens Chrome; you complete the passkey / 2FA
25
+ # next runs: ws = load_cached() # reuse the cached token, no re-login
26
+
27
+ ws.quote("AAPL") # {'symbol': 'AAPL', 'price': '319.9', 'bid': ..., ...}
28
+ ws.accounts() # every account + balance
29
+ ws.positions() # holdings: qty, market value, unrealized P&L
30
+ ws.activities(10) # recent feed items
31
+ ws.security("AAPL") # fundamentals: P/E, market cap, yield, 52wk range
32
+ ws.historical_quotes("AAPL", "1m")# daily price history
33
+ ws.identity_id # your identity id (decoded from the token)
34
+ ```
35
+
36
+ ```bash
37
+ pip install curl_cffi playwright
38
+ ```
39
+
40
+ ## Auth
41
+
42
+ Wealthsimple has no public API and (for passkey/2FA accounts) can't be logged into headlessly.
43
+ `login_via_browser()` opens your real Chrome, **you** complete the passkey, and it captures the
44
+ access token from the first post-login request — then caches it to `.env` for reuse.
45
+
46
+ - `curl_cffi` (Chrome impersonation) is required — WS is behind Cloudflare TLS fingerprinting.
47
+ - Access tokens expire (~1h); rerun `login_via_browser()` to refresh.
48
+ - **`.env` holds a live account token in plaintext — never commit it.**
49
+
50
+ ## API reference
51
+
52
+ All methods are read-only and return plain dicts/lists. Create a client with
53
+ `login_via_browser()` (interactive passkey) or `load_cached()` (reuse `.env`).
54
+
55
+ ### Profile & session
56
+ | Method | Returns |
57
+ |---|---|
58
+ | `me()` | name, email, identity id, ownership, token scope, token expiry |
59
+ | `identity_id` | your `identity-...` id (decoded from the JWT) |
60
+ | `token_claims` | raw decoded JWT claims (sub, scope, client_id, iat, exp) |
61
+
62
+ ### Market data
63
+ | Method | Returns |
64
+ |---|---|
65
+ | `quote(symbol)` | price, bid/ask, OHLC, close, prev close, volume, `change_pct`, market status |
66
+ | `security(symbol)` | core fundamentals (market cap, P/E, EPS, yield, 52wk range) |
67
+ | `security_info(symbol)` | full: + beta, margin rate, MER, allowed order subtypes, revenue, shares |
68
+ | `security_dividend(symbol)` | yield, frequency, ex-div / record / payable dates |
69
+ | `historical_quotes(symbol, timerange="1m")` | price series; `timerange` ∈ `1d 1w 1m 3m 1y 5y` |
70
+
71
+ ### Accounts & portfolio
72
+ | Method | Returns |
73
+ |---|---|
74
+ | `accounts()` | every account: id, type, nickname, currency, status, value |
75
+ | `positions(currency="CAD")` | holdings: symbol, quantity, book/market value, unrealized P&L |
76
+ | `net_worth(currency="CAD")` | combined value, net deposits, simple return (amount + rate) |
77
+ | `realized_returns(currency="CAD")` | total realized P&L + per-security breakdown |
78
+ | `dividends(currency="CAD")` | total dividend income + per-security breakdown |
79
+ | `portfolio_history(days=90, currency="CAD")` | daily net-worth series for charting |
80
+ | `activities(limit=10)` | recent feed items (deposits, trades, card, interest, dividends) |
81
+ | `credit_card()` | credit-card limit, balances, cards (or `None`) |
82
+
83
+ All methods raise `WSError` on failure (`UNAUTHENTICATED` → token expired, re-login).
84
+
85
+ ## CLI
86
+
87
+ ```bash
88
+ python run_env.py quote AAPL
89
+ python run_env.py accounts
90
+ python run_env.py positions
91
+ python run_env.py activities 10
92
+ python run_env.py security TSLA
93
+ python run_env.py history AAPL 3m
94
+ python automate.py # full end-to-end: login -> quote -> accounts -> activity
95
+ ```
96
+
97
+ ## Prior art
98
+
99
+ Endpoint shapes referenced from [`ws-api`](https://github.com/gboudreau/ws-api-python) (Guillaume Boudreau). This is a clean, focused reimplementation of the read-only path.
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,89 @@
1
+ # wealthsim
2
+
3
+ **Unofficial Python client for Wealthsimple** — quotes, accounts, positions, activity. Read-only.
4
+
5
+ > Not affiliated with or endorsed by Wealthsimple. Uses the private GraphQL API behind the web app. Automated access may violate Wealthsimple's terms — use at your own risk. No order placement, by design.
6
+
7
+ ```python
8
+ from wealthsim import login_via_browser, load_cached
9
+
10
+ ws = login_via_browser() # opens Chrome; you complete the passkey / 2FA
11
+ # next runs: ws = load_cached() # reuse the cached token, no re-login
12
+
13
+ ws.quote("AAPL") # {'symbol': 'AAPL', 'price': '319.9', 'bid': ..., ...}
14
+ ws.accounts() # every account + balance
15
+ ws.positions() # holdings: qty, market value, unrealized P&L
16
+ ws.activities(10) # recent feed items
17
+ ws.security("AAPL") # fundamentals: P/E, market cap, yield, 52wk range
18
+ ws.historical_quotes("AAPL", "1m")# daily price history
19
+ ws.identity_id # your identity id (decoded from the token)
20
+ ```
21
+
22
+ ```bash
23
+ pip install curl_cffi playwright
24
+ ```
25
+
26
+ ## Auth
27
+
28
+ Wealthsimple has no public API and (for passkey/2FA accounts) can't be logged into headlessly.
29
+ `login_via_browser()` opens your real Chrome, **you** complete the passkey, and it captures the
30
+ access token from the first post-login request — then caches it to `.env` for reuse.
31
+
32
+ - `curl_cffi` (Chrome impersonation) is required — WS is behind Cloudflare TLS fingerprinting.
33
+ - Access tokens expire (~1h); rerun `login_via_browser()` to refresh.
34
+ - **`.env` holds a live account token in plaintext — never commit it.**
35
+
36
+ ## API reference
37
+
38
+ All methods are read-only and return plain dicts/lists. Create a client with
39
+ `login_via_browser()` (interactive passkey) or `load_cached()` (reuse `.env`).
40
+
41
+ ### Profile & session
42
+ | Method | Returns |
43
+ |---|---|
44
+ | `me()` | name, email, identity id, ownership, token scope, token expiry |
45
+ | `identity_id` | your `identity-...` id (decoded from the JWT) |
46
+ | `token_claims` | raw decoded JWT claims (sub, scope, client_id, iat, exp) |
47
+
48
+ ### Market data
49
+ | Method | Returns |
50
+ |---|---|
51
+ | `quote(symbol)` | price, bid/ask, OHLC, close, prev close, volume, `change_pct`, market status |
52
+ | `security(symbol)` | core fundamentals (market cap, P/E, EPS, yield, 52wk range) |
53
+ | `security_info(symbol)` | full: + beta, margin rate, MER, allowed order subtypes, revenue, shares |
54
+ | `security_dividend(symbol)` | yield, frequency, ex-div / record / payable dates |
55
+ | `historical_quotes(symbol, timerange="1m")` | price series; `timerange` ∈ `1d 1w 1m 3m 1y 5y` |
56
+
57
+ ### Accounts & portfolio
58
+ | Method | Returns |
59
+ |---|---|
60
+ | `accounts()` | every account: id, type, nickname, currency, status, value |
61
+ | `positions(currency="CAD")` | holdings: symbol, quantity, book/market value, unrealized P&L |
62
+ | `net_worth(currency="CAD")` | combined value, net deposits, simple return (amount + rate) |
63
+ | `realized_returns(currency="CAD")` | total realized P&L + per-security breakdown |
64
+ | `dividends(currency="CAD")` | total dividend income + per-security breakdown |
65
+ | `portfolio_history(days=90, currency="CAD")` | daily net-worth series for charting |
66
+ | `activities(limit=10)` | recent feed items (deposits, trades, card, interest, dividends) |
67
+ | `credit_card()` | credit-card limit, balances, cards (or `None`) |
68
+
69
+ All methods raise `WSError` on failure (`UNAUTHENTICATED` → token expired, re-login).
70
+
71
+ ## CLI
72
+
73
+ ```bash
74
+ python run_env.py quote AAPL
75
+ python run_env.py accounts
76
+ python run_env.py positions
77
+ python run_env.py activities 10
78
+ python run_env.py security TSLA
79
+ python run_env.py history AAPL 3m
80
+ python automate.py # full end-to-end: login -> quote -> accounts -> activity
81
+ ```
82
+
83
+ ## Prior art
84
+
85
+ Endpoint shapes referenced from [`ws-api`](https://github.com/gboudreau/ws-api-python) (Guillaume Boudreau). This is a clean, focused reimplementation of the read-only path.
86
+
87
+ ## License
88
+
89
+ MIT
@@ -0,0 +1,106 @@
1
+ """Demo every wealthsim client method using the cached .env token (no login).
2
+
3
+ Run: python automate.py
4
+ If the token expired, refresh it once with: python browser_auth.py
5
+ """
6
+
7
+ import sys
8
+
9
+ from wealthsim import WSError, load_cached
10
+
11
+ sys.stdout.reconfigure(encoding="utf-8") # Windows console: allow emoji nicknames
12
+
13
+
14
+ def main() -> None:
15
+ try:
16
+ ws = load_cached(".env")
17
+ except (FileNotFoundError, ValueError, KeyError):
18
+ print("No cached token in .env. Run: python browser_auth.py")
19
+ sys.exit(1)
20
+
21
+ print("=== me() ===")
22
+ for k, v in ws.me().items():
23
+ print(f" {k:<16} {v}")
24
+
25
+ print("\n=== quote(sym) ===")
26
+ for sym in ("AAPL", "TSLA", "VFV"):
27
+ q = ws.quote(sym)
28
+ print(f" {q['symbol']:<6} {q['price']} {q['currency']} "
29
+ f"bid {q['bid']}/ask {q['ask']} [{q['market_status']}]")
30
+
31
+ print("\n=== security('AAPL') fundamentals ===")
32
+ s = ws.security("AAPL")
33
+ for k in ("marketCap", "peRatio", "eps", "yield", "high52Week", "low52Week"):
34
+ print(f" {k:<12} {s.get(k)}")
35
+
36
+ print("\n=== historical_quotes('AAPL', '1m') ===")
37
+ hq = ws.historical_quotes("AAPL", "1m")
38
+ print(f" {len(hq)} points; last: {hq[-1]['timestamp'][:10]} = {hq[-1]['price']} {hq[-1]['currency']}")
39
+
40
+ print("\n=== accounts() (open only) ===")
41
+ for a in ws.accounts():
42
+ if a["status"] != "open":
43
+ continue
44
+ nick = a.get("nickname") or "-"
45
+ print(f" {a['type']:<40} {nick:<14} {a['value'] or '0':>18} {a['currency']}")
46
+
47
+ print("\n=== positions() ===")
48
+ for p in ws.positions():
49
+ print(f" {p['symbol'] or '?':<6} qty {str(p['quantity']):<12} "
50
+ f"mkt {p['market_value'] or '0':>16} {p['currency'] or ''} pnl {p['unrealized_pnl'] or '0'}")
51
+
52
+ print("\n=== activities(5) ===")
53
+ for act in ws.activities(5):
54
+ print(f" {act.get('occurredAt', '')[:10]} {act.get('type'):<16} "
55
+ f"{act.get('subType') or '':<14} "
56
+ f"{act.get('amountSign') or ''}{act.get('amount') or ''} {act.get('currency') or ''} "
57
+ f"{act.get('assetSymbol') or ''}")
58
+
59
+ print("\n=== net_worth() ===")
60
+ n = ws.net_worth()
61
+ print(f" value {n['net_value']} {n['currency']} deposits {n['net_deposits']} "
62
+ f"return {n['return_amount']} ({n['return_rate']})")
63
+
64
+ print("\n=== realized_returns() top 5 ===")
65
+ r = ws.realized_returns(limit=5)
66
+ print(f" total {r['total']} {r['currency']}")
67
+ for b in r["by_security"]:
68
+ print(f" {b['symbol'] or '?':<10} {b['amount']}")
69
+
70
+ print("\n=== dividends() top 5 ===")
71
+ d = ws.dividends()
72
+ print(f" total {d['total']} {d['currency']}")
73
+ for b in d["by_security"][:5]:
74
+ print(f" {b['symbol'] or '?':<10} {b['amount']}")
75
+
76
+ print("\n=== security_info('AAPL') ===")
77
+ si = ws.security_info("AAPL")
78
+ for k in ("beta", "margin_rate", "mer", "dividend_frequency", "allowed_order_subtypes"):
79
+ print(f" {k:<22} {si.get(k)}")
80
+
81
+ print("\n=== security_dividend('AAPL') ===")
82
+ print(" ", ws.security_dividend("AAPL"))
83
+
84
+ print("\n=== portfolio_history(30 days) ===")
85
+ h = ws.portfolio_history(days=30)
86
+ print(f" {len(h)} days; {h[0]['date']}={h[0]['value'][:8]} -> {h[-1]['date']}={h[-1]['value'][:8]}")
87
+
88
+ print("\n=== credit_card() ===")
89
+ cc = ws.credit_card()
90
+ if cc:
91
+ print(f" limit {cc['creditLimit']} balance {cc['balance']['current']} "
92
+ f"available {cc['balance']['availableCreditLimit']}")
93
+ else:
94
+ print(" (no credit card account)")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ try:
99
+ main()
100
+ except WSError as e:
101
+ msg = str(e)
102
+ if "UNAUTHENTICATED" in msg or "Not Authorized" in msg:
103
+ print("\nToken expired — refresh with: python browser_auth.py")
104
+ else:
105
+ print("\nERROR:", msg)
106
+ sys.exit(1)
@@ -0,0 +1,85 @@
1
+ """Browser-assisted login for passkey accounts.
2
+
3
+ Opens your real Chrome to the Wealthsimple login page. YOU log in with your passkey
4
+ (Windows Hello / phone). The script watches the network, captures the access token
5
+ (and refresh token if seen), saves them to .env, then quotes a symbol.
6
+
7
+ pip install playwright
8
+ python -m playwright install chromium # or rely on channel="chrome" below
9
+ python browser_auth.py AAPL
10
+
11
+ Nothing is typed by the script — it only reads the tokens the browser already obtained.
12
+ """
13
+
14
+ import json
15
+ import sys
16
+
17
+ from playwright.sync_api import sync_playwright
18
+
19
+ from wealthsim import Session
20
+ from curl_cffi import requests as cffi_requests
21
+
22
+ symbol = sys.argv[1] if len(sys.argv) > 1 else "AAPL"
23
+ captured: dict[str, str] = {}
24
+
25
+
26
+ def _is_user_graphql(req) -> bool:
27
+ # Only /graphql carries the real user token, and only after login succeeds.
28
+ # (WS sends an anonymous Bearer on pre-login bootstrap calls — ignore those.)
29
+ return "/graphql" in req.url and req.headers.get("authorization", "").lower().startswith(
30
+ "bearer "
31
+ )
32
+
33
+
34
+ def on_request(req):
35
+ if _is_user_graphql(req) and "access_token" not in captured:
36
+ captured["access_token"] = req.headers["authorization"][7:]
37
+ dev = req.headers.get("x-ws-device-id")
38
+ if dev:
39
+ captured["device_id"] = dev
40
+ print("captured user access token from a graphql request.")
41
+
42
+
43
+ def on_response(resp):
44
+ if resp.url.endswith("/token") and resp.request.method == "POST":
45
+ try:
46
+ body = resp.json()
47
+ except Exception:
48
+ return
49
+ if "refresh_token" in body:
50
+ captured["refresh_token"] = body["refresh_token"]
51
+ captured.setdefault("access_token", body.get("access_token", ""))
52
+ print("captured refresh token from token response.")
53
+
54
+
55
+ with sync_playwright() as p:
56
+ # channel="chrome" uses your installed Chrome so the OS passkey prompt works.
57
+ browser = p.chromium.launch(channel="chrome", headless=False)
58
+ page = browser.new_page()
59
+ page.on("request", on_request)
60
+ page.on("response", on_response)
61
+ page.goto("https://my.wealthsimple.com/app/login")
62
+
63
+ print("\n>>> Log in with your passkey in the browser window.")
64
+ print(">>> Waiting for a token... (up to 3 min)\n")
65
+ try:
66
+ page.wait_for_event("request", predicate=_is_user_graphql, timeout=180_000)
67
+ except Exception:
68
+ pass
69
+ browser.close()
70
+
71
+ if "access_token" not in captured:
72
+ print("No token captured. Did login complete?")
73
+ sys.exit(1)
74
+
75
+ # Persist whatever we got (access + refresh if present) for reuse.
76
+ with open(".env", "w") as f:
77
+ json.dump(captured, f, indent=2)
78
+ print("saved tokens to .env")
79
+
80
+ ws = Session(
81
+ cffi_requests.Session(),
82
+ access_token=captured["access_token"],
83
+ device_id=captured.get("device_id", ""),
84
+ )
85
+ print(ws.quote(symbol))
@@ -0,0 +1,25 @@
1
+ """Run the quote path against a real account.
2
+
3
+ Credentials come from env vars / interactive prompt so they never get hard-coded.
4
+ Run it yourself: python example.py (or in Claude Code: ! python example.py)
5
+
6
+ set WS_EMAIL=you@example.com
7
+ set WS_PASSWORD=...
8
+ python example.py
9
+ """
10
+
11
+ import getpass
12
+ import os
13
+
14
+ from wealthsim import OTPRequired, login
15
+
16
+ email = os.environ.get("WS_EMAIL") or input("Wealthsimple email: ")
17
+ password = os.environ.get("WS_PASSWORD") or getpass.getpass("Password: ")
18
+
19
+ try:
20
+ ws = login(email, password)
21
+ except OTPRequired:
22
+ code = input("2FA code: ")
23
+ ws = login(email, password, otp=code)
24
+
25
+ print(ws.quote("AAPL"))
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "wealthsim"
7
+ version = "0.1.0"
8
+ description = "Unofficial Python client for Wealthsimple: quotes, accounts, positions, activity."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = ["curl_cffi>=0.7"]
13
+
14
+ [project.optional-dependencies]
15
+ browser = ["playwright>=1.40"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/eugland/wealthsim"
19
+ Source = "https://github.com/eugland/wealthsim"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["wealthsim"]
@@ -0,0 +1,33 @@
1
+ """Quote a symbol using a browser-grabbed REFRESH token (mints fresh access tokens).
2
+
3
+ Values read from prompt/env so they never get hard-coded or pasted anywhere shared:
4
+
5
+ python quote_refresh.py AAPL
6
+
7
+ Refresh token: the `refresh_token` value from the OAuth response in DevTools
8
+ (Network -> the `token` request -> Response), the long JWT.
9
+ Device id: the `x-ws-device-id` request header value.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+
15
+ from wealthsim import WSError, from_refresh_token
16
+
17
+
18
+ def clean(v: str) -> str:
19
+ v = v.strip().strip('"').strip("'").strip()
20
+ if v.lower().startswith("bearer "):
21
+ v = v[7:].strip()
22
+ return v
23
+
24
+
25
+ refresh = clean(os.environ.get("WS_REFRESH") or input("Refresh token: "))
26
+ device_id = clean(os.environ.get("WS_DEVICE_ID") or input("x-ws-device-id: "))
27
+ symbol = sys.argv[1] if len(sys.argv) > 1 else "AAPL"
28
+
29
+ try:
30
+ ws = from_refresh_token(refresh, device_id=device_id)
31
+ print(ws.quote(symbol))
32
+ except WSError as e:
33
+ print("ERROR:", e)
@@ -0,0 +1,38 @@
1
+ """Quote a symbol using a browser-grabbed access token (for passkey accounts).
2
+
3
+ Token + device id are read from env vars or an interactive prompt, so they never
4
+ get hard-coded or pasted anywhere shared. Run it yourself:
5
+
6
+ python quote_token.py # prompts for both
7
+ # or set them first:
8
+ $env:WS_TOKEN="eyJhbGci..." # value AFTER "Bearer ", no "Bearer " prefix
9
+ $env:WS_DEVICE_ID="226d18b3-...." # the x-ws-device-id header value
10
+ python quote_token.py AAPL
11
+
12
+ Token format: the raw JWT from the `authorization: Bearer <THIS>` header.
13
+ Do NOT include the word "Bearer". Device id: the `x-ws-device-id` header value.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+
19
+ from curl_cffi import requests
20
+
21
+ from wealthsim import Session
22
+
23
+ token = os.environ.get("WS_TOKEN") or input("Bearer access token: ").strip()
24
+ device_id = os.environ.get("WS_DEVICE_ID") or input("x-ws-device-id: ").strip()
25
+ symbol = sys.argv[1] if len(sys.argv) > 1 else "AAPL"
26
+
27
+ # sanitize a copy/pasted header value: surrounding quotes, an "authorization:"
28
+ # label, and/or a leading "Bearer " — keep only the raw JWT.
29
+ token = token.strip().strip('"').strip("'").strip()
30
+ if ":" in token.split(".")[0]: # e.g. "authorization: Bearer eyJ..."
31
+ token = token.split(":", 1)[1].strip()
32
+ if token.lower().startswith("bearer "):
33
+ token = token[7:].strip()
34
+ token = token.strip().strip('"').strip("'").strip()
35
+ device_id = device_id.strip().strip('"').strip("'").strip()
36
+
37
+ ws = Session(requests.Session(), access_token=token, device_id=device_id)
38
+ print(ws.quote(symbol))