fidelity-helper 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.
- fidelity_helper-0.1.0/.gitignore +2 -0
- fidelity_helper-0.1.0/LICENSE +7 -0
- fidelity_helper-0.1.0/PKG-INFO +45 -0
- fidelity_helper-0.1.0/README.md +26 -0
- fidelity_helper-0.1.0/fidelity_helper/fidelity-templates/getAccountsOptions.json +22 -0
- fidelity_helper-0.1.0/fidelity_helper/fidelity-templates/getTransactionsBody.json.mustache +27 -0
- fidelity_helper-0.1.0/fidelity_helper/fidelity-templates/getTransactionsOptions.json.mustache +23 -0
- fidelity_helper-0.1.0/pyproject.toml +98 -0
- fidelity_helper-0.1.0/src/fidelity_helper/__init__.py +27 -0
- fidelity_helper-0.1.0/src/fidelity_helper/fidelity.py +340 -0
- fidelity_helper-0.1.0/src/fidelity_helper/fidelity_models.py +104 -0
- fidelity_helper-0.1.0/src/fidelity_helper/py.typed +0 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2025 Theodore H. Romer
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fidelity-helper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fetch Fidelity transactions
|
|
5
|
+
Project-URL: Homepage, https://github.com/righteffort/finance-helper/tree/main/python/fidelity-helper
|
|
6
|
+
Project-URL: Source, https://github.com/righteffort/finance-helper.git
|
|
7
|
+
Project-URL: Documentation, https://righteffort.github.io/finance-helper/python/fidelity-helper
|
|
8
|
+
Project-URL: Issues, https://github.com/righteffort/finance-helper/issues
|
|
9
|
+
Author-email: Ted Romer <tromer@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: banking,fidelity,finance
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Requires-Dist: pydantic>=2.12.5
|
|
17
|
+
Requires-Dist: pystache>=0.6.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# fidelity-helper
|
|
21
|
+
|
|
22
|
+
Retrieve Fidelity transaction data via fetch calls to fidelity.com in the context of a browser.
|
|
23
|
+
|
|
24
|
+
[API documentation](https://righteffort.github.io/finance-helper/python/fidelity-helper)
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from datetime import date
|
|
30
|
+
from fidelity_helper import Fidelity
|
|
31
|
+
|
|
32
|
+
# In this example the callback assumes page.evaluate is provided by your browser
|
|
33
|
+
# automation framework.
|
|
34
|
+
fidelity = Fidelity(lambda expr: page.evaluate(expr, await_promise=True))
|
|
35
|
+
|
|
36
|
+
# Get transactions for specific accounts and date range
|
|
37
|
+
transactions = await fidelity.get_transactions(
|
|
38
|
+
accounts=["123456789", "987654321"], start=date(2024, 1, 1), end=date(2024, 1, 31)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
for t in transactions:
|
|
42
|
+
print(f"{t.date}\t${t.amount}\t{t.description}")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
See [fidelity_example.py](https://github.com/righteffort/finance-helper/tree/main/python/fidelity-example/fidelity_example.py) for a working example.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# fidelity-helper
|
|
2
|
+
|
|
3
|
+
Retrieve Fidelity transaction data via fetch calls to fidelity.com in the context of a browser.
|
|
4
|
+
|
|
5
|
+
[API documentation](https://righteffort.github.io/finance-helper/python/fidelity-helper)
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from datetime import date
|
|
11
|
+
from fidelity_helper import Fidelity
|
|
12
|
+
|
|
13
|
+
# In this example the callback assumes page.evaluate is provided by your browser
|
|
14
|
+
# automation framework.
|
|
15
|
+
fidelity = Fidelity(lambda expr: page.evaluate(expr, await_promise=True))
|
|
16
|
+
|
|
17
|
+
# Get transactions for specific accounts and date range
|
|
18
|
+
transactions = await fidelity.get_transactions(
|
|
19
|
+
accounts=["123456789", "987654321"], start=date(2024, 1, 1), end=date(2024, 1, 31)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
for t in transactions:
|
|
23
|
+
print(f"{t.date}\t${t.amount}\t{t.description}")
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
See [fidelity_example.py](https://github.com/righteffort/finance-helper/tree/main/python/fidelity-example/fidelity_example.py) for a working example.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"headers": {
|
|
3
|
+
"accept": "*/*",
|
|
4
|
+
"accept-language": "en-US,en;q=0.9",
|
|
5
|
+
"apollographql-client-version": "0.0.0",
|
|
6
|
+
"cache-control": "no-cache",
|
|
7
|
+
"content-type": "application/json",
|
|
8
|
+
"pragma": "no-cache",
|
|
9
|
+
"priority": "u=1, i",
|
|
10
|
+
"sec-ch-ua": "\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"",
|
|
11
|
+
"sec-ch-ua-mobile": "?0",
|
|
12
|
+
"sec-ch-ua-platform": "\"Chrome OS\"",
|
|
13
|
+
"sec-fetch-dest": "empty",
|
|
14
|
+
"sec-fetch-mode": "cors",
|
|
15
|
+
"sec-fetch-site": "same-origin"
|
|
16
|
+
},
|
|
17
|
+
"referrer": "https://digital.fidelity.com/ftgw/digital/portfolio/summary",
|
|
18
|
+
"body": "{\"operationName\":\"GetContext\",\"variables\":{},\"query\":\"query GetContext {\\n getContext {\\n sysStatus {\\n backend {\\n account\\n __typename\\n }\\n __typename\\n }\\n person {\\n assets {\\n acctNum\\n acctType\\n preferenceDetail {\\n name\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n __typename\\n }\\n}\\n\"}",
|
|
19
|
+
"method": "POST",
|
|
20
|
+
"mode": "cors",
|
|
21
|
+
"credentials": "include"
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"operationName": "getTransactions",
|
|
3
|
+
"variables": {
|
|
4
|
+
"isNewOrderApi": true,
|
|
5
|
+
"isSupportCrypto": true,
|
|
6
|
+
"hideDCOrders": false,
|
|
7
|
+
"acctIdList": "{{#accounts}}{{number}}{{^last}},{{/last}}{{/accounts}}",
|
|
8
|
+
"acctDetailList": [{{#accounts}}
|
|
9
|
+
{
|
|
10
|
+
"acctNum": "{{number}}",
|
|
11
|
+
"acctType": "Brokerage",
|
|
12
|
+
"name": "{{name}}"
|
|
13
|
+
}{{^last}},{{/last}}{{/accounts}}
|
|
14
|
+
],
|
|
15
|
+
"searchCriteriaDetail": {
|
|
16
|
+
"timePeriod": 30,
|
|
17
|
+
"txnCat": null,
|
|
18
|
+
"viewType": "NON_CORE",
|
|
19
|
+
"histSortDir": "D",
|
|
20
|
+
"acctHistSort": "DATE",
|
|
21
|
+
"hasBasketName": true,
|
|
22
|
+
"txnFromDate": "{{start}}",
|
|
23
|
+
"txnToDate": "{{end}}"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"query": "query getTransactions($acctIdList: String, $acctDetailList: [AcctDetailList], $searchCriteriaDetail: SearchCriteriaDetail, $isNewOrderApi: Boolean! = false, $isSupportCrypto: Boolean! = false, $hideDCOrders: Boolean! = true) {\n getTransactions(\n acctIdList: $acctIdList\n acctDetailList: $acctDetailList\n searchCriteriaDetail: $searchCriteriaDetail\n isNewOrderApi: $isNewOrderApi\n isSupportCrypto: $isSupportCrypto\n hideDCOrders: $hideDCOrders\n ) {\n backendStatus {\n history\n __typename\n }\n historys {\n acctNum\n orderNumber\n intradayInd\n description\n date\n amount\n txnTypeCode\n txnCatCode\n txnCatDesc\n txnSubCatCode\n cashBalance\n __typename\n }\n __typename\n }\n}"
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"headers": {
|
|
3
|
+
"accept": "*/*",
|
|
4
|
+
"accept-language": "en-US,en;q=0.9",
|
|
5
|
+
"apollographql-client-name": "activity",
|
|
6
|
+
"apollographql-client-version": "0.0.1",
|
|
7
|
+
"cache-control": "no-cache",
|
|
8
|
+
"content-type": "application/json",
|
|
9
|
+
"pragma": "no-cache",
|
|
10
|
+
"priority": "u=1, i",
|
|
11
|
+
"sec-ch-ua": "\"Chromium\";v=\"142\", \"Google Chrome\";v=\"142\", \"Not_A Brand\";v=\"99\"",
|
|
12
|
+
"sec-ch-ua-mobile": "?0",
|
|
13
|
+
"sec-ch-ua-platform": "\"Chrome OS\"",
|
|
14
|
+
"sec-fetch-dest": "empty",
|
|
15
|
+
"sec-fetch-mode": "cors",
|
|
16
|
+
"sec-fetch-site": "same-origin"
|
|
17
|
+
},
|
|
18
|
+
"referrer": "https://digital.fidelity.com/ftgw/digital/portfolio/activity",
|
|
19
|
+
"body": {{{body}}},
|
|
20
|
+
"method": "POST",
|
|
21
|
+
"mode": "cors",
|
|
22
|
+
"credentials": "include"
|
|
23
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fidelity-helper"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fetch Fidelity transactions"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
license-files = ["LICENSE"]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Ted Romer", email = "tromer@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
keywords = [ "banking", "fidelity", "finance"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
dependencies = [
|
|
19
|
+
"pydantic>=2.12.5",
|
|
20
|
+
"pystache>=0.6.8",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/righteffort/finance-helper/tree/main/python/fidelity-helper"
|
|
25
|
+
Source = "https://github.com/righteffort/finance-helper.git"
|
|
26
|
+
Documentation = "https://righteffort.github.io/finance-helper/python/fidelity-helper"
|
|
27
|
+
Issues = "https://github.com/righteffort/finance-helper/issues"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"aiofiles>=25.1.0",
|
|
32
|
+
"basedpyright>0",
|
|
33
|
+
"pdoc>=16.0.0",
|
|
34
|
+
"pytest>0",
|
|
35
|
+
"pytest-asyncio>=1.3.0",
|
|
36
|
+
"ruff>0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/fidelity_helper"]
|
|
45
|
+
exclude = ["fidelity-templates"]
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.sdist]
|
|
48
|
+
include = [
|
|
49
|
+
"/src",
|
|
50
|
+
]
|
|
51
|
+
exclude = [
|
|
52
|
+
"/src/fidelity_helper/fidelity-templates"
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[tool.hatch.build.targets.wheel.force-include]
|
|
56
|
+
"../../fidelity-templates" = "fidelity_helper/fidelity-templates"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
59
|
+
"../../fidelity-templates" = "fidelity_helper/fidelity-templates"
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
addopts = [
|
|
63
|
+
"--import-mode=importlib",
|
|
64
|
+
"--strict-markers",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
[tool.pyright]
|
|
68
|
+
typeCheckingMode = "recommended"
|
|
69
|
+
reportUnannotatedClassAttribute = false
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint]
|
|
72
|
+
|
|
73
|
+
select = [
|
|
74
|
+
"ALL",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
ignore = [
|
|
78
|
+
"COM812", # missing-trailing-comma -- let the formatter deal with it
|
|
79
|
+
"D203", # incorrect-blank-line-before-class
|
|
80
|
+
"D213", # multi-line-summary-second-line
|
|
81
|
+
"DTZ007", # Naive datetime constructed using `datetime.datetime.strptime()` without %z
|
|
82
|
+
"FIX002", # line-contains-todo
|
|
83
|
+
"SLF001", # Private member accessed -- handled by basedpyright
|
|
84
|
+
"TC006", # runtime-cast-value
|
|
85
|
+
"TD002", # missing-todo-author
|
|
86
|
+
"TD003", # missing-todo-link
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
[tool.ruff.lint.extend-per-file-ignores]
|
|
90
|
+
"tests/**/*.py" = ["D", "PLR2004", "S101"]
|
|
91
|
+
|
|
92
|
+
[tool.ruff.format]
|
|
93
|
+
docstring-code-format = true
|
|
94
|
+
skip-magic-trailing-comma = true
|
|
95
|
+
|
|
96
|
+
[tool.ruff.lint.isort]
|
|
97
|
+
split-on-trailing-comma = false
|
|
98
|
+
lines-after-imports = 2
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
r"""Retrieve Fidelity transaction data via fetch calls to fidelity.com.
|
|
2
|
+
|
|
3
|
+
## Usage
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from datetime import date
|
|
7
|
+
from fidelity_helper import Fidelity
|
|
8
|
+
|
|
9
|
+
# In this example the callback assumes page.evaluate is provided by your browser
|
|
10
|
+
# automation framework.
|
|
11
|
+
fidelity = Fidelity(lambda expr: page.evaluate(expr, await_promise=True))
|
|
12
|
+
|
|
13
|
+
# Get transactions for specific accounts and date range
|
|
14
|
+
transactions = await fidelity.get_transactions(
|
|
15
|
+
accounts=["123456789", "987654321"], start=date(2024, 1, 1), end=date(2024, 1, 31)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
for t in transactions:
|
|
19
|
+
print(f"{t.date}\t${t.amount}\t{t.description}")
|
|
20
|
+
```
|
|
21
|
+
See [fidelity_example.py](https://github.com/righteffort/finance-helper/python/fidelity-example/fidelity_example.py) for a working example.
|
|
22
|
+
""" # noqa: E501
|
|
23
|
+
|
|
24
|
+
from .fidelity import Account, Fidelity, FidelityError, Transaction
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = ["Transaction", "Account", "Fidelity", "FidelityError"] # noqa: RUF022
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Retrieve transactions from fidelity.com via browswer fetch calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import date, datetime
|
|
10
|
+
from http import HTTPStatus
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
|
13
|
+
from zoneinfo import ZoneInfo
|
|
14
|
+
|
|
15
|
+
from .fidelity_models import (
|
|
16
|
+
GetAccountsRespModel,
|
|
17
|
+
GetTransactionsReqModel,
|
|
18
|
+
GetTransactionsRespHistoryModel,
|
|
19
|
+
GetTransactionsRespModel,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
from types import CoroutineType
|
|
26
|
+
|
|
27
|
+
import pystache # pyright: ignore[reportMissingTypeStubs]
|
|
28
|
+
from pydantic import ValidationError
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FidelityError(Exception):
|
|
32
|
+
"""Raised on any error specific to `Fidelity` class."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Account:
|
|
37
|
+
"""Fidelity account."""
|
|
38
|
+
|
|
39
|
+
number: str
|
|
40
|
+
"""Fidelity account number."""
|
|
41
|
+
name: str
|
|
42
|
+
"""Fidelity account name selected by the account owner."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Transaction:
|
|
47
|
+
"""Fidelity transaction."""
|
|
48
|
+
|
|
49
|
+
acct_num: str
|
|
50
|
+
"""Fidelity account number."""
|
|
51
|
+
date: date
|
|
52
|
+
"""Transaction date. Should be interpreted as midnight America/New_York."""
|
|
53
|
+
description: str
|
|
54
|
+
"""Transaction description."""
|
|
55
|
+
amount: float
|
|
56
|
+
"""Transaction amount in dollars."""
|
|
57
|
+
order_number: str | None
|
|
58
|
+
"""Transaction identifier. Unique for this account. `None` only if pending."""
|
|
59
|
+
pending: bool
|
|
60
|
+
"""True iff transaction is pending."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Fidelity:
|
|
64
|
+
"""Retrieve transactions from fidelity.com via browser fetch calls."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
evaluator: Callable[[str], CoroutineType[Any, Any, Any]], # pyright: ignore[reportExplicitAny]
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Initialize new instance.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
evaluator: async callback that calls evaluate in context of the browser.
|
|
74
|
+
|
|
75
|
+
"""
|
|
76
|
+
self._accounts: dict[str, Account] | None = None
|
|
77
|
+
self._evaluator = evaluator
|
|
78
|
+
|
|
79
|
+
async def get_transactions(
|
|
80
|
+
self, accounts: list[str], start: date, end: date
|
|
81
|
+
) -> list[Transaction]:
|
|
82
|
+
"""Retrieve transactions for the logged in user.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
accounts: list of Fidelity account numbers to retrieve.
|
|
86
|
+
start: start date, inclusive. Treated as midnight America/New_York.
|
|
87
|
+
end: end date, inclusive. Treated as midnight America/New_York.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Transactions for accounts between [start, end].
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
FidelityError: if there is any error retrieving the transactions.
|
|
94
|
+
|
|
95
|
+
"""
|
|
96
|
+
acct_dict = {a: await self._get_account(a) for a in accounts}
|
|
97
|
+
missing = [a for a, acct in acct_dict.items() if acct is None]
|
|
98
|
+
if missing:
|
|
99
|
+
msg = f"Account(s) not found: {', '.join(missing)}"
|
|
100
|
+
raise FidelityError(msg)
|
|
101
|
+
accts = [a for a in acct_dict.values() if a is not None]
|
|
102
|
+
|
|
103
|
+
resp_json = await self._fetch(
|
|
104
|
+
"https://digital.fidelity.com/ftgw/digital/webactivity/api/graphql?ref_at=activity",
|
|
105
|
+
json.dumps(Fidelity.get_transactions_options(accts, start, end)),
|
|
106
|
+
)
|
|
107
|
+
try:
|
|
108
|
+
historys = GetTransactionsRespModel.model_validate(
|
|
109
|
+
resp_json
|
|
110
|
+
).data.getTransactions.historys
|
|
111
|
+
except ValidationError as e:
|
|
112
|
+
msg = "Failed to parse get_transactions response"
|
|
113
|
+
raise FidelityError(msg) from e
|
|
114
|
+
return [Fidelity._history_entry_to_transaction(h) for h in historys]
|
|
115
|
+
|
|
116
|
+
async def get_accounts(self) -> list[Account]:
|
|
117
|
+
"""Retrieve accounts for the logged in user.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
User's accounts.
|
|
121
|
+
|
|
122
|
+
Raises:
|
|
123
|
+
FidelityError: if there is any error retrieving the transactions.
|
|
124
|
+
|
|
125
|
+
"""
|
|
126
|
+
return list((await self._get_accounts()).values())
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def get_transactions_options(
|
|
130
|
+
accounts: list[Account], start: date, end: date
|
|
131
|
+
) -> dict[str, object]:
|
|
132
|
+
"""Return the [options](https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#options) argument for calling fetch to get transactions.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
accounts: list of Fidelity accounts to retrieve.
|
|
136
|
+
start: start date, inclusive. Treated as midnight America/New_York.
|
|
137
|
+
end: end date, inclusive. Treated as midnight America/New_York.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
JSON-compatible object options argument for fetch call.
|
|
141
|
+
|
|
142
|
+
""" # noqa: E501
|
|
143
|
+
context = {
|
|
144
|
+
"body": json.dumps(
|
|
145
|
+
Fidelity._get_transactions_body(accounts, start, end).model_dump_json()
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
return cast(
|
|
149
|
+
dict[str, object],
|
|
150
|
+
json.loads(
|
|
151
|
+
cast(
|
|
152
|
+
str,
|
|
153
|
+
pystache.render( # pyright: ignore[reportUnknownMemberType]
|
|
154
|
+
Fidelity._get_transactions_options_template(), context
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def get_accounts_options() -> dict[str, object]:
|
|
162
|
+
"""Return the <a href="https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch#options" target="_blank">options</a> argument for calling fetch to get accounts.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
JSON-compatible object options argument for fetch call.
|
|
166
|
+
|
|
167
|
+
""" # noqa: E501
|
|
168
|
+
return cast(
|
|
169
|
+
dict[str, object],
|
|
170
|
+
json.loads(
|
|
171
|
+
files("fidelity_helper")
|
|
172
|
+
.joinpath("fidelity-templates/getAccountsOptions.json")
|
|
173
|
+
.read_text()
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
_FIDELITY_ZONE = ZoneInfo("America/New_York")
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def fidelity_date(d: date) -> int:
|
|
181
|
+
"""Convert date to epoch seconds in Fidelity time zone (America/New_York).
|
|
182
|
+
|
|
183
|
+
Arguments:
|
|
184
|
+
d(date): date to convert.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Epoch seconds for d interpreted as America/New_York at midnight.
|
|
188
|
+
|
|
189
|
+
"""
|
|
190
|
+
return int(
|
|
191
|
+
datetime.combine(
|
|
192
|
+
d, datetime.min.time(), Fidelity._FIDELITY_ZONE
|
|
193
|
+
).timestamp()
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def encode_account_name(name: str) -> str:
|
|
198
|
+
"""Encode name for use in Fidelity get transactions.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
name: the plain-text name.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
The encoded name.
|
|
205
|
+
|
|
206
|
+
"""
|
|
207
|
+
return base64.b64encode(name.encode("utf-8")).decode("ascii")
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def print_get_transactions_options(options: dict[str, object]) -> str:
|
|
211
|
+
"""Human-friendly rendering of options used to get transactions.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
options: JSON-compatible object for fetch options arg.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
Printable string with embedded JSON strings parsed into objects.
|
|
218
|
+
|
|
219
|
+
"""
|
|
220
|
+
options_minus_body = cast(dict[str, object], json.loads(json.dumps(options)))
|
|
221
|
+
del options_minus_body["body"]
|
|
222
|
+
body = cast(dict[str, object], json.loads(cast(str, options["body"])))
|
|
223
|
+
body_minus_query = cast(dict[str, object], json.loads(json.dumps(body)))
|
|
224
|
+
del body_minus_query["query"]
|
|
225
|
+
query = cast(str, body["query"])
|
|
226
|
+
return "\n".join(
|
|
227
|
+
[
|
|
228
|
+
f"options_minus_body={json.dumps(options_minus_body, indent=2)}",
|
|
229
|
+
f"body_minus_query={json.dumps(body_minus_query, indent=2)}",
|
|
230
|
+
"query=" + query,
|
|
231
|
+
]
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def _fetch(self, url: str, options_str: str) -> dict[str, object]:
|
|
235
|
+
fetch = f'fetch("{url}", {options_str})'
|
|
236
|
+
script = f"""
|
|
237
|
+
(async () => {{
|
|
238
|
+
const r = await {fetch};
|
|
239
|
+
if (!r.ok) {{
|
|
240
|
+
return {{ status: r.status, text: await r.text() }}
|
|
241
|
+
}}
|
|
242
|
+
const json = await r.json()
|
|
243
|
+
return {{ status: r.status, json: json }};
|
|
244
|
+
}})()
|
|
245
|
+
"""
|
|
246
|
+
resp = cast(_FetchResp, await self._evaluator(script))
|
|
247
|
+
if resp["status"] != HTTPStatus.OK:
|
|
248
|
+
msg = (
|
|
249
|
+
"get_transactions fetch failed, code: ",
|
|
250
|
+
f"{resp['status']}, text: {resp.get('text')}",
|
|
251
|
+
)
|
|
252
|
+
raise FidelityError(msg)
|
|
253
|
+
return resp["json"]
|
|
254
|
+
|
|
255
|
+
async def _get_account(self, account: str) -> Account | None:
|
|
256
|
+
return (await self._get_accounts()).get(account, None)
|
|
257
|
+
|
|
258
|
+
async def _get_accounts(self) -> dict[str, Account]:
|
|
259
|
+
if self._accounts is not None:
|
|
260
|
+
return self._accounts
|
|
261
|
+
self._accounts = await self._fetch_accounts()
|
|
262
|
+
return self._accounts
|
|
263
|
+
|
|
264
|
+
async def _fetch_accounts(self) -> dict[str, Account]:
|
|
265
|
+
resp_json = await self._fetch(
|
|
266
|
+
"https://digital.fidelity.com/ftgw/digital/portfolio/api/graphql?ref_at=portsum",
|
|
267
|
+
json.dumps(Fidelity.get_accounts_options()),
|
|
268
|
+
)
|
|
269
|
+
try:
|
|
270
|
+
resp = GetAccountsRespModel.model_validate(resp_json).data.getContext
|
|
271
|
+
except ValidationError as e:
|
|
272
|
+
msg = "Failed to parse get_accounts response"
|
|
273
|
+
raise FidelityError(msg) from e
|
|
274
|
+
status = resp.sysStatus.backend.account
|
|
275
|
+
if status.lower() != "ok":
|
|
276
|
+
msg = f"Fidelity backend not ok {status}"
|
|
277
|
+
raise FidelityError(msg)
|
|
278
|
+
accounts = [
|
|
279
|
+
Account(number=a.acctNum, name=a.preferenceDetail.name)
|
|
280
|
+
for a in resp.person.assets
|
|
281
|
+
]
|
|
282
|
+
return {a.number: a for a in accounts}
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def _get_transactions_body(
|
|
286
|
+
accounts: list[Account], start: date, end: date
|
|
287
|
+
) -> GetTransactionsReqModel:
|
|
288
|
+
account_dicts: list[dict[str, str | bool]] = [
|
|
289
|
+
{
|
|
290
|
+
"number": a.number,
|
|
291
|
+
"name": Fidelity.encode_account_name(a.name),
|
|
292
|
+
"last": i == len(accounts) - 1,
|
|
293
|
+
}
|
|
294
|
+
for i, a in enumerate(accounts)
|
|
295
|
+
]
|
|
296
|
+
context = {
|
|
297
|
+
"accounts": account_dicts,
|
|
298
|
+
"start": str(Fidelity.fidelity_date(start)),
|
|
299
|
+
"end": str(Fidelity.fidelity_date(end)),
|
|
300
|
+
}
|
|
301
|
+
body = cast(
|
|
302
|
+
str,
|
|
303
|
+
pystache.render(Fidelity._get_transactions_body_template(), context), # pyright: ignore[reportUnknownMemberType]
|
|
304
|
+
)
|
|
305
|
+
return GetTransactionsReqModel.model_validate_json(body)
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
def _get_transactions_body_template() -> str:
|
|
309
|
+
return (
|
|
310
|
+
files("fidelity_helper")
|
|
311
|
+
.joinpath("fidelity-templates/getTransactionsBody.json.mustache")
|
|
312
|
+
.read_text()
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _get_transactions_options_template() -> str:
|
|
317
|
+
return (
|
|
318
|
+
files("fidelity_helper")
|
|
319
|
+
.joinpath("fidelity-templates/getTransactionsOptions.json.mustache")
|
|
320
|
+
.read_text()
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _history_entry_to_transaction(
|
|
325
|
+
h: GetTransactionsRespHistoryModel,
|
|
326
|
+
) -> Transaction:
|
|
327
|
+
return Transaction(
|
|
328
|
+
acct_num=h.acctNum,
|
|
329
|
+
date=datetime.strptime(h.date, "%b-%d-%Y").date(),
|
|
330
|
+
description=h.description,
|
|
331
|
+
amount=round(float(re.sub(r"[,$]", r"", h.amount)), 2),
|
|
332
|
+
order_number=h.orderNumber,
|
|
333
|
+
pending=bool(h.intradayInd),
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class _FetchResp(TypedDict):
|
|
338
|
+
status: int
|
|
339
|
+
text: str
|
|
340
|
+
json: dict[str, Any] # pyright: ignore[reportExplicitAny]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Fidelity request and response objects. Use with caution."""
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: N815 # mixed-case-variable-in-class-scope
|
|
4
|
+
# ruff: noqa: D101 # Missing docstring in public class
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AcctDetailModel(BaseModel):
|
|
10
|
+
acctNum: str
|
|
11
|
+
acctType: str
|
|
12
|
+
name: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SearchCriteriaDetailModel(BaseModel):
|
|
16
|
+
timePeriod: int
|
|
17
|
+
txnCat: None
|
|
18
|
+
viewType: str
|
|
19
|
+
histSortDir: str
|
|
20
|
+
acctHistSort: str
|
|
21
|
+
hasBasketName: bool
|
|
22
|
+
txnFromDate: str
|
|
23
|
+
txnToDate: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GetTransactionsReqVarsModel(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
28
|
+
isNewOrderApi: bool
|
|
29
|
+
isSupportCrypto: bool
|
|
30
|
+
hideDCOrders: bool
|
|
31
|
+
acctIdList: str
|
|
32
|
+
acctDetailList: list[AcctDetailModel]
|
|
33
|
+
searchCriteriaDetail: SearchCriteriaDetailModel
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GetTransactionsReqModel(BaseModel):
|
|
37
|
+
"""Fidelity get_transactions request."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(extra="forbid", strict=True)
|
|
40
|
+
operationName: str
|
|
41
|
+
variables: GetTransactionsReqVarsModel
|
|
42
|
+
query: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GetTransactionsRespHistoryModel(BaseModel):
|
|
46
|
+
"""Single transaction in Fidelity response to get_transactions."""
|
|
47
|
+
|
|
48
|
+
acctNum: str
|
|
49
|
+
amount: str
|
|
50
|
+
date: str
|
|
51
|
+
description: str
|
|
52
|
+
intradayInd: bool
|
|
53
|
+
orderNumber: str | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GetTransactionsRespTransactionsModel(BaseModel):
|
|
57
|
+
historys: list[GetTransactionsRespHistoryModel]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GetTransactionsRespDataModel(BaseModel):
|
|
61
|
+
getTransactions: GetTransactionsRespTransactionsModel
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GetTransactionsRespModel(BaseModel):
|
|
65
|
+
"""Fidelity response to get_transactions."""
|
|
66
|
+
|
|
67
|
+
data: GetTransactionsRespDataModel
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class BackendStatusModel(BaseModel):
|
|
71
|
+
account: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class SysStatusModel(BaseModel):
|
|
75
|
+
backend: BackendStatusModel
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PreferenceDetailModel(BaseModel):
|
|
79
|
+
name: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class GetAccountsRespAcctDetailModel(BaseModel):
|
|
83
|
+
acctNum: str
|
|
84
|
+
acctType: str
|
|
85
|
+
preferenceDetail: PreferenceDetailModel
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PersonModel(BaseModel):
|
|
89
|
+
assets: list[GetAccountsRespAcctDetailModel]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GetAccountsContextModel(BaseModel):
|
|
93
|
+
sysStatus: SysStatusModel
|
|
94
|
+
person: PersonModel
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GetAccountsRespDataModel(BaseModel):
|
|
98
|
+
getContext: GetAccountsContextModel
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GetAccountsRespModel(BaseModel):
|
|
102
|
+
"""Fidelity response to get_accounts."""
|
|
103
|
+
|
|
104
|
+
data: GetAccountsRespDataModel
|
|
File without changes
|