pyeasyequities 2.0.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.
- easy_equities_client/__init__.py +1 -0
- easy_equities_client/accounts/__init__.py +0 -0
- easy_equities_client/accounts/clients.py +334 -0
- easy_equities_client/accounts/parsers.py +195 -0
- easy_equities_client/accounts/types.py +63 -0
- easy_equities_client/clients.py +243 -0
- easy_equities_client/constants.py +51 -0
- easy_equities_client/instruments/__init__.py +0 -0
- easy_equities_client/instruments/clients.py +1322 -0
- easy_equities_client/instruments/types.py +179 -0
- easy_equities_client/orders/__init__.py +13 -0
- easy_equities_client/orders/clients.py +272 -0
- easy_equities_client/orders/types.py +63 -0
- easy_equities_client/types.py +18 -0
- easy_equities_client/utils/__init__.py +0 -0
- easy_equities_client/utils/currencies.csv +8 -0
- easy_equities_client/utils/currencies.py +17 -0
- easy_equities_client/utils/dataclasses.py +8 -0
- pyeasyequities-2.0.0.dist-info/METADATA +192 -0
- pyeasyequities-2.0.0.dist-info/RECORD +22 -0
- pyeasyequities-2.0.0.dist-info/WHEEL +4 -0
- pyeasyequities-2.0.0.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from . import accounts, clients, constants, instruments, orders, types, utils # noqa: F401
|
|
File without changes
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import logging
|
|
3
|
+
from datetime import date, timedelta
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from requests import Session
|
|
7
|
+
|
|
8
|
+
from easy_equities_client import constants
|
|
9
|
+
from easy_equities_client.accounts.types import (
|
|
10
|
+
Account,
|
|
11
|
+
Holding,
|
|
12
|
+
Transaction,
|
|
13
|
+
TransactionForPeriod,
|
|
14
|
+
Valuation,
|
|
15
|
+
)
|
|
16
|
+
from easy_equities_client.types import Client
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_API_GW_TRANSACTION_PATHS = [
|
|
21
|
+
"/transaction-history-provider/api/v1/transactions",
|
|
22
|
+
"/transaction-history-provider/v1/transactions",
|
|
23
|
+
"/easytrader/api/TransactionHistory/transactions",
|
|
24
|
+
"/easytrader/api/Transactions/account-transactions",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AccountsClient(Client):
|
|
29
|
+
def __init__(self, base_url: str = "", session: Session = None):
|
|
30
|
+
super().__init__(base_url, session)
|
|
31
|
+
self._portfolio_cache: Optional[Dict] = None
|
|
32
|
+
|
|
33
|
+
def _get_portfolio_overview(self, force_refresh: bool = False) -> Dict:
|
|
34
|
+
"""Fetch and cache the REST API portfolio overview."""
|
|
35
|
+
if self._portfolio_cache is None or force_refresh:
|
|
36
|
+
response = self.session.get(
|
|
37
|
+
constants.REST_API_BASE_URL + constants.REST_PORTFOLIO_OVERVIEW_PATH
|
|
38
|
+
)
|
|
39
|
+
response.raise_for_status()
|
|
40
|
+
self._portfolio_cache = response.json()
|
|
41
|
+
return self._portfolio_cache
|
|
42
|
+
|
|
43
|
+
def _find_account(self, account_id: str) -> Optional[Dict]:
|
|
44
|
+
"""Return the investmentAccount dict matching account_id (accountNumber)."""
|
|
45
|
+
overview = self._get_portfolio_overview()
|
|
46
|
+
for acc in overview.get("investmentAccounts", []):
|
|
47
|
+
if acc.get("accountNumber") == account_id:
|
|
48
|
+
return acc
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def _decode_image_uri(self, image_uri: str) -> str:
|
|
52
|
+
"""Decode base64-encoded image URI to a plain URL."""
|
|
53
|
+
try:
|
|
54
|
+
return base64.b64decode(image_uri).decode("utf-8")
|
|
55
|
+
except Exception:
|
|
56
|
+
return image_uri
|
|
57
|
+
|
|
58
|
+
def list(self) -> List[Account]:
|
|
59
|
+
"""Return all investment accounts for the authenticated user."""
|
|
60
|
+
overview = self._get_portfolio_overview()
|
|
61
|
+
accounts = []
|
|
62
|
+
for acc in overview.get("investmentAccounts", []):
|
|
63
|
+
accounts.append(
|
|
64
|
+
Account(
|
|
65
|
+
id=acc["accountNumber"],
|
|
66
|
+
name=acc["productName"],
|
|
67
|
+
trading_currency_id=str(acc.get("productId", "")),
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return accounts
|
|
71
|
+
|
|
72
|
+
def valuations(self, account_id: str) -> Valuation:
|
|
73
|
+
"""
|
|
74
|
+
Return valuation data for the given account from the REST API portfolio overview.
|
|
75
|
+
|
|
76
|
+
:param account_id: Account number string (e.g. 'EE3237137-15547214').
|
|
77
|
+
"""
|
|
78
|
+
self._portfolio_cache = None
|
|
79
|
+
acc = self._find_account(account_id)
|
|
80
|
+
if acc is None:
|
|
81
|
+
raise ValueError(f"Account '{account_id}' not found in portfolio overview.")
|
|
82
|
+
|
|
83
|
+
aggregates = acc.get("aggregates", [])
|
|
84
|
+
accruals = acc.get("accruals", [])
|
|
85
|
+
costs = acc.get("costs", {})
|
|
86
|
+
|
|
87
|
+
investment_types = next(
|
|
88
|
+
(a for a in aggregates if a.get("aggregateName") == "Investment Type"), {}
|
|
89
|
+
)
|
|
90
|
+
managers = next(
|
|
91
|
+
(a for a in aggregates if a.get("aggregateName") == "Manager"), {}
|
|
92
|
+
)
|
|
93
|
+
income_accruals = next(
|
|
94
|
+
(a for a in accruals if a.get("accrualName") == "Income"), {}
|
|
95
|
+
)
|
|
96
|
+
expense_accruals = next(
|
|
97
|
+
(a for a in accruals if a.get("accrualName") == "Expense"), {}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"accountNumber": acc.get("accountNumber"),
|
|
102
|
+
"productName": acc.get("productName"),
|
|
103
|
+
"currencyCode": acc.get("currencyCode"),
|
|
104
|
+
"totalInvestmentHoldingsValue": acc.get("totalInvestmentHoldingsValue"),
|
|
105
|
+
"InvestmentTypesAndManagers": {
|
|
106
|
+
"types": investment_types.get("items", []),
|
|
107
|
+
"managers": managers.get("items", []),
|
|
108
|
+
},
|
|
109
|
+
"AccrualIncomeSummaryItems": income_accruals.get("items", []),
|
|
110
|
+
"AccrualExpenseSummaryItems": expense_accruals.get("items", []),
|
|
111
|
+
"CostsSummaryItems": costs.get("items", []) if isinstance(costs, dict) else [],
|
|
112
|
+
"costsTotal": costs.get("costsTotal", 0) if isinstance(costs, dict) else 0,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
def transactions(self, account_id: str) -> List[Transaction]:
|
|
116
|
+
"""
|
|
117
|
+
Fetch transactions for the given account.
|
|
118
|
+
|
|
119
|
+
Tries multiple known API Gateway transaction endpoints. Returns an empty
|
|
120
|
+
list if the account has no transactions or the endpoint is unavailable.
|
|
121
|
+
|
|
122
|
+
:param account_id: Account number string (e.g. 'EE3237137-15547214').
|
|
123
|
+
"""
|
|
124
|
+
gw = constants.API_GATEWAY_BASE_URL
|
|
125
|
+
headers = {
|
|
126
|
+
"Origin": "https://portfolio-overview.apps.easyequities.io",
|
|
127
|
+
"Referer": "https://portfolio-overview.apps.easyequities.io/",
|
|
128
|
+
"Accept": "application/json, text/plain, */*",
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for path in _API_GW_TRANSACTION_PATHS:
|
|
132
|
+
url = gw + path
|
|
133
|
+
params = {"accountNumber": account_id}
|
|
134
|
+
try:
|
|
135
|
+
r = self.session.get(url, params=params, headers=headers, timeout=15)
|
|
136
|
+
if r.status_code == 200:
|
|
137
|
+
data = r.json()
|
|
138
|
+
logger.info(f"Transactions fetched from {path}: {len(data)} records")
|
|
139
|
+
return data if isinstance(data, list) else data.get("transactions", [])
|
|
140
|
+
elif r.status_code == 404:
|
|
141
|
+
# Empty result set — account exists but no transactions
|
|
142
|
+
logger.debug(f"No transactions at {path} (404)")
|
|
143
|
+
return []
|
|
144
|
+
else:
|
|
145
|
+
logger.debug(f"Transaction endpoint {path} returned {r.status_code}")
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
logger.debug(f"Transaction endpoint {path} error: {exc}")
|
|
148
|
+
|
|
149
|
+
# No endpoint worked — return empty list with a warning
|
|
150
|
+
logger.warning(
|
|
151
|
+
f"Could not fetch transactions for account '{account_id}'. "
|
|
152
|
+
"The transaction history API endpoint may have changed. "
|
|
153
|
+
"Check constants.API_GW_TRANSACTIONS_PATH for the latest URL."
|
|
154
|
+
)
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
def transactions_for_period(
|
|
158
|
+
self, account_id: str, start_date: date, end_date: date
|
|
159
|
+
) -> List[TransactionForPeriod]:
|
|
160
|
+
"""
|
|
161
|
+
Fetch transactions for a given date range.
|
|
162
|
+
|
|
163
|
+
:param account_id: Account number string.
|
|
164
|
+
:param start_date: Start of the period (inclusive).
|
|
165
|
+
:param end_date: End of the period (inclusive).
|
|
166
|
+
"""
|
|
167
|
+
gw = constants.API_GATEWAY_BASE_URL
|
|
168
|
+
headers = {
|
|
169
|
+
"Origin": "https://portfolio-overview.apps.easyequities.io",
|
|
170
|
+
"Referer": "https://portfolio-overview.apps.easyequities.io/",
|
|
171
|
+
"Accept": "application/json, text/plain, */*",
|
|
172
|
+
}
|
|
173
|
+
transactions: List[Any] = []
|
|
174
|
+
current_start = start_date
|
|
175
|
+
current_end = min(end_date, current_start + timedelta(days=90))
|
|
176
|
+
|
|
177
|
+
while current_start <= end_date:
|
|
178
|
+
logger.debug(f"Fetching transactions {current_start} → {current_end}")
|
|
179
|
+
fetched = False
|
|
180
|
+
|
|
181
|
+
for path in _API_GW_TRANSACTION_PATHS:
|
|
182
|
+
url = gw + path
|
|
183
|
+
params = {
|
|
184
|
+
"accountNumber": account_id,
|
|
185
|
+
"startDate": current_start.isoformat(),
|
|
186
|
+
"endDate": current_end.isoformat(),
|
|
187
|
+
}
|
|
188
|
+
try:
|
|
189
|
+
r = self.session.get(url, params=params, headers=headers, timeout=15)
|
|
190
|
+
if r.status_code == 200:
|
|
191
|
+
batch = r.json()
|
|
192
|
+
if isinstance(batch, list):
|
|
193
|
+
transactions = batch + transactions
|
|
194
|
+
fetched = True
|
|
195
|
+
break
|
|
196
|
+
elif r.status_code == 404:
|
|
197
|
+
fetched = True
|
|
198
|
+
break
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
logger.debug(f"transactions_for_period error at {path}: {exc}")
|
|
201
|
+
|
|
202
|
+
if not fetched:
|
|
203
|
+
logger.warning(
|
|
204
|
+
f"Could not fetch transactions for period {current_start}–{current_end}. "
|
|
205
|
+
"Transaction API endpoint may have changed."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
current_start = current_end + timedelta(days=1)
|
|
209
|
+
current_end = min(end_date, current_start + timedelta(days=90))
|
|
210
|
+
|
|
211
|
+
return transactions
|
|
212
|
+
|
|
213
|
+
def nav_chart(self, account_id: str, period: str = "1mo") -> dict:
|
|
214
|
+
"""
|
|
215
|
+
Return the portfolio NAV (Net Asset Value) chart data for the given account.
|
|
216
|
+
|
|
217
|
+
The EasyEquities REST API endpoint ``/portfolios/nav_chart_data/{period}``
|
|
218
|
+
returns an empty response for accounts with no trading history. For accounts
|
|
219
|
+
that have holdings and transactions, it returns time-series NAV data.
|
|
220
|
+
|
|
221
|
+
Supported period strings: ``"1W"``, ``"1mo"``, ``"3mo"``, ``"6mo"``, ``"1Y"``
|
|
222
|
+
|
|
223
|
+
:param account_id: Account number string (e.g. 'EE3237137-15547214').
|
|
224
|
+
:param period: Time period string. Default ``"1mo"``.
|
|
225
|
+
:return: Dict with:
|
|
226
|
+
- ``success`` — True if chart data is available
|
|
227
|
+
- ``account_id`` — the requested account
|
|
228
|
+
- ``period`` — the requested period
|
|
229
|
+
- ``data`` — list of ``{"date": str, "nav": float}`` points,
|
|
230
|
+
empty if the account has no trade history
|
|
231
|
+
- ``message`` — explanation if no data is available
|
|
232
|
+
|
|
233
|
+
Example::
|
|
234
|
+
|
|
235
|
+
chart = client.accounts.nav_chart("EE3237137-15547214", period="3mo")
|
|
236
|
+
if chart["success"]:
|
|
237
|
+
for point in chart["data"]:
|
|
238
|
+
print(point["date"], point["nav"])
|
|
239
|
+
else:
|
|
240
|
+
print(chart["message"])
|
|
241
|
+
"""
|
|
242
|
+
url = constants.REST_API_BASE_URL + f"/portfolios/nav_chart_data/{period}"
|
|
243
|
+
headers = {
|
|
244
|
+
"Origin": "https://portfolio-overview.apps.easyequities.io",
|
|
245
|
+
"Referer": "https://portfolio-overview.apps.easyequities.io/",
|
|
246
|
+
"Accept": "application/json, text/plain, */*",
|
|
247
|
+
}
|
|
248
|
+
try:
|
|
249
|
+
r = self.session.get(url, headers=headers, timeout=15)
|
|
250
|
+
r.raise_for_status()
|
|
251
|
+
raw = r.json()
|
|
252
|
+
except Exception as exc:
|
|
253
|
+
return {
|
|
254
|
+
"success": False,
|
|
255
|
+
"account_id": account_id,
|
|
256
|
+
"period": period,
|
|
257
|
+
"data": [],
|
|
258
|
+
"message": f"Request failed: {exc}",
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if not raw:
|
|
262
|
+
return {
|
|
263
|
+
"success": False,
|
|
264
|
+
"account_id": account_id,
|
|
265
|
+
"period": period,
|
|
266
|
+
"data": [],
|
|
267
|
+
"message": (
|
|
268
|
+
"No NAV chart data returned. This account has no trading history "
|
|
269
|
+
"yet — NAV chart data is only available for accounts with completed "
|
|
270
|
+
"buy/sell transactions."
|
|
271
|
+
),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
points = []
|
|
275
|
+
if isinstance(raw, list):
|
|
276
|
+
for entry in raw:
|
|
277
|
+
date = entry.get("Date") or entry.get("date") or entry.get("x")
|
|
278
|
+
nav = entry.get("Nav") or entry.get("nav") or entry.get("y") or entry.get("Value")
|
|
279
|
+
if date and nav is not None:
|
|
280
|
+
points.append({"date": str(date)[:10], "nav": float(nav)})
|
|
281
|
+
elif isinstance(raw, dict):
|
|
282
|
+
for key in ("data", "Data", "points", "Points", "series", "Series"):
|
|
283
|
+
if key in raw and isinstance(raw[key], list):
|
|
284
|
+
for entry in raw[key]:
|
|
285
|
+
date = entry.get("Date") or entry.get("date") or entry.get("x")
|
|
286
|
+
nav = entry.get("Nav") or entry.get("nav") or entry.get("y") or entry.get("Value")
|
|
287
|
+
if date and nav is not None:
|
|
288
|
+
points.append({"date": str(date)[:10], "nav": float(nav)})
|
|
289
|
+
break
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
"success": True,
|
|
293
|
+
"account_id": account_id,
|
|
294
|
+
"period": period,
|
|
295
|
+
"data": points,
|
|
296
|
+
"message": None,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
def holdings(self, account_id: str, include_shares: bool = False) -> List[Holding]:
|
|
300
|
+
"""
|
|
301
|
+
Get an account's holdings from the REST API portfolio overview.
|
|
302
|
+
|
|
303
|
+
:param account_id: Account number string (e.g. 'EE3237137-15547214').
|
|
304
|
+
:param include_shares: Included for backward compatibility; share units are
|
|
305
|
+
already present in the REST API response as 'units'.
|
|
306
|
+
"""
|
|
307
|
+
self._portfolio_cache = None
|
|
308
|
+
acc = self._find_account(account_id)
|
|
309
|
+
if acc is None:
|
|
310
|
+
raise ValueError(f"Account '{account_id}' not found in portfolio overview.")
|
|
311
|
+
|
|
312
|
+
currency = acc.get("currencyCode", "")
|
|
313
|
+
holdings: List[Holding] = []
|
|
314
|
+
|
|
315
|
+
for asset in acc.get("assets", []):
|
|
316
|
+
image_uri = asset.get("imageUri", "")
|
|
317
|
+
img_url = self._decode_image_uri(image_uri) if image_uri else ""
|
|
318
|
+
|
|
319
|
+
holding: Holding = {
|
|
320
|
+
"name": asset.get("assetName", ""),
|
|
321
|
+
"contract_code": asset.get("contractCode", ""),
|
|
322
|
+
"purchase_value": f"{currency} {asset.get('purchaseValue', 0)}",
|
|
323
|
+
"current_value": f"{currency} {asset.get('currentValue', 0)}",
|
|
324
|
+
"current_price": f"{currency} {asset.get('currentPrice', 0)}",
|
|
325
|
+
"img": img_url,
|
|
326
|
+
"view_url": "",
|
|
327
|
+
"isin": asset.get("assetCode", ""),
|
|
328
|
+
"shares": str(asset.get("units", "")),
|
|
329
|
+
"profit_loss_value": asset.get("profitLossValue", 0),
|
|
330
|
+
"profit_loss_percentage": asset.get("profitLossPercentage", 0),
|
|
331
|
+
}
|
|
332
|
+
holdings.append(holding)
|
|
333
|
+
|
|
334
|
+
return holdings
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, List, Optional
|
|
4
|
+
|
|
5
|
+
from bs4 import BeautifulSoup
|
|
6
|
+
from bs4.element import Tag
|
|
7
|
+
|
|
8
|
+
from easy_equities_client import constants
|
|
9
|
+
from easy_equities_client.accounts.types import Account, Holding
|
|
10
|
+
from easy_equities_client.utils import currencies
|
|
11
|
+
|
|
12
|
+
amount_pattern_compiled = re.compile(constants.RE_AMOUNT_PATTERN)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def extract_account_info(account_div: Tag) -> Optional[Account]:
|
|
16
|
+
trading_currency = account_div.parent.attrs.get("data-tradingcurrencyid")
|
|
17
|
+
if not trading_currency:
|
|
18
|
+
return None
|
|
19
|
+
return Account(
|
|
20
|
+
name=account_div.text.strip(),
|
|
21
|
+
trading_currency_id=trading_currency.strip(),
|
|
22
|
+
id=account_div.parent.attrs["data-id"].strip(),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AccountOverviewParser:
|
|
28
|
+
"""
|
|
29
|
+
Parse the accounts overview page (/AccountOverview) given the html
|
|
30
|
+
contents of the page.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
page: str
|
|
34
|
+
|
|
35
|
+
def extract_accounts(self) -> List[Account]:
|
|
36
|
+
"""
|
|
37
|
+
Return the accounts found on the account overview page.
|
|
38
|
+
"""
|
|
39
|
+
soup = BeautifulSoup(self.page, "html.parser")
|
|
40
|
+
accounts_divs = soup.find_all(attrs={"id": "trust-account-types"})
|
|
41
|
+
return [
|
|
42
|
+
account
|
|
43
|
+
for account in [
|
|
44
|
+
extract_account_info(account_div) for account_div in accounts_divs
|
|
45
|
+
]
|
|
46
|
+
if account
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HoldingFieldNotFoundException(Exception):
|
|
51
|
+
def __init__(self, field, exception):
|
|
52
|
+
return super().__init__(
|
|
53
|
+
f"Field '{field}' not found in holding div. Exception: f{exception}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HoldingDivParser:
|
|
58
|
+
def __init__(self, div: Tag):
|
|
59
|
+
self.div = div
|
|
60
|
+
|
|
61
|
+
def __eq__(a, b):
|
|
62
|
+
return a.name == b.name
|
|
63
|
+
|
|
64
|
+
def __hash__(self):
|
|
65
|
+
return hash(self.name)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def name(self) -> str:
|
|
69
|
+
return self.div.find(attrs={"class": "equity-image-as-text"}).text.strip()
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def purchase_value(self) -> str:
|
|
73
|
+
return self.div.find(attrs={"class": "purchase-value-cell"}).text.strip()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def current_value(self) -> str:
|
|
77
|
+
return self.div.find(attrs={"class": "current-value-cell"}).text.strip()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def current_price(self) -> str:
|
|
81
|
+
return self.div.find(attrs={"class": "current-price-cell"}).text.strip()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def img(self) -> str:
|
|
85
|
+
return self.div.find(attrs={"class": "instrument"}).attrs["src"]
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def contract_code(self) -> str:
|
|
89
|
+
return self.img[self.img.rindex("/") + 1 : self.img.index(".png")]
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def view_url(self) -> str:
|
|
93
|
+
return (
|
|
94
|
+
self.div.find(attrs={"class": "collapse-container"})
|
|
95
|
+
.find("span")
|
|
96
|
+
.attrs["data-detailviewurl"]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def isin(self) -> str:
|
|
101
|
+
return (
|
|
102
|
+
self.div.find(attrs={"class": "collapse-container"})
|
|
103
|
+
.find("span")
|
|
104
|
+
.attrs["data-detailviewurl"]
|
|
105
|
+
.split("=")[-1]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> Holding:
|
|
109
|
+
fields = [
|
|
110
|
+
"name",
|
|
111
|
+
"contract_code",
|
|
112
|
+
"purchase_value",
|
|
113
|
+
"current_value",
|
|
114
|
+
"current_price",
|
|
115
|
+
"img",
|
|
116
|
+
"view_url",
|
|
117
|
+
"isin",
|
|
118
|
+
]
|
|
119
|
+
data: Holding = {}
|
|
120
|
+
for field in fields:
|
|
121
|
+
try:
|
|
122
|
+
data[field] = getattr(self, field) # type: ignore
|
|
123
|
+
except Exception as e:
|
|
124
|
+
raise HoldingFieldNotFoundException(field, e)
|
|
125
|
+
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class AccountHoldingsParser:
|
|
131
|
+
"""
|
|
132
|
+
Parse the accounts holdings page given the html contents of the page.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
page: bytes
|
|
136
|
+
|
|
137
|
+
def extract_holdings(self) -> List[Holding]:
|
|
138
|
+
"""
|
|
139
|
+
Return the holdings found on the holdings page.
|
|
140
|
+
"""
|
|
141
|
+
soup = BeautifulSoup(self.page, "html.parser")
|
|
142
|
+
holdings_divs = soup.find_all(attrs={"class": "holding-inner-container"})
|
|
143
|
+
# Get unique holdings (skip first row because it is the header row)
|
|
144
|
+
divs = set([HoldingDivParser(holding_div) for holding_div in holdings_divs[1:]])
|
|
145
|
+
return [div.to_dict() for div in divs]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_amount_and_currency_from_string(
|
|
149
|
+
amount_string: str,
|
|
150
|
+
) -> tuple[str, str | None, float]:
|
|
151
|
+
amount_match = amount_pattern_compiled.match(amount_string)
|
|
152
|
+
if amount_match is None:
|
|
153
|
+
raise Exception(
|
|
154
|
+
f"Could not parse amount {amount_string} into currency and value."
|
|
155
|
+
)
|
|
156
|
+
currency_symbol = amount_match.group("currency")
|
|
157
|
+
currency_code = currencies.convert_non_ascii_currency_symbol_to_ascii_code(
|
|
158
|
+
currency_symbol
|
|
159
|
+
)
|
|
160
|
+
value = float(
|
|
161
|
+
(amount_match.group("symbol") if amount_match.group("symbol") else "")
|
|
162
|
+
+ amount_match.group("value").replace(" ", "")
|
|
163
|
+
)
|
|
164
|
+
return currency_symbol, currency_code, value
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_transactions_from_page(page_body: bytes) -> List[Any]:
|
|
168
|
+
"""
|
|
169
|
+
:param page_body: Page html from response.content.
|
|
170
|
+
"""
|
|
171
|
+
soup = BeautifulSoup(page_body, "html.parser")
|
|
172
|
+
table = soup.find("div", {"id": "TransactionHistory"}).find("tbody")
|
|
173
|
+
if table is None:
|
|
174
|
+
validation_error = soup.find(class_="validation-summary-errors")
|
|
175
|
+
if validation_error:
|
|
176
|
+
raise Exception(validation_error.text.strip())
|
|
177
|
+
return []
|
|
178
|
+
rows = table.find_all("tr")
|
|
179
|
+
transactions = []
|
|
180
|
+
for row in rows:
|
|
181
|
+
columns = row.find_all("td")
|
|
182
|
+
currency_symbol, currency_code, value = get_amount_and_currency_from_string(
|
|
183
|
+
columns[2].text.strip()
|
|
184
|
+
)
|
|
185
|
+
transactions.append(
|
|
186
|
+
{
|
|
187
|
+
"date": columns[0].text.strip(),
|
|
188
|
+
"description": columns[1].text.strip(),
|
|
189
|
+
"currency_symbol": currency_symbol,
|
|
190
|
+
"currency_code": currency_code,
|
|
191
|
+
"value": value,
|
|
192
|
+
"amount": columns[2].text.strip(),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
return transactions
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
if sys.version_info >= (3, 8):
|
|
6
|
+
from typing import TypedDict
|
|
7
|
+
else:
|
|
8
|
+
from typing_extensions import TypedDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Account:
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
trading_currency_id: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Holding(TypedDict, total=False):
|
|
19
|
+
name: str
|
|
20
|
+
contract_code: str
|
|
21
|
+
purchase_value: str
|
|
22
|
+
current_value: str
|
|
23
|
+
current_price: str
|
|
24
|
+
img: str
|
|
25
|
+
view_url: str
|
|
26
|
+
isin: str
|
|
27
|
+
shares: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Transaction(TypedDict):
|
|
31
|
+
TransactionId: int
|
|
32
|
+
DebitCredit: float
|
|
33
|
+
Comment: str
|
|
34
|
+
TransactionDate: str
|
|
35
|
+
LogId: int
|
|
36
|
+
ActionId: int
|
|
37
|
+
Action: str
|
|
38
|
+
ContractCode: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TransactionForPeriod(TypedDict):
|
|
42
|
+
date: str
|
|
43
|
+
description: str
|
|
44
|
+
amount: str
|
|
45
|
+
currency: str
|
|
46
|
+
value: float
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LabelValue(TypedDict):
|
|
50
|
+
Label: str
|
|
51
|
+
Value: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Valuation(TypedDict):
|
|
55
|
+
NetInterestOnCashItems: List[LabelValue]
|
|
56
|
+
AccrualSummaryItems: List[LabelValue]
|
|
57
|
+
TopSummary: dict
|
|
58
|
+
InvestmentTypesAndManagers: dict
|
|
59
|
+
InvestmentSummaryItems: list
|
|
60
|
+
CostsSummaryItems: list
|
|
61
|
+
FundSummaryItems: list
|
|
62
|
+
AccrualIncomeSummaryItems: Optional[list]
|
|
63
|
+
AccrualExpenseSummaryItems: Optional[list]
|