wealthsim 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.
- wealthsim/__init__.py +38 -0
- wealthsim/browser.py +91 -0
- wealthsim/client.py +612 -0
- wealthsim-0.1.0.dist-info/METADATA +103 -0
- wealthsim-0.1.0.dist-info/RECORD +7 -0
- wealthsim-0.1.0.dist-info/WHEEL +4 -0
- wealthsim-0.1.0.dist-info/licenses/LICENSE +21 -0
wealthsim/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""wealthsim — tiny standalone Wealthsimple client. Login + one thing: quote a symbol.
|
|
2
|
+
|
|
3
|
+
Unofficial. Wealthsimple has no public API; this talks to their private GraphQL
|
|
4
|
+
backend. Use at your own risk (their ToS forbids automated access; worst case is an
|
|
5
|
+
account lock). Read-only scope only.
|
|
6
|
+
|
|
7
|
+
from wealthsim import login
|
|
8
|
+
ws = login("you@example.com", "password", otp="123456")
|
|
9
|
+
print(ws.quote("AAPL"))
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from .client import OTPRequired, Session, WSError, from_refresh_token, login
|
|
15
|
+
|
|
16
|
+
# Session is the client; expose a friendlier alias.
|
|
17
|
+
Client = Session
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def __getattr__(name: str): # lazy: keep playwright import optional
|
|
21
|
+
if name in ("login_via_browser", "load_cached"):
|
|
22
|
+
from . import browser
|
|
23
|
+
|
|
24
|
+
return getattr(browser, name)
|
|
25
|
+
raise AttributeError(name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Client",
|
|
30
|
+
"Session",
|
|
31
|
+
"login",
|
|
32
|
+
"from_refresh_token",
|
|
33
|
+
"login_via_browser",
|
|
34
|
+
"load_cached",
|
|
35
|
+
"WSError",
|
|
36
|
+
"OTPRequired",
|
|
37
|
+
]
|
|
38
|
+
__version__ = "0.1.0"
|
wealthsim/browser.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Browser-assisted login for passkey/2FA accounts.
|
|
2
|
+
|
|
3
|
+
Opens your real Chrome to the Wealthsimple login page; you complete the passkey
|
|
4
|
+
(Windows Hello / phone). We capture the token the browser earns from the first
|
|
5
|
+
post-login GraphQL request, optionally cache it, and return an authed Client.
|
|
6
|
+
|
|
7
|
+
Requires: pip install playwright (uses your installed Chrome via channel="chrome").
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from curl_cffi import requests as _cffi
|
|
16
|
+
|
|
17
|
+
from .client import GRAPHQL_URL, Session, WSError
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def login_via_browser(
|
|
21
|
+
cache_path: Optional[str] = ".env",
|
|
22
|
+
timeout_sec: int = 180,
|
|
23
|
+
) -> Session:
|
|
24
|
+
"""Interactive passkey login. Returns an authed :class:`Session` (a.k.a. Client).
|
|
25
|
+
|
|
26
|
+
If ``cache_path`` is set, the captured tokens are written there as JSON for reuse.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
from playwright.sync_api import sync_playwright
|
|
30
|
+
except ImportError as exc: # pragma: no cover
|
|
31
|
+
raise WSError("playwright not installed. Run: pip install playwright") from exc
|
|
32
|
+
|
|
33
|
+
captured: dict[str, str] = {}
|
|
34
|
+
|
|
35
|
+
def is_user_graphql(req) -> bool:
|
|
36
|
+
return GRAPHQL_URL in req.url and req.headers.get(
|
|
37
|
+
"authorization", ""
|
|
38
|
+
).lower().startswith("bearer ")
|
|
39
|
+
|
|
40
|
+
def on_request(req) -> None:
|
|
41
|
+
if is_user_graphql(req) and "access_token" not in captured:
|
|
42
|
+
captured["access_token"] = req.headers["authorization"][7:]
|
|
43
|
+
dev = req.headers.get("x-ws-device-id")
|
|
44
|
+
if dev:
|
|
45
|
+
captured["device_id"] = dev
|
|
46
|
+
|
|
47
|
+
def on_response(resp) -> None:
|
|
48
|
+
if resp.url.endswith("/token") and resp.request.method == "POST":
|
|
49
|
+
try:
|
|
50
|
+
body = resp.json()
|
|
51
|
+
except Exception:
|
|
52
|
+
return
|
|
53
|
+
if "refresh_token" in body:
|
|
54
|
+
captured["refresh_token"] = body["refresh_token"]
|
|
55
|
+
|
|
56
|
+
with sync_playwright() as p:
|
|
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
|
+
print(">>> Log in with your passkey in the browser window...")
|
|
63
|
+
try:
|
|
64
|
+
page.wait_for_event("request", predicate=is_user_graphql, timeout=timeout_sec * 1000)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
browser.close()
|
|
68
|
+
|
|
69
|
+
if "access_token" not in captured:
|
|
70
|
+
raise WSError("No token captured — did login complete in the browser?")
|
|
71
|
+
|
|
72
|
+
if cache_path:
|
|
73
|
+
with open(cache_path, "w") as f:
|
|
74
|
+
json.dump(captured, f, indent=2)
|
|
75
|
+
|
|
76
|
+
return Session(
|
|
77
|
+
_cffi.Session(),
|
|
78
|
+
access_token=captured["access_token"],
|
|
79
|
+
device_id=captured.get("device_id", ""),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_cached(cache_path: str = ".env") -> Session:
|
|
84
|
+
"""Build a Client from previously cached tokens. Raises if the file is missing."""
|
|
85
|
+
with open(cache_path) as f:
|
|
86
|
+
tok = json.load(f)
|
|
87
|
+
return Session(
|
|
88
|
+
_cffi.Session(),
|
|
89
|
+
access_token=tok["access_token"],
|
|
90
|
+
device_id=tok.get("device_id", ""),
|
|
91
|
+
)
|
wealthsim/client.py
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"""Minimal Wealthsimple GraphQL client: bootstrap -> login (+TOTP) -> quote(symbol).
|
|
2
|
+
|
|
3
|
+
Endpoints and query shapes are the public/undocumented ones used by the web app,
|
|
4
|
+
same as the community `ws-api` library. This is a clean, from-scratch, single-file
|
|
5
|
+
reimplementation of only the login + quote path.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from curl_cffi import requests # required: WS is behind Cloudflare TLS fingerprinting
|
|
18
|
+
|
|
19
|
+
OAUTH_TOKEN_URL = "https://api.production.wealthsimple.com/v1/oauth/v2/token"
|
|
20
|
+
GRAPHQL_URL = "https://my.wealthsimple.com/graphql"
|
|
21
|
+
LOGIN_PAGE_URL = "https://my.wealthsimple.com/app/login"
|
|
22
|
+
GRAPHQL_VERSION = "12"
|
|
23
|
+
SCOPE_READ_ONLY = "invest.read trade.read tax.read"
|
|
24
|
+
IMPERSONATE = "chrome" # curl_cffi browser fingerprint to pass Cloudflare
|
|
25
|
+
|
|
26
|
+
_SEARCH_QUERY = (
|
|
27
|
+
"query FetchSecuritySearchResult($query: String!) {"
|
|
28
|
+
" securitySearch(input: {query: $query}) {"
|
|
29
|
+
" results { id buyable status stock { symbol name primaryExchange }"
|
|
30
|
+
" quoteV2 { currency price"
|
|
31
|
+
" ... on EquityQuote { marketStatus last bid ask open high low close"
|
|
32
|
+
" mid volume: vol referenceClose } } } } }"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_ACCOUNTS_QUERY = (
|
|
36
|
+
"query FetchAccounts($identityId: ID!, $pageSize: Int = 25, $cursor: String) {"
|
|
37
|
+
" identity(id: $identityId) {"
|
|
38
|
+
" id accounts(filter: {}, first: $pageSize, after: $cursor) {"
|
|
39
|
+
" edges { node {"
|
|
40
|
+
" id nickname unifiedAccountType currency status"
|
|
41
|
+
" financials { currentCombined {"
|
|
42
|
+
" netLiquidationValueV2 { amount currency } } } } } } } }"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
_ACTIVITIES_QUERY = (
|
|
46
|
+
"query FetchActivityFeedItems($first: Int, $accountScope: AccountScope = OWN) {"
|
|
47
|
+
" activityFeedItems(first: $first, accountScope: $accountScope) {"
|
|
48
|
+
" edges { node {"
|
|
49
|
+
" occurredAt type subType amount amountSign currency"
|
|
50
|
+
" assetSymbol assetQuantity status } } } }"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
_POSITIONS_QUERY = (
|
|
54
|
+
"query FetchIdentityPositions($identityId: ID!, $currency: Currency!, $first: Int) {"
|
|
55
|
+
" identity(id: $identityId) {"
|
|
56
|
+
" id financials(filter: {}) {"
|
|
57
|
+
" current(currency: $currency) {"
|
|
58
|
+
" positions(first: $first, aggregated: true) {"
|
|
59
|
+
" edges { node {"
|
|
60
|
+
" quantity positionDirection percentageOfAccount"
|
|
61
|
+
" bookValue { amount currency }"
|
|
62
|
+
" totalValue(currencyOverride: null) { amount currency }"
|
|
63
|
+
" unrealizedReturns(since: null) { amount currency }"
|
|
64
|
+
" security { id stock { symbol name primaryExchange } } } } } } } } }"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
_SECURITY_QUERY = (
|
|
68
|
+
"query FetchSecurityMarketData($id: ID!) {"
|
|
69
|
+
" security(id: $id) {"
|
|
70
|
+
" id stock { symbol name primaryExchange }"
|
|
71
|
+
" fundamentals {"
|
|
72
|
+
" marketCap peRatio eps yield high52Week low52Week"
|
|
73
|
+
" avgVolume dailyVolume sharesOutstanding currency } } }"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
_HIST_QUERY = (
|
|
77
|
+
"query FetchChartBarQuotes($id: ID!, $period: ChartPeriod) {"
|
|
78
|
+
" security(id: $id) {"
|
|
79
|
+
" id chartBarQuotes(period: $period) {"
|
|
80
|
+
" price sessionPrice timestamp currency } } }"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Friendly range -> WS ChartPeriod enum.
|
|
84
|
+
_CHART_PERIODS = {
|
|
85
|
+
"1d": "ONE_DAY",
|
|
86
|
+
"1w": "ONE_WEEK",
|
|
87
|
+
"1m": "ONE_MONTH",
|
|
88
|
+
"3m": "THREE_MONTHS",
|
|
89
|
+
"1y": "ONE_YEAR",
|
|
90
|
+
"5y": "FIVE_YEARS",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
_NETWORTH_QUERY = (
|
|
94
|
+
"query FetchIdentityCurrentFinancials($identityId: ID!, $currency: Currency!) {"
|
|
95
|
+
" identity(id: $identityId) {"
|
|
96
|
+
" id financials(filter: {}) {"
|
|
97
|
+
" current(currency: $currency) {"
|
|
98
|
+
" netLiquidationValueV2 { amount currency }"
|
|
99
|
+
" netDeposits: netDepositsV2 { amount currency }"
|
|
100
|
+
" simpleReturns(referenceDate: null) { amount { amount } rate } } } } }"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_REALIZED_QUERY = (
|
|
104
|
+
"query FetchIdentityRealizedReturns($identityId: ID!, $currency: Currency!, $first: Int) {"
|
|
105
|
+
" identity(id: $identityId) {"
|
|
106
|
+
" id financials(filter: {}) {"
|
|
107
|
+
" realizedReturns(currency: $currency) {"
|
|
108
|
+
" totalValue { amount currency }"
|
|
109
|
+
" securityBreakdown(first: $first) { edges { node {"
|
|
110
|
+
" security { stock { symbol name } }"
|
|
111
|
+
" totalValue { amount currency } } } } } } } }"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
_DIVIDENDS_QUERY = (
|
|
115
|
+
"query FetchDividendsV2($identityId: ID!, $currency: Currency!) {"
|
|
116
|
+
" identity(id: $identityId) {"
|
|
117
|
+
" id financials(filter: {}) {"
|
|
118
|
+
" dividendsV2(currency: $currency) {"
|
|
119
|
+
" totalValue { amount currency }"
|
|
120
|
+
" issuingSecurityBreakdown {"
|
|
121
|
+
" security { stock { symbol name } }"
|
|
122
|
+
" totalValue { amount currency } } } } } }"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
_SEC_DIVIDEND_QUERY = (
|
|
126
|
+
"query FetchSecurityDividendDetails($securityId: ID!) {"
|
|
127
|
+
" security(id: $securityId) {"
|
|
128
|
+
" id currency fundamentals { yield }"
|
|
129
|
+
" events { exDividendDate payableDate recordDate }"
|
|
130
|
+
" stock { dividendFrequency } } }"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
_PORTFOLIO_HISTORY_QUERY = (
|
|
134
|
+
"query FetchIdentityHistoricalFinancials("
|
|
135
|
+
"$identityId: ID!, $currency: Currency!, $startDate: Date, $first: Int) {"
|
|
136
|
+
" identity(id: $identityId) {"
|
|
137
|
+
" id financials(filter: {}) {"
|
|
138
|
+
" historicalDaily(currency: $currency, startDate: $startDate, first: $first) {"
|
|
139
|
+
" edges { node { date netLiquidationValueV2 { amount currency } } } } } } }"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
_SEC_INFO_QUERY = (
|
|
143
|
+
"query FetchSecurityMarketData($id: ID!) {"
|
|
144
|
+
" security(id: $id) {"
|
|
145
|
+
" id allowedOrderSubtypes managementExpenseRatio"
|
|
146
|
+
" marginRates { clientMarginRate }"
|
|
147
|
+
" fundamentals {"
|
|
148
|
+
" marketCap peRatio eps yield high52Week low52Week beta"
|
|
149
|
+
" avgVolume dailyVolume sharesOutstanding companyRevenue"
|
|
150
|
+
" description currency }"
|
|
151
|
+
" stock { symbol name primaryExchange dividendFrequency } } }"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
_PROFILE_QUERY = (
|
|
155
|
+
"query FetchProfile($id: ID!) {"
|
|
156
|
+
" identity(id: $id) {"
|
|
157
|
+
" id accounts(first: 1) { edges { node { accountOwners {"
|
|
158
|
+
" name email identityId ownershipType } } } } } }"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
_CREDIT_CARD_QUERY = (
|
|
162
|
+
"query FetchCreditCardAccount($id: ID!) {"
|
|
163
|
+
" creditCardAccount(id: $id) {"
|
|
164
|
+
" id creditLimit"
|
|
165
|
+
" balance { current outstanding availableCreditLimit pending }"
|
|
166
|
+
" currentCards { cardNumber cardStatus nameOnCard isLocked } } }"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class WSError(Exception):
|
|
171
|
+
"""Any failure talking to Wealthsimple (bootstrap, login, OTP, GraphQL)."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class OTPRequired(WSError):
|
|
175
|
+
"""2FA code required — call login() again with otp=."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Session:
|
|
179
|
+
"""An authenticated Wealthsimple session. Create via :func:`login`."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, http: requests.Session, access_token: str, device_id: str) -> None:
|
|
182
|
+
self._http = http
|
|
183
|
+
self._access_token = access_token
|
|
184
|
+
self._device_id = device_id
|
|
185
|
+
self._session_id = str(uuid.uuid4())
|
|
186
|
+
self._identity_id: Optional[str] = None
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def identity_id(self) -> str:
|
|
190
|
+
"""The `identity-...` id, decoded from the access token's JWT claims."""
|
|
191
|
+
if self._identity_id is None:
|
|
192
|
+
try:
|
|
193
|
+
payload_b64 = self._access_token.split(".")[1]
|
|
194
|
+
payload_b64 += "=" * (-len(payload_b64) % 4)
|
|
195
|
+
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
raise WSError(f"Couldn't decode identity from token: {exc}") from exc
|
|
198
|
+
ident = next(
|
|
199
|
+
(v for v in claims.values() if isinstance(v, str) and v.startswith("identity-")),
|
|
200
|
+
None,
|
|
201
|
+
)
|
|
202
|
+
if not ident:
|
|
203
|
+
raise WSError("No identity id found in token claims.")
|
|
204
|
+
self._identity_id = ident
|
|
205
|
+
return self._identity_id
|
|
206
|
+
|
|
207
|
+
def _graphql(self, operation: str, query: str, variables: dict[str, Any]) -> Any:
|
|
208
|
+
headers = {
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
"Authorization": f"Bearer {self._access_token}",
|
|
211
|
+
"x-ws-profile": "trade",
|
|
212
|
+
"x-ws-api-version": GRAPHQL_VERSION,
|
|
213
|
+
"x-ws-locale": "en-CA",
|
|
214
|
+
"x-ws-device-id": self._device_id,
|
|
215
|
+
"x-ws-session-id": self._session_id,
|
|
216
|
+
"x-platform-os": "web",
|
|
217
|
+
}
|
|
218
|
+
body = {"operationName": operation, "query": query, "variables": variables}
|
|
219
|
+
try:
|
|
220
|
+
resp = self._http.post(
|
|
221
|
+
GRAPHQL_URL, json=body, headers=headers, impersonate=IMPERSONATE
|
|
222
|
+
)
|
|
223
|
+
except Exception as exc: # curl_cffi network errors
|
|
224
|
+
raise WSError(f"GraphQL request failed: {exc}") from exc
|
|
225
|
+
payload = resp.json()
|
|
226
|
+
if "data" not in payload or payload["data"] is None:
|
|
227
|
+
raise WSError(f"GraphQL error for {operation}: {payload.get('errors', payload)}")
|
|
228
|
+
return payload["data"]
|
|
229
|
+
|
|
230
|
+
def quote(self, symbol: str) -> dict[str, Any]:
|
|
231
|
+
"""Look up ``symbol`` and return its latest daily price.
|
|
232
|
+
|
|
233
|
+
Returns a dict: symbol, name, exchange, security_id, market_status,
|
|
234
|
+
last_price, currency, as_of. Raises WSError if the symbol isn't found.
|
|
235
|
+
"""
|
|
236
|
+
data = self._graphql("FetchSecuritySearchResult", _SEARCH_QUERY, {"query": symbol})
|
|
237
|
+
results = data["securitySearch"]["results"]
|
|
238
|
+
match = next(
|
|
239
|
+
(r for r in results if r["stock"]["symbol"].upper() == symbol.upper()),
|
|
240
|
+
results[0] if results else None,
|
|
241
|
+
)
|
|
242
|
+
if match is None:
|
|
243
|
+
raise WSError(f"No security found for {symbol!r}")
|
|
244
|
+
|
|
245
|
+
q = match.get("quoteV2") or {}
|
|
246
|
+
price = q.get("price") or q.get("last")
|
|
247
|
+
prev = q.get("referenceClose")
|
|
248
|
+
change = None
|
|
249
|
+
if price is not None and prev not in (None, "0"):
|
|
250
|
+
try:
|
|
251
|
+
change = round((float(price) - float(prev)) / float(prev) * 100, 2)
|
|
252
|
+
except (TypeError, ValueError, ZeroDivisionError):
|
|
253
|
+
change = None
|
|
254
|
+
return {
|
|
255
|
+
"symbol": match["stock"]["symbol"],
|
|
256
|
+
"name": match["stock"]["name"],
|
|
257
|
+
"exchange": match["stock"]["primaryExchange"],
|
|
258
|
+
"security_id": match["id"],
|
|
259
|
+
"market_status": q.get("marketStatus"),
|
|
260
|
+
"price": price,
|
|
261
|
+
"bid": q.get("bid"),
|
|
262
|
+
"ask": q.get("ask"),
|
|
263
|
+
"open": q.get("open"),
|
|
264
|
+
"high": q.get("high"),
|
|
265
|
+
"low": q.get("low"),
|
|
266
|
+
"close": q.get("close"),
|
|
267
|
+
"prev_close": prev,
|
|
268
|
+
"volume": q.get("volume"),
|
|
269
|
+
"change_pct": change,
|
|
270
|
+
"currency": q.get("currency"),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
def accounts(self) -> list[dict[str, Any]]:
|
|
274
|
+
"""List your accounts: id, type, nickname, currency, status, value."""
|
|
275
|
+
data = self._graphql(
|
|
276
|
+
"FetchAccounts", _ACCOUNTS_QUERY, {"identityId": self.identity_id}
|
|
277
|
+
)
|
|
278
|
+
out = []
|
|
279
|
+
for edge in data["identity"]["accounts"]["edges"]:
|
|
280
|
+
n = edge["node"]
|
|
281
|
+
nlv = (((n.get("financials") or {}).get("currentCombined")) or {}).get(
|
|
282
|
+
"netLiquidationValueV2"
|
|
283
|
+
) or {}
|
|
284
|
+
out.append(
|
|
285
|
+
{
|
|
286
|
+
"id": n["id"],
|
|
287
|
+
"type": n["unifiedAccountType"],
|
|
288
|
+
"nickname": n.get("nickname"),
|
|
289
|
+
"currency": n["currency"],
|
|
290
|
+
"status": n["status"],
|
|
291
|
+
"value": nlv.get("amount"),
|
|
292
|
+
}
|
|
293
|
+
)
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
def activities(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
297
|
+
"""Recent activity feed items (deposits, trades, etc.), newest first."""
|
|
298
|
+
data = self._graphql("FetchActivityFeedItems", _ACTIVITIES_QUERY, {"first": limit})
|
|
299
|
+
return [e["node"] for e in data["activityFeedItems"]["edges"]]
|
|
300
|
+
|
|
301
|
+
def positions(self, currency: str = "CAD", limit: int = 50) -> list[dict[str, Any]]:
|
|
302
|
+
"""Your aggregated holdings: symbol, quantity, book/market value, unrealized P&L."""
|
|
303
|
+
data = self._graphql(
|
|
304
|
+
"FetchIdentityPositions",
|
|
305
|
+
_POSITIONS_QUERY,
|
|
306
|
+
{"identityId": self.identity_id, "currency": currency, "first": limit},
|
|
307
|
+
)
|
|
308
|
+
edges = data["identity"]["financials"]["current"]["positions"]["edges"]
|
|
309
|
+
out = []
|
|
310
|
+
for e in edges:
|
|
311
|
+
n = e["node"]
|
|
312
|
+
stock = (n["security"].get("stock") or {})
|
|
313
|
+
out.append(
|
|
314
|
+
{
|
|
315
|
+
"symbol": stock.get("symbol"),
|
|
316
|
+
"name": stock.get("name"),
|
|
317
|
+
"quantity": n.get("quantity"),
|
|
318
|
+
"direction": n.get("positionDirection"),
|
|
319
|
+
"book_value": (n.get("bookValue") or {}).get("amount"),
|
|
320
|
+
"market_value": (n.get("totalValue") or {}).get("amount"),
|
|
321
|
+
"unrealized_pnl": (n.get("unrealizedReturns") or {}).get("amount"),
|
|
322
|
+
"pct_of_account": n.get("percentageOfAccount"),
|
|
323
|
+
"currency": (n.get("totalValue") or {}).get("currency"),
|
|
324
|
+
}
|
|
325
|
+
)
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
def security(self, symbol: str) -> dict[str, Any]:
|
|
329
|
+
"""Fundamentals for ``symbol``: market cap, P/E, EPS, yield, 52wk range, volume."""
|
|
330
|
+
sec_id = self._resolve_security_id(symbol)
|
|
331
|
+
data = self._graphql("FetchSecurityMarketData", _SECURITY_QUERY, {"id": sec_id})
|
|
332
|
+
sec = data["security"]
|
|
333
|
+
return {
|
|
334
|
+
"symbol": (sec.get("stock") or {}).get("symbol"),
|
|
335
|
+
"name": (sec.get("stock") or {}).get("name"),
|
|
336
|
+
"security_id": sec_id,
|
|
337
|
+
**(sec.get("fundamentals") or {}),
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
def historical_quotes(self, symbol: str, timerange: str = "1m") -> list[dict[str, Any]]:
|
|
341
|
+
"""Price history. ``timerange``: 1d, 1w, 1m, 3m, 1y, 5y."""
|
|
342
|
+
period = _CHART_PERIODS.get(timerange.lower())
|
|
343
|
+
if period is None:
|
|
344
|
+
raise WSError(f"timerange must be one of {sorted(_CHART_PERIODS)}")
|
|
345
|
+
sec_id = self._resolve_security_id(symbol)
|
|
346
|
+
data = self._graphql(
|
|
347
|
+
"FetchChartBarQuotes", _HIST_QUERY, {"id": sec_id, "period": period}
|
|
348
|
+
)
|
|
349
|
+
return data["security"]["chartBarQuotes"]
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def token_claims(self) -> dict[str, Any]:
|
|
353
|
+
"""Decoded access-token JWT claims (sub, scope, client_id, iat, exp)."""
|
|
354
|
+
payload_b64 = self._access_token.split(".")[1]
|
|
355
|
+
payload_b64 += "=" * (-len(payload_b64) % 4)
|
|
356
|
+
return json.loads(base64.urlsafe_b64decode(payload_b64))
|
|
357
|
+
|
|
358
|
+
def me(self) -> dict[str, Any]:
|
|
359
|
+
"""Your profile: name, email, identity id, plus token scope and expiry."""
|
|
360
|
+
data = self._graphql("FetchProfile", _PROFILE_QUERY, {"id": self.identity_id})
|
|
361
|
+
owners = data["identity"]["accounts"]["edges"][0]["node"]["accountOwners"]
|
|
362
|
+
owner = next(
|
|
363
|
+
(o for o in owners if o.get("identityId") == self.identity_id),
|
|
364
|
+
owners[0] if owners else {},
|
|
365
|
+
)
|
|
366
|
+
claims = self.token_claims
|
|
367
|
+
exp = claims.get("exp")
|
|
368
|
+
return {
|
|
369
|
+
"name": owner.get("name"),
|
|
370
|
+
"email": owner.get("email"),
|
|
371
|
+
"identity_id": self.identity_id,
|
|
372
|
+
"ownership_type": owner.get("ownershipType"),
|
|
373
|
+
"scope": claims.get("scope"),
|
|
374
|
+
"client_id": claims.get("client_id"),
|
|
375
|
+
"token_expires": (
|
|
376
|
+
datetime.fromtimestamp(exp, timezone.utc).isoformat() if exp else None
|
|
377
|
+
),
|
|
378
|
+
"token_expired": bool(exp and exp < datetime.now(timezone.utc).timestamp()),
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
def net_worth(self, currency: str = "CAD") -> dict[str, Any]:
|
|
382
|
+
"""Combined value across all accounts + simple return (amount and rate)."""
|
|
383
|
+
data = self._graphql(
|
|
384
|
+
"FetchIdentityCurrentFinancials",
|
|
385
|
+
_NETWORTH_QUERY,
|
|
386
|
+
{"identityId": self.identity_id, "currency": currency},
|
|
387
|
+
)
|
|
388
|
+
cur = data["identity"]["financials"]["current"]
|
|
389
|
+
sr = cur.get("simpleReturns") or {}
|
|
390
|
+
return {
|
|
391
|
+
"net_value": (cur.get("netLiquidationValueV2") or {}).get("amount"),
|
|
392
|
+
"net_deposits": (cur.get("netDeposits") or {}).get("amount"),
|
|
393
|
+
"return_amount": ((sr.get("amount") or {}).get("amount")),
|
|
394
|
+
"return_rate": sr.get("rate"),
|
|
395
|
+
"currency": currency,
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
def realized_returns(self, currency: str = "CAD", limit: int = 25) -> dict[str, Any]:
|
|
399
|
+
"""Total realized P&L + per-security breakdown."""
|
|
400
|
+
data = self._graphql(
|
|
401
|
+
"FetchIdentityRealizedReturns",
|
|
402
|
+
_REALIZED_QUERY,
|
|
403
|
+
{"identityId": self.identity_id, "currency": currency, "first": limit},
|
|
404
|
+
)
|
|
405
|
+
rr = data["identity"]["financials"]["realizedReturns"]
|
|
406
|
+
return {
|
|
407
|
+
"total": (rr.get("totalValue") or {}).get("amount"),
|
|
408
|
+
"currency": currency,
|
|
409
|
+
"by_security": [
|
|
410
|
+
{
|
|
411
|
+
"symbol": (e["node"]["security"].get("stock") or {}).get("symbol"),
|
|
412
|
+
"amount": (e["node"].get("totalValue") or {}).get("amount"),
|
|
413
|
+
}
|
|
414
|
+
for e in rr["securityBreakdown"]["edges"]
|
|
415
|
+
],
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
def dividends(self, currency: str = "CAD") -> dict[str, Any]:
|
|
419
|
+
"""Total dividend income + per-security breakdown."""
|
|
420
|
+
data = self._graphql(
|
|
421
|
+
"FetchDividendsV2",
|
|
422
|
+
_DIVIDENDS_QUERY,
|
|
423
|
+
{"identityId": self.identity_id, "currency": currency},
|
|
424
|
+
)
|
|
425
|
+
dv = data["identity"]["financials"]["dividendsV2"]
|
|
426
|
+
return {
|
|
427
|
+
"total": (dv.get("totalValue") or {}).get("amount"),
|
|
428
|
+
"currency": currency,
|
|
429
|
+
"by_security": [
|
|
430
|
+
{
|
|
431
|
+
"symbol": (b["security"].get("stock") or {}).get("symbol"),
|
|
432
|
+
"amount": (b.get("totalValue") or {}).get("amount"),
|
|
433
|
+
}
|
|
434
|
+
for b in (dv.get("issuingSecurityBreakdown") or [])
|
|
435
|
+
],
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
def security_dividend(self, symbol: str) -> dict[str, Any]:
|
|
439
|
+
"""Dividend details for ``symbol``: yield, frequency, ex-div/record/payable dates."""
|
|
440
|
+
sec_id = self._resolve_security_id(symbol)
|
|
441
|
+
data = self._graphql("FetchSecurityDividendDetails", _SEC_DIVIDEND_QUERY, {"securityId": sec_id})
|
|
442
|
+
sec = data["security"]
|
|
443
|
+
ev = sec.get("events") or {}
|
|
444
|
+
return {
|
|
445
|
+
"yield": (sec.get("fundamentals") or {}).get("yield"),
|
|
446
|
+
"frequency": (sec.get("stock") or {}).get("dividendFrequency"),
|
|
447
|
+
"ex_dividend_date": ev.get("exDividendDate"),
|
|
448
|
+
"record_date": ev.get("recordDate"),
|
|
449
|
+
"payable_date": ev.get("payableDate"),
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
def portfolio_history(
|
|
453
|
+
self, days: int = 90, currency: str = "CAD"
|
|
454
|
+
) -> list[dict[str, Any]]:
|
|
455
|
+
"""Daily net-worth series for the last ``days`` (for charting account value)."""
|
|
456
|
+
start = (datetime.now(timezone.utc) - timedelta(days=days)).date().isoformat()
|
|
457
|
+
data = self._graphql(
|
|
458
|
+
"FetchIdentityHistoricalFinancials",
|
|
459
|
+
_PORTFOLIO_HISTORY_QUERY,
|
|
460
|
+
{"identityId": self.identity_id, "currency": currency, "startDate": start, "first": days + 5},
|
|
461
|
+
)
|
|
462
|
+
edges = data["identity"]["financials"]["historicalDaily"]["edges"]
|
|
463
|
+
return [
|
|
464
|
+
{"date": e["node"]["date"], "value": (e["node"].get("netLiquidationValueV2") or {}).get("amount")}
|
|
465
|
+
for e in edges
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
def security_info(self, symbol: str) -> dict[str, Any]:
|
|
469
|
+
"""Full security data: fundamentals, margin rate, MER, order subtypes, exchange."""
|
|
470
|
+
sec_id = self._resolve_security_id(symbol)
|
|
471
|
+
data = self._graphql("FetchSecurityMarketData", _SEC_INFO_QUERY, {"id": sec_id})
|
|
472
|
+
sec = data["security"]
|
|
473
|
+
return {
|
|
474
|
+
"symbol": (sec.get("stock") or {}).get("symbol"),
|
|
475
|
+
"name": (sec.get("stock") or {}).get("name"),
|
|
476
|
+
"exchange": (sec.get("stock") or {}).get("primaryExchange"),
|
|
477
|
+
"dividend_frequency": (sec.get("stock") or {}).get("dividendFrequency"),
|
|
478
|
+
"allowed_order_subtypes": sec.get("allowedOrderSubtypes"),
|
|
479
|
+
"mer": sec.get("managementExpenseRatio"),
|
|
480
|
+
"margin_rate": (sec.get("marginRates") or {}).get("clientMarginRate"),
|
|
481
|
+
**(sec.get("fundamentals") or {}),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
def credit_card(self) -> Optional[dict[str, Any]]:
|
|
485
|
+
"""Credit-card account: limit, balances, cards. None if you have no card account."""
|
|
486
|
+
card_acct = next(
|
|
487
|
+
(a for a in self.accounts() if a["type"] == "CREDIT_CARD"), None
|
|
488
|
+
)
|
|
489
|
+
if card_acct is None:
|
|
490
|
+
return None
|
|
491
|
+
data = self._graphql("FetchCreditCardAccount", _CREDIT_CARD_QUERY, {"id": card_acct["id"]})
|
|
492
|
+
return data["creditCardAccount"]
|
|
493
|
+
|
|
494
|
+
def _resolve_security_id(self, symbol: str) -> str:
|
|
495
|
+
"""Search ``symbol`` and return the best-matching WS security id."""
|
|
496
|
+
data = self._graphql("FetchSecuritySearchResult", _SEARCH_QUERY, {"query": symbol})
|
|
497
|
+
results = data["securitySearch"]["results"]
|
|
498
|
+
match = next(
|
|
499
|
+
(r for r in results if r["stock"]["symbol"].upper() == symbol.upper()),
|
|
500
|
+
results[0] if results else None,
|
|
501
|
+
)
|
|
502
|
+
if match is None:
|
|
503
|
+
raise WSError(f"No security found for {symbol!r}")
|
|
504
|
+
return match["id"]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _bootstrap(http: requests.Session) -> tuple[str, str]:
|
|
508
|
+
"""Fetch device id (wssdi cookie) and production client_id from the login page."""
|
|
509
|
+
try:
|
|
510
|
+
resp = http.get(LOGIN_PAGE_URL, impersonate=IMPERSONATE)
|
|
511
|
+
except Exception as exc:
|
|
512
|
+
raise WSError(f"Bootstrap request failed: {exc}") from exc
|
|
513
|
+
|
|
514
|
+
device_id = resp.cookies.get("wssdi")
|
|
515
|
+
if not device_id:
|
|
516
|
+
m = re.search(r"wssdi=([a-f0-9-]+)", "; ".join(f"{k}={v}" for k, v in resp.cookies.items()))
|
|
517
|
+
device_id = m.group(1) if m else None
|
|
518
|
+
if not device_id:
|
|
519
|
+
raise WSError("Couldn't find wssdi (device id) on login page.")
|
|
520
|
+
|
|
521
|
+
m = re.search(r'<script[^>]+src="([^"]+/app-[a-f0-9]+\.js)"', resp.text, re.IGNORECASE)
|
|
522
|
+
if not m:
|
|
523
|
+
raise WSError("Couldn't find app JS bundle URL on login page.")
|
|
524
|
+
js = http.get(m.group(1), impersonate=IMPERSONATE)
|
|
525
|
+
m = re.search(r'"production"[^}]*clientId:"([a-f0-9]+)"', js.text, re.IGNORECASE)
|
|
526
|
+
if not m:
|
|
527
|
+
raise WSError("Couldn't find production clientId in app JS.")
|
|
528
|
+
return device_id, m.group(1)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def from_refresh_token(
|
|
532
|
+
refresh_token: str,
|
|
533
|
+
device_id: Optional[str] = None,
|
|
534
|
+
client_id: Optional[str] = None,
|
|
535
|
+
) -> Session:
|
|
536
|
+
"""Exchange a refresh token for a fresh access token and return a Session.
|
|
537
|
+
|
|
538
|
+
Best path for passkey/2FA accounts: grab the refresh token once from the browser,
|
|
539
|
+
then this mints new access tokens without any login or OTP. ``device_id`` and
|
|
540
|
+
``client_id`` are bootstrapped from the login page if not supplied.
|
|
541
|
+
"""
|
|
542
|
+
http = requests.Session()
|
|
543
|
+
if not device_id or not client_id:
|
|
544
|
+
boot_device, boot_client = _bootstrap(http)
|
|
545
|
+
device_id = device_id or boot_device
|
|
546
|
+
client_id = client_id or boot_client
|
|
547
|
+
|
|
548
|
+
data = {
|
|
549
|
+
"grant_type": "refresh_token",
|
|
550
|
+
"refresh_token": refresh_token,
|
|
551
|
+
"client_id": client_id,
|
|
552
|
+
}
|
|
553
|
+
headers = {
|
|
554
|
+
"Content-Type": "application/json",
|
|
555
|
+
"x-wealthsimple-client": "@wealthsimple/wealthsimple",
|
|
556
|
+
"x-ws-profile": "invest",
|
|
557
|
+
"x-ws-device-id": device_id,
|
|
558
|
+
}
|
|
559
|
+
try:
|
|
560
|
+
resp = http.post(OAUTH_TOKEN_URL, json=data, headers=headers, impersonate=IMPERSONATE)
|
|
561
|
+
except Exception as exc:
|
|
562
|
+
raise WSError(f"Refresh request failed: {exc}") from exc
|
|
563
|
+
payload = resp.json()
|
|
564
|
+
if "access_token" not in payload:
|
|
565
|
+
raise WSError(f"Refresh failed: {payload.get('error_description', payload)}")
|
|
566
|
+
return Session(http, payload["access_token"], device_id)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def login(
|
|
570
|
+
email: str,
|
|
571
|
+
password: str,
|
|
572
|
+
otp: Optional[str] = None,
|
|
573
|
+
scope: str = SCOPE_READ_ONLY,
|
|
574
|
+
) -> Session:
|
|
575
|
+
"""Log in and return an authenticated :class:`Session`.
|
|
576
|
+
|
|
577
|
+
On first call without ``otp`` for a 2FA account, raises :class:`OTPRequired`;
|
|
578
|
+
call again passing the TOTP code as ``otp``.
|
|
579
|
+
"""
|
|
580
|
+
http = requests.Session()
|
|
581
|
+
device_id, client_id = _bootstrap(http)
|
|
582
|
+
|
|
583
|
+
data = {
|
|
584
|
+
"grant_type": "password",
|
|
585
|
+
"username": email,
|
|
586
|
+
"password": password,
|
|
587
|
+
"skip_provision": "true",
|
|
588
|
+
"scope": scope,
|
|
589
|
+
"client_id": client_id,
|
|
590
|
+
"otp_claim": None,
|
|
591
|
+
}
|
|
592
|
+
headers = {
|
|
593
|
+
"Content-Type": "application/json",
|
|
594
|
+
"x-wealthsimple-client": "@wealthsimple/wealthsimple",
|
|
595
|
+
"x-ws-profile": "undefined",
|
|
596
|
+
"x-ws-device-id": device_id,
|
|
597
|
+
}
|
|
598
|
+
if otp:
|
|
599
|
+
headers["x-wealthsimple-otp"] = f"{otp};remember=true"
|
|
600
|
+
|
|
601
|
+
try:
|
|
602
|
+
resp = http.post(OAUTH_TOKEN_URL, json=data, headers=headers, impersonate=IMPERSONATE)
|
|
603
|
+
except Exception as exc:
|
|
604
|
+
raise WSError(f"Login request failed: {exc}") from exc
|
|
605
|
+
payload = resp.json()
|
|
606
|
+
|
|
607
|
+
if payload.get("error") == "invalid_grant" and otp is None:
|
|
608
|
+
raise OTPRequired("2FA code required — call login() again with otp=<code>.")
|
|
609
|
+
if "error" in payload or "access_token" not in payload:
|
|
610
|
+
raise WSError(f"Login failed: {payload.get('error_description', payload)}")
|
|
611
|
+
|
|
612
|
+
return Session(http, payload["access_token"], device_id)
|
|
@@ -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,7 @@
|
|
|
1
|
+
wealthsim/__init__.py,sha256=mAEN3RIUTAKwdt7cWiDci0U0JA47fM2Y_jpQ4U-t7TA,1008
|
|
2
|
+
wealthsim/browser.py,sha256=7MoQfPH_3HYkK9TrSSmyhgJwZwubPXlL8wHFBtwwML0,3100
|
|
3
|
+
wealthsim/client.py,sha256=pkc4Iy9Yc5um8w_uy_ax5fNhgDiyvrqGS2EfrBDhAZs,25147
|
|
4
|
+
wealthsim-0.1.0.dist-info/METADATA,sha256=t4kbnBvHyCWDQars_3N0X0QtlscifXmRTY-HxouB-aU,4384
|
|
5
|
+
wealthsim-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
wealthsim-0.1.0.dist-info/licenses/LICENSE,sha256=OLWFiSLNHudpXEHIOvgyUzfgfu8QM1RQnvR_HItg4Wk,1068
|
|
7
|
+
wealthsim-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|