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.
- wealthsim-0.1.0/.gitignore +21 -0
- wealthsim-0.1.0/LICENSE +21 -0
- wealthsim-0.1.0/PKG-INFO +103 -0
- wealthsim-0.1.0/README.md +89 -0
- wealthsim-0.1.0/automate.py +106 -0
- wealthsim-0.1.0/browser_auth.py +85 -0
- wealthsim-0.1.0/example.py +25 -0
- wealthsim-0.1.0/pyproject.toml +22 -0
- wealthsim-0.1.0/quote_refresh.py +33 -0
- wealthsim-0.1.0/quote_token.py +38 -0
- wealthsim-0.1.0/run_env.py +159 -0
- wealthsim-0.1.0/set_token.py +24 -0
- wealthsim-0.1.0/wealthsim/__init__.py +38 -0
- wealthsim-0.1.0/wealthsim/browser.py +91 -0
- wealthsim-0.1.0/wealthsim/client.py +612 -0
|
@@ -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/
|
wealthsim-0.1.0/LICENSE
ADDED
|
@@ -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.
|
wealthsim-0.1.0/PKG-INFO
ADDED
|
@@ -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))
|