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.
@@ -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]