ibflex 1.1__tar.gz → 1.2__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.
- {ibflex-1.1 → ibflex-1.2}/PKG-INFO +30 -1
- {ibflex-1.1 → ibflex-1.2}/README.rst +29 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex/Types.py +3 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex/__version__.py +1 -1
- {ibflex-1.1 → ibflex-1.2}/ibflex/client.py +80 -10
- {ibflex-1.1 → ibflex-1.2}/ibflex/enums.py +14 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex/parser.py +18 -7
- {ibflex-1.1 → ibflex-1.2}/ibflex.egg-info/PKG-INFO +30 -1
- {ibflex-1.1 → ibflex-1.2}/pyproject.toml +1 -1
- {ibflex-1.1 → ibflex-1.2}/tests/test_client.py +90 -1
- {ibflex-1.1 → ibflex-1.2}/tests/test_parser.py +7 -3
- {ibflex-1.1 → ibflex-1.2}/LICENSE.txt +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex/__init__.py +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex/py.typed +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex/utils.py +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex.egg-info/SOURCES.txt +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex.egg-info/dependency_links.txt +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex.egg-info/entry_points.txt +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex.egg-info/requires.txt +0 -0
- {ibflex-1.1 → ibflex-1.2}/ibflex.egg-info/top_level.txt +0 -0
- {ibflex-1.1 → ibflex-1.2}/setup.cfg +0 -0
- {ibflex-1.1 → ibflex-1.2}/tests/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ibflex
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2
|
|
4
4
|
Summary: Parse Interactive Brokers Flex XML reports and convert to Python types
|
|
5
5
|
Author-email: Christopher Singley <csingley@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -148,6 +148,16 @@ them:
|
|
|
148
148
|
In [5]: response[:215]
|
|
149
149
|
Out[5]: b'<FlexQueryResponse queryName="Get Everything" type="AF">\n<FlexStatements count="1">\n<FlexStatement accountId="U111111" fromDate="2018-01-01" toDate="2018-01-31" period="LastMonth" whenGenerated="2018-02-01;211353">\n'
|
|
150
150
|
|
|
151
|
+
You can override the report's date range per request with ``period`` (the
|
|
152
|
+
``p`` parameter - a lookback in days, up to 365) or ``fd``/``td`` (explicit
|
|
153
|
+
``yyyymmdd`` from/to dates, given together); the two forms are mutually
|
|
154
|
+
exclusive. This is useful because the v3 Flex Web Service may not honor a
|
|
155
|
+
query's saved "Last N Calendar Days" period:
|
|
156
|
+
|
|
157
|
+
.. code:: python
|
|
158
|
+
|
|
159
|
+
In [6]: response = client.download(token, query_id, period=90)
|
|
160
|
+
|
|
151
161
|
|
|
152
162
|
``pip install ibflex[web]`` also installs a ``flexget`` console script:
|
|
153
163
|
|
|
@@ -156,6 +166,24 @@ them:
|
|
|
156
166
|
$ flexget -t 111111111111111111111111 -q 111111 > 2018-01_ibkr.xml
|
|
157
167
|
|
|
158
168
|
|
|
169
|
+
Releasing
|
|
170
|
+
=========
|
|
171
|
+
Releases are published to PyPI automatically via GitHub Actions using
|
|
172
|
+
`trusted publishing`_ (OIDC). No API tokens are needed.
|
|
173
|
+
|
|
174
|
+
To cut a release:
|
|
175
|
+
|
|
176
|
+
1. Bump the version in ``pyproject.toml`` and ``ibflex/__version__.py``.
|
|
177
|
+
2. Commit, push to ``master``.
|
|
178
|
+
3. Create a GitHub release with a tag matching the version (e.g. ``v1.1``).
|
|
179
|
+
|
|
180
|
+
The ``Publish to PyPI`` workflow will build and upload the package.
|
|
181
|
+
|
|
182
|
+
Any repository collaborator who can create a GitHub release **and** has access
|
|
183
|
+
to the ``pypi`` deployment environment can publish. Environment access is
|
|
184
|
+
managed under Settings → Environments → pypi.
|
|
185
|
+
|
|
186
|
+
|
|
159
187
|
Resources
|
|
160
188
|
=========
|
|
161
189
|
* Interactive Brokers `Activity Flex Query Reference`_
|
|
@@ -168,5 +196,6 @@ Resources
|
|
|
168
196
|
.. _Interactive Brokers account management: https://www.interactivebrokers.com/sso/Login
|
|
169
197
|
.. _Activity Flex Query Reference: https://www.interactivebrokers.com/en/software/reportguide/reportguide.htm#reportguide/activity_flex_query_reference.htm
|
|
170
198
|
.. _FlexWeb Service Reference: https://www.interactivebrokers.com/en/software/am/am/reports/flex_web_service_version_3.htm
|
|
199
|
+
.. _trusted publishing: https://docs.pypi.org/trusted-publishers/
|
|
171
200
|
.. _capgains: https://github.com/csingley/capgains
|
|
172
201
|
.. _ib-flex-analyzer: https://github.com/wesm/ib-flex-analyzer
|
|
@@ -116,6 +116,16 @@ them:
|
|
|
116
116
|
In [5]: response[:215]
|
|
117
117
|
Out[5]: b'<FlexQueryResponse queryName="Get Everything" type="AF">\n<FlexStatements count="1">\n<FlexStatement accountId="U111111" fromDate="2018-01-01" toDate="2018-01-31" period="LastMonth" whenGenerated="2018-02-01;211353">\n'
|
|
118
118
|
|
|
119
|
+
You can override the report's date range per request with ``period`` (the
|
|
120
|
+
``p`` parameter - a lookback in days, up to 365) or ``fd``/``td`` (explicit
|
|
121
|
+
``yyyymmdd`` from/to dates, given together); the two forms are mutually
|
|
122
|
+
exclusive. This is useful because the v3 Flex Web Service may not honor a
|
|
123
|
+
query's saved "Last N Calendar Days" period:
|
|
124
|
+
|
|
125
|
+
.. code:: python
|
|
126
|
+
|
|
127
|
+
In [6]: response = client.download(token, query_id, period=90)
|
|
128
|
+
|
|
119
129
|
|
|
120
130
|
``pip install ibflex[web]`` also installs a ``flexget`` console script:
|
|
121
131
|
|
|
@@ -124,6 +134,24 @@ them:
|
|
|
124
134
|
$ flexget -t 111111111111111111111111 -q 111111 > 2018-01_ibkr.xml
|
|
125
135
|
|
|
126
136
|
|
|
137
|
+
Releasing
|
|
138
|
+
=========
|
|
139
|
+
Releases are published to PyPI automatically via GitHub Actions using
|
|
140
|
+
`trusted publishing`_ (OIDC). No API tokens are needed.
|
|
141
|
+
|
|
142
|
+
To cut a release:
|
|
143
|
+
|
|
144
|
+
1. Bump the version in ``pyproject.toml`` and ``ibflex/__version__.py``.
|
|
145
|
+
2. Commit, push to ``master``.
|
|
146
|
+
3. Create a GitHub release with a tag matching the version (e.g. ``v1.1``).
|
|
147
|
+
|
|
148
|
+
The ``Publish to PyPI`` workflow will build and upload the package.
|
|
149
|
+
|
|
150
|
+
Any repository collaborator who can create a GitHub release **and** has access
|
|
151
|
+
to the ``pypi`` deployment environment can publish. Environment access is
|
|
152
|
+
managed under Settings → Environments → pypi.
|
|
153
|
+
|
|
154
|
+
|
|
127
155
|
Resources
|
|
128
156
|
=========
|
|
129
157
|
* Interactive Brokers `Activity Flex Query Reference`_
|
|
@@ -136,5 +164,6 @@ Resources
|
|
|
136
164
|
.. _Interactive Brokers account management: https://www.interactivebrokers.com/sso/Login
|
|
137
165
|
.. _Activity Flex Query Reference: https://www.interactivebrokers.com/en/software/reportguide/reportguide.htm#reportguide/activity_flex_query_reference.htm
|
|
138
166
|
.. _FlexWeb Service Reference: https://www.interactivebrokers.com/en/software/am/am/reports/flex_web_service_version_3.htm
|
|
167
|
+
.. _trusted publishing: https://docs.pypi.org/trusted-publishers/
|
|
139
168
|
.. _capgains: https://github.com/csingley/capgains
|
|
140
169
|
.. _ib-flex-analyzer: https://github.com/wesm/ib-flex-analyzer
|
|
@@ -485,6 +485,7 @@ class MTDYTDPerformanceSummaryUnderlying(FlexElement):
|
|
|
485
485
|
"""Wrapped in <MTDYTDPerformanceSummary>"""
|
|
486
486
|
|
|
487
487
|
assetCategory: enums.AssetClass | None = None
|
|
488
|
+
subCategory: enums.SubCategory | None = None
|
|
488
489
|
accountId: str | None = None
|
|
489
490
|
acctAlias: str | None = None
|
|
490
491
|
model: str | None = None
|
|
@@ -494,6 +495,7 @@ class MTDYTDPerformanceSummaryUnderlying(FlexElement):
|
|
|
494
495
|
securityID: str | None = None
|
|
495
496
|
cusip: str | None = None
|
|
496
497
|
isin: str | None = None
|
|
498
|
+
figi: str | None = None
|
|
497
499
|
listingExchange: str | None = None
|
|
498
500
|
underlyingConid: str | None = None
|
|
499
501
|
underlyingSecurityID: str | None = None
|
|
@@ -2796,6 +2798,7 @@ class SalesTax(FlexElement):
|
|
|
2796
2798
|
securityIDType: str | None = None
|
|
2797
2799
|
cusip: str | None = None
|
|
2798
2800
|
isin: str | None = None
|
|
2801
|
+
figi: str | None = None
|
|
2799
2802
|
listingExchange: str | None = None
|
|
2800
2803
|
underlyingConid: str | None = None
|
|
2801
2804
|
underlyingSecurityID: str | None = None
|
|
@@ -17,7 +17,7 @@ __description__ = (
|
|
|
17
17
|
"Parse Interactive Brokers Flex XML reports and convert to Python types"
|
|
18
18
|
),
|
|
19
19
|
__url__ = "https://github.com/csingley/ibflex"
|
|
20
|
-
__version__ = "1.
|
|
20
|
+
__version__ = "1.2"
|
|
21
21
|
__author__ = "Christopher Singley"
|
|
22
22
|
__author_email__ = "csingley@gmail.com"
|
|
23
23
|
__license__ = "MIT"
|
|
@@ -17,7 +17,7 @@ import requests
|
|
|
17
17
|
# SERVICE LOCATIONS
|
|
18
18
|
###############################################################################
|
|
19
19
|
REQUEST_URL = 'https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/SendRequest'
|
|
20
|
-
STMT_URL = 'https://
|
|
20
|
+
STMT_URL = 'https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement'
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
###############################################################################
|
|
@@ -99,15 +99,34 @@ class StatementError:
|
|
|
99
99
|
###############################################################################
|
|
100
100
|
# FUNCTIONS
|
|
101
101
|
###############################################################################
|
|
102
|
-
def download(
|
|
102
|
+
def download(
|
|
103
|
+
token: str,
|
|
104
|
+
query_id: str,
|
|
105
|
+
max_tries: int | None = 5,
|
|
106
|
+
period: int | None = None,
|
|
107
|
+
fd: str | None = None,
|
|
108
|
+
td: str | None = None,
|
|
109
|
+
timeout: float = 5,
|
|
110
|
+
) -> bytes:
|
|
103
111
|
"""2-step FlexQueryReport download process.
|
|
104
112
|
|
|
105
113
|
Args:
|
|
106
114
|
token: Current access token from Reports > Settings > FlexWeb Service.
|
|
107
115
|
query_id: Flex Query ID from
|
|
108
116
|
Reports > Flex Queries > Custom Flex Queries > Configure.
|
|
117
|
+
max_tries: Number of times to poll for statement generation before
|
|
118
|
+
raising StatementGenerationTimeout.
|
|
119
|
+
period: Optional lookback override, in days (max 365), sent as the
|
|
120
|
+
``p`` SendRequest parameter.
|
|
121
|
+
fd, td: Optional explicit from/to date override (``yyyymmdd``, at most
|
|
122
|
+
365 days apart), sent as the ``fd``/``td`` SendRequest
|
|
123
|
+
parameters; must be given together. ``period`` and
|
|
124
|
+
``fd``/``td`` are mutually exclusive.
|
|
125
|
+
timeout: Seconds to wait for the first attempt of each HTTP request
|
|
109
126
|
"""
|
|
110
|
-
stmt_access = request_statement(
|
|
127
|
+
stmt_access = request_statement(
|
|
128
|
+
token, query_id, period=period, fd=fd, td=td, timeout=timeout
|
|
129
|
+
)
|
|
111
130
|
status = 0
|
|
112
131
|
tries = 0
|
|
113
132
|
while status is not True:
|
|
@@ -117,6 +136,7 @@ def download(token: str, query_id: str, max_tries: int | None = 5) -> bytes:
|
|
|
117
136
|
url=stmt_access.Url or STMT_URL,
|
|
118
137
|
token=token,
|
|
119
138
|
query=stmt_access.ReferenceCode,
|
|
139
|
+
timeout_increment=timeout,
|
|
120
140
|
)
|
|
121
141
|
status = check_statement_response(response)
|
|
122
142
|
if max_tries and tries > max_tries:
|
|
@@ -127,12 +147,24 @@ def download(token: str, query_id: str, max_tries: int | None = 5) -> bytes:
|
|
|
127
147
|
|
|
128
148
|
|
|
129
149
|
def request_statement(
|
|
130
|
-
token: str,
|
|
150
|
+
token: str,
|
|
151
|
+
query_id: str,
|
|
152
|
+
url: str | None = None,
|
|
153
|
+
period: int | None = None,
|
|
154
|
+
fd: str | None = None,
|
|
155
|
+
td: str | None = None,
|
|
156
|
+
timeout: float = 5,
|
|
131
157
|
) -> StatementAccess:
|
|
132
158
|
"""First part of the 2-step download process.
|
|
159
|
+
|
|
160
|
+
``period``/``fd``/``td`` are optional date-range overrides applied only
|
|
161
|
+
to this SendRequest, not to the GetStatement polls.
|
|
133
162
|
"""
|
|
134
163
|
url = url or REQUEST_URL
|
|
135
|
-
response = submit_request(
|
|
164
|
+
response = submit_request(
|
|
165
|
+
url, token, query=query_id, period=period, fd=fd, td=td,
|
|
166
|
+
timeout_increment=timeout
|
|
167
|
+
)
|
|
136
168
|
stmt_access = parse_stmt_response(response)
|
|
137
169
|
if isinstance(stmt_access, StatementError):
|
|
138
170
|
raise ResponseCodeError(
|
|
@@ -142,13 +174,37 @@ def request_statement(
|
|
|
142
174
|
return stmt_access
|
|
143
175
|
|
|
144
176
|
|
|
145
|
-
def submit_request(
|
|
177
|
+
def submit_request(
|
|
178
|
+
url: str,
|
|
179
|
+
token: str,
|
|
180
|
+
query: str,
|
|
181
|
+
period: int | None = None,
|
|
182
|
+
fd: str | None = None,
|
|
183
|
+
td: str | None = None,
|
|
184
|
+
timeout_increment: float = 5,
|
|
185
|
+
) -> requests.Response:
|
|
146
186
|
"""Post a query to an API access point, along with an authentication token.
|
|
147
187
|
|
|
188
|
+
``period`` (``p``), ``fd`` and ``td`` are optional date-range overrides
|
|
189
|
+
added to the query string after the required ``v``/``t``/``q`` params.
|
|
190
|
+
``period`` and ``fd``/``td`` are mutually exclusive, and ``fd``/``td``
|
|
191
|
+
must be given together.
|
|
192
|
+
|
|
148
193
|
Retry with a progressive timeout window.
|
|
149
194
|
"""
|
|
195
|
+
if period is not None and (fd is not None or td is not None):
|
|
196
|
+
raise ValueError("Pass either `period` or `fd`/`td`, not both")
|
|
197
|
+
if (fd is None) != (td is None):
|
|
198
|
+
raise ValueError("Pass both `fd` and `td`, or neither")
|
|
199
|
+
|
|
150
200
|
MAX_REQUESTS = 3
|
|
151
|
-
|
|
201
|
+
params = {"v": "3", "t": token, "q": query}
|
|
202
|
+
if period is not None:
|
|
203
|
+
params["p"] = str(period)
|
|
204
|
+
if fd is not None:
|
|
205
|
+
params["fd"] = fd
|
|
206
|
+
if td is not None:
|
|
207
|
+
params["td"] = td
|
|
152
208
|
|
|
153
209
|
response = None
|
|
154
210
|
req_count = 1
|
|
@@ -156,9 +212,9 @@ def submit_request(url: str, token: str, query: str) -> requests.Response:
|
|
|
156
212
|
try:
|
|
157
213
|
response = requests.get(
|
|
158
214
|
url,
|
|
159
|
-
params=
|
|
215
|
+
params=params,
|
|
160
216
|
headers={"user-agent": "Java"},
|
|
161
|
-
timeout=req_count *
|
|
217
|
+
timeout=req_count * timeout_increment,
|
|
162
218
|
)
|
|
163
219
|
except requests.exceptions.Timeout:
|
|
164
220
|
if req_count >= MAX_REQUESTS:
|
|
@@ -186,6 +242,9 @@ def parse_stmt_response(
|
|
|
186
242
|
datetime_ = datetime.strptime(timestamp, '%d %B, %Y %I:%M %p %z')
|
|
187
243
|
|
|
188
244
|
data = {child.tag: child.text for child in elem}
|
|
245
|
+
# Live responses use <Url>, but IB docs show <url>; accept either
|
|
246
|
+
if "url" in data:
|
|
247
|
+
data["Url"] = data.pop("url")
|
|
189
248
|
status = data.pop("Status")
|
|
190
249
|
assert status in {"Success", "Fail", "Warn"}
|
|
191
250
|
Type = {
|
|
@@ -243,9 +302,20 @@ def main():
|
|
|
243
302
|
help='Current Flex Web Service token')
|
|
244
303
|
argparser.add_argument('--query', '-q', required=True,
|
|
245
304
|
help='Flex Query ID#')
|
|
305
|
+
argparser.add_argument('--period', '-p', type=int, default=None,
|
|
306
|
+
help='Lookback override in days (max 365)')
|
|
307
|
+
argparser.add_argument('--fromdate', '--fd', dest='fd', default=None,
|
|
308
|
+
help='From-date override (yyyymmdd)')
|
|
309
|
+
argparser.add_argument('--todate', '--td', dest='td', default=None,
|
|
310
|
+
help='To-date override (yyyymmdd)')
|
|
246
311
|
args = argparser.parse_args()
|
|
247
312
|
|
|
248
|
-
|
|
313
|
+
try:
|
|
314
|
+
statement = download(
|
|
315
|
+
args.token, args.query, period=args.period, fd=args.fd, td=args.td
|
|
316
|
+
)
|
|
317
|
+
except ValueError as exc:
|
|
318
|
+
argparser.error(str(exc))
|
|
249
319
|
print(statement.decode())
|
|
250
320
|
|
|
251
321
|
|
|
@@ -68,6 +68,7 @@ class Code(str, enum.Enum):
|
|
|
68
68
|
ETF = "ETF" # ETF Creation/Redemption
|
|
69
69
|
EXPIRED = "Ep" # Resulted from an Expired Position
|
|
70
70
|
EXERCISE = "Ex" # Exercise
|
|
71
|
+
MUTUALFUNDEXCHANGE = "XCH" # Mutual fund exchange (FUND trade routed via FUNDSERV)
|
|
71
72
|
GUARANTEED = "G" # Trade in Guaranteed Account Segment
|
|
72
73
|
HIGHESTCOST = "HC" # Highest Cost tax lot-matching method
|
|
73
74
|
HFINVESTMENT = "HFI" # Investment Transferred to Hedge Fund
|
|
@@ -129,6 +130,15 @@ class AssetClass(str, enum.Enum):
|
|
|
129
130
|
MUTUALFUND = "FUND"
|
|
130
131
|
|
|
131
132
|
|
|
133
|
+
@enum.unique
|
|
134
|
+
class SubCategory(str, enum.Enum):
|
|
135
|
+
COMMON = "COMMON"
|
|
136
|
+
ADR = "ADR"
|
|
137
|
+
ETF = "ETF"
|
|
138
|
+
P = "P"
|
|
139
|
+
C = "C"
|
|
140
|
+
|
|
141
|
+
|
|
132
142
|
@enum.unique
|
|
133
143
|
class TradeType(str, enum.Enum):
|
|
134
144
|
EXCHTRADE = "ExchTrade"
|
|
@@ -146,6 +156,8 @@ class BuySell(str, enum.Enum):
|
|
|
146
156
|
CANCELBUY = "BUY (Ca.)"
|
|
147
157
|
SELL = "SELL"
|
|
148
158
|
CANCELSELL = "SELL (Ca.)"
|
|
159
|
+
EXCHANGEBUY = "EXCHANGE BUY"
|
|
160
|
+
EXCHANGESELL = "EXCHANGE SELL"
|
|
149
161
|
|
|
150
162
|
|
|
151
163
|
@enum.unique
|
|
@@ -173,6 +185,7 @@ class OrderType(str, enum.Enum):
|
|
|
173
185
|
REL = "REL"
|
|
174
186
|
MIT = "MIT"
|
|
175
187
|
LIT = "LIT"
|
|
188
|
+
SNAPMID = "SNAPMID"
|
|
176
189
|
|
|
177
190
|
@enum.unique
|
|
178
191
|
class Reorg(str, enum.Enum):
|
|
@@ -266,6 +279,7 @@ ENUMS = [
|
|
|
266
279
|
CashAction,
|
|
267
280
|
Code,
|
|
268
281
|
AssetClass,
|
|
282
|
+
SubCategory,
|
|
269
283
|
TradeType,
|
|
270
284
|
BuySell,
|
|
271
285
|
OpenClose,
|
|
@@ -132,11 +132,15 @@ def parse_data_element(
|
|
|
132
132
|
|
|
133
133
|
# FlexQueryResponse & FlexStatement are the only data elements
|
|
134
134
|
# that contain other data elements.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
# Skip sections the class has no field for (e.g. a statement section IB
|
|
136
|
+
# added after this release), with a warning instead of failing the parse.
|
|
137
|
+
contained_elements = {}
|
|
138
|
+
for child in elem:
|
|
139
|
+
if child.tag not in Class.__dataclass_fields__:
|
|
140
|
+
warnings.warn(f"{Class.__name__} has no attribute '{child.tag}'; skipping")
|
|
141
|
+
continue
|
|
142
|
+
if (parsed := parse_element(child)) is not None:
|
|
143
|
+
contained_elements[child.tag] = parsed
|
|
140
144
|
if contained_elements:
|
|
141
145
|
assert elem.tag in ("FlexQueryResponse", "FlexStatement")
|
|
142
146
|
attrs.update(contained_elements)
|
|
@@ -295,7 +299,7 @@ def prep_code_sequence(value: str) -> Iterable[enums.Code]:
|
|
|
295
299
|
"""
|
|
296
300
|
sep = ";" if ";" in value else ","
|
|
297
301
|
return (
|
|
298
|
-
enums.Code
|
|
302
|
+
convert_enum(Type=enums.Code, value=v)
|
|
299
303
|
for v in value.split(sep)
|
|
300
304
|
if v
|
|
301
305
|
) if value != "" else []
|
|
@@ -373,7 +377,14 @@ def convert_enum(Type, value):
|
|
|
373
377
|
# Enums bind custom names to the IB-supplied values.
|
|
374
378
|
# To convert, just do a by-value lookup on the incoming string.
|
|
375
379
|
# https://docs.python.org/3/library/enum.html#programmatic-access-to-enumeration-members-and-their-attributes
|
|
376
|
-
|
|
380
|
+
try:
|
|
381
|
+
return Type(value) if value != "" else None
|
|
382
|
+
except ValueError:
|
|
383
|
+
warnings.warn(
|
|
384
|
+
f"{value!r} is not a valid {Type.__name__}."
|
|
385
|
+
" Possibly a new update from IBKR; passing through as a plain string"
|
|
386
|
+
)
|
|
387
|
+
return value
|
|
377
388
|
|
|
378
389
|
|
|
379
390
|
ATTRIB_CONVERTERS = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ibflex
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2
|
|
4
4
|
Summary: Parse Interactive Brokers Flex XML reports and convert to Python types
|
|
5
5
|
Author-email: Christopher Singley <csingley@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -148,6 +148,16 @@ them:
|
|
|
148
148
|
In [5]: response[:215]
|
|
149
149
|
Out[5]: b'<FlexQueryResponse queryName="Get Everything" type="AF">\n<FlexStatements count="1">\n<FlexStatement accountId="U111111" fromDate="2018-01-01" toDate="2018-01-31" period="LastMonth" whenGenerated="2018-02-01;211353">\n'
|
|
150
150
|
|
|
151
|
+
You can override the report's date range per request with ``period`` (the
|
|
152
|
+
``p`` parameter - a lookback in days, up to 365) or ``fd``/``td`` (explicit
|
|
153
|
+
``yyyymmdd`` from/to dates, given together); the two forms are mutually
|
|
154
|
+
exclusive. This is useful because the v3 Flex Web Service may not honor a
|
|
155
|
+
query's saved "Last N Calendar Days" period:
|
|
156
|
+
|
|
157
|
+
.. code:: python
|
|
158
|
+
|
|
159
|
+
In [6]: response = client.download(token, query_id, period=90)
|
|
160
|
+
|
|
151
161
|
|
|
152
162
|
``pip install ibflex[web]`` also installs a ``flexget`` console script:
|
|
153
163
|
|
|
@@ -156,6 +166,24 @@ them:
|
|
|
156
166
|
$ flexget -t 111111111111111111111111 -q 111111 > 2018-01_ibkr.xml
|
|
157
167
|
|
|
158
168
|
|
|
169
|
+
Releasing
|
|
170
|
+
=========
|
|
171
|
+
Releases are published to PyPI automatically via GitHub Actions using
|
|
172
|
+
`trusted publishing`_ (OIDC). No API tokens are needed.
|
|
173
|
+
|
|
174
|
+
To cut a release:
|
|
175
|
+
|
|
176
|
+
1. Bump the version in ``pyproject.toml`` and ``ibflex/__version__.py``.
|
|
177
|
+
2. Commit, push to ``master``.
|
|
178
|
+
3. Create a GitHub release with a tag matching the version (e.g. ``v1.1``).
|
|
179
|
+
|
|
180
|
+
The ``Publish to PyPI`` workflow will build and upload the package.
|
|
181
|
+
|
|
182
|
+
Any repository collaborator who can create a GitHub release **and** has access
|
|
183
|
+
to the ``pypi`` deployment environment can publish. Environment access is
|
|
184
|
+
managed under Settings → Environments → pypi.
|
|
185
|
+
|
|
186
|
+
|
|
159
187
|
Resources
|
|
160
188
|
=========
|
|
161
189
|
* Interactive Brokers `Activity Flex Query Reference`_
|
|
@@ -168,5 +196,6 @@ Resources
|
|
|
168
196
|
.. _Interactive Brokers account management: https://www.interactivebrokers.com/sso/Login
|
|
169
197
|
.. _Activity Flex Query Reference: https://www.interactivebrokers.com/en/software/reportguide/reportguide.htm#reportguide/activity_flex_query_reference.htm
|
|
170
198
|
.. _FlexWeb Service Reference: https://www.interactivebrokers.com/en/software/am/am/reports/flex_web_service_version_3.htm
|
|
199
|
+
.. _trusted publishing: https://docs.pypi.org/trusted-publishers/
|
|
171
200
|
.. _capgains: https://github.com/csingley/capgains
|
|
172
201
|
.. _ib-flex-analyzer: https://github.com/wesm/ib-flex-analyzer
|
|
@@ -14,7 +14,7 @@ RESPONSE_SUCCESS = (
|
|
|
14
14
|
'<FlexStatementResponse timestamp="28 August, 2012 10:37 AM EDT">'
|
|
15
15
|
'<Status>Success</Status>'
|
|
16
16
|
'<ReferenceCode>1234567890</ReferenceCode>'
|
|
17
|
-
'<Url>https://
|
|
17
|
+
'<Url>https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement</Url>'
|
|
18
18
|
'</FlexStatementResponse>'
|
|
19
19
|
)
|
|
20
20
|
|
|
@@ -119,6 +119,15 @@ class RequestStatementTestCase(unittest.TestCase):
|
|
|
119
119
|
self.assertEqual(output.Url, client.STMT_URL)
|
|
120
120
|
|
|
121
121
|
|
|
122
|
+
class ParseStmtResponseTestCase(unittest.TestCase):
|
|
123
|
+
def test_lowercase_url_element(self):
|
|
124
|
+
response = Mock()
|
|
125
|
+
response.content = RESPONSE_SUCCESS.replace("Url>", "url>").encode()
|
|
126
|
+
output = client.parse_stmt_response(response)
|
|
127
|
+
self.assertIsInstance(output, client.StatementAccess)
|
|
128
|
+
self.assertEqual(output.Url, client.STMT_URL)
|
|
129
|
+
|
|
130
|
+
|
|
122
131
|
@patch("requests.get", side_effect=mock_response)
|
|
123
132
|
class DownloadTestCase(unittest.TestCase):
|
|
124
133
|
def test_request_statement(self, mock_requests_get: Mock):
|
|
@@ -150,5 +159,85 @@ class DownloadTestCase(unittest.TestCase):
|
|
|
150
159
|
self.assertIsInstance(response, Types.FlexQueryResponse)
|
|
151
160
|
|
|
152
161
|
|
|
162
|
+
@patch("requests.get", side_effect=mock_response)
|
|
163
|
+
class DateRangeOverrideTestCase(unittest.TestCase):
|
|
164
|
+
def test_period_override_sent_on_send_request_only(self, mock_requests_get):
|
|
165
|
+
# `period` is forwarded to SendRequest as `p`, but the GetStatement
|
|
166
|
+
# poll must NOT carry it.
|
|
167
|
+
output = client.download(
|
|
168
|
+
token="DEADBEEF",
|
|
169
|
+
query_id="0987654321",
|
|
170
|
+
period=90,
|
|
171
|
+
)
|
|
172
|
+
self.assertIsInstance(output, bytes)
|
|
173
|
+
|
|
174
|
+
self.assertEqual(
|
|
175
|
+
mock_requests_get.call_args_list,
|
|
176
|
+
[
|
|
177
|
+
call(
|
|
178
|
+
client.REQUEST_URL,
|
|
179
|
+
params={
|
|
180
|
+
"v": "3", "t": "DEADBEEF", "q": "0987654321", "p": "90",
|
|
181
|
+
},
|
|
182
|
+
headers={"user-agent": "Java"},
|
|
183
|
+
timeout=5,
|
|
184
|
+
),
|
|
185
|
+
call(
|
|
186
|
+
client.STMT_URL,
|
|
187
|
+
params={"v": "3", "t": "DEADBEEF", "q": "1234567890"},
|
|
188
|
+
headers={"user-agent": "Java"},
|
|
189
|
+
timeout=5,
|
|
190
|
+
),
|
|
191
|
+
],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def test_from_to_date_override(self, mock_requests_get):
|
|
195
|
+
client.request_statement(
|
|
196
|
+
token="DEADBEEF",
|
|
197
|
+
query_id="0987654321",
|
|
198
|
+
fd="20260101",
|
|
199
|
+
td="20260331",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
mock_requests_get.assert_called_once_with(
|
|
203
|
+
client.REQUEST_URL,
|
|
204
|
+
params={
|
|
205
|
+
"v": "3", "t": "DEADBEEF", "q": "0987654321",
|
|
206
|
+
"fd": "20260101", "td": "20260331",
|
|
207
|
+
},
|
|
208
|
+
headers={"user-agent": "Java"},
|
|
209
|
+
timeout=5,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def test_period_and_dates_are_mutually_exclusive(self, mock_requests_get):
|
|
213
|
+
with self.assertRaises(ValueError):
|
|
214
|
+
client.download(
|
|
215
|
+
token="DEADBEEF",
|
|
216
|
+
query_id="0987654321",
|
|
217
|
+
period=90,
|
|
218
|
+
fd="20260101",
|
|
219
|
+
td="20260131",
|
|
220
|
+
)
|
|
221
|
+
mock_requests_get.assert_not_called()
|
|
222
|
+
|
|
223
|
+
def test_fd_and_td_must_be_given_together(self, mock_requests_get):
|
|
224
|
+
for kwargs in ({"fd": "20260101"}, {"td": "20260131"}):
|
|
225
|
+
with self.assertRaises(ValueError):
|
|
226
|
+
client.download(
|
|
227
|
+
token="DEADBEEF", query_id="0987654321", **kwargs
|
|
228
|
+
)
|
|
229
|
+
mock_requests_get.assert_not_called()
|
|
230
|
+
|
|
231
|
+
def test_no_override_leaves_params_unchanged(self, mock_requests_get):
|
|
232
|
+
client.request_statement(token="DEADBEEF", query_id="0987654321")
|
|
233
|
+
|
|
234
|
+
mock_requests_get.assert_called_once_with(
|
|
235
|
+
client.REQUEST_URL,
|
|
236
|
+
params={"v": "3", "t": "DEADBEEF", "q": "0987654321"},
|
|
237
|
+
headers={"user-agent": "Java"},
|
|
238
|
+
timeout=5,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
153
242
|
if __name__ == '__main__':
|
|
154
243
|
unittest.main(verbosity=3)
|
|
@@ -297,9 +297,13 @@ class ParseElementAttrTestCase(unittest.TestCase):
|
|
|
297
297
|
("foobar", TestEnum.BAR)
|
|
298
298
|
)
|
|
299
299
|
|
|
300
|
-
#
|
|
301
|
-
with
|
|
302
|
-
|
|
300
|
+
# Unknown enum values (e.g. new categories added by IBKR
|
|
301
|
+
# upstream) pass through as plain strings with a warning.
|
|
302
|
+
with self.assertWarns(UserWarning):
|
|
303
|
+
self.assertEqual(
|
|
304
|
+
parser.parse_element_attr(TestClass, "foobar", "3"),
|
|
305
|
+
("foobar", "3")
|
|
306
|
+
)
|
|
303
307
|
|
|
304
308
|
def testCurrency(self):
|
|
305
309
|
"""parse_element_attr() checks attributes named 'currency' vs ISO4217.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|