ibflex 1.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ibflex
3
- Version: 1.0
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
@@ -186,7 +186,7 @@ class FlexStatement(FlexElement):
186
186
  ConversionRates: tuple["ConversionRate", ...] = ()
187
187
  HKIPOOpenSubscriptions: tuple = () # TODO
188
188
  CommissionCredits: tuple = () # TODO
189
- StockGrantActivities: tuple = () # TODO
189
+ StockGrantActivities: tuple["StockGrantActivity", ...] = ()
190
190
  SLBCollaterals: tuple = () # TODO
191
191
  IncentiveCouponAccrualDetails: tuple = () # TODO
192
192
  DepositsOnHold: tuple = () # TODO
@@ -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
@@ -2869,5 +2872,51 @@ class SLBOpenContract(FlexElement):
2869
2872
  fineness: decimal.Decimal | None = None
2870
2873
  weight: decimal.Decimal | None = None
2871
2874
 
2875
+
2876
+ @dataclass(frozen=True)
2877
+ class StockGrantActivity(FlexElement):
2878
+ """Wrapped in <StockGrantActivities>"""
2879
+
2880
+ accountId: str | None = None
2881
+ acctAlias: str | None = None
2882
+ model: str | None = None
2883
+ currency: str | None = None
2884
+ fxRateToBase: decimal.Decimal | None = None
2885
+ assetCategory: enums.AssetClass | None = None
2886
+ subCategory: str | None = None
2887
+ symbol: str | None = None
2888
+ description: str | None = None
2889
+ conid: str | None = None
2890
+ securityID: str | None = None
2891
+ securityIDType: str | None = None
2892
+ cusip: str | None = None
2893
+ isin: str | None = None
2894
+ figi: str | None = None
2895
+ listingExchange: str | None = None
2896
+ underlyingConid: str | None = None
2897
+ underlyingSymbol: str | None = None
2898
+ underlyingSecurityID: str | None = None
2899
+ underlyingListingExchange: str | None = None
2900
+ issuer: str | None = None
2901
+ issuerCountryCode: str | None = None
2902
+ multiplier: decimal.Decimal | None = None
2903
+ strike: decimal.Decimal | None = None
2904
+ expiry: datetime.date | None = None
2905
+ putCall: enums.PutCall | None = None
2906
+ principalAdjustFactor: decimal.Decimal | None = None
2907
+ reportDate: datetime.date | None = None
2908
+ activityDescription: str | None = None
2909
+ awardDate: datetime.date | None = None
2910
+ vestingDate: datetime.date | None = None
2911
+ quantity: decimal.Decimal | None = None
2912
+ price: decimal.Decimal | None = None
2913
+ value: decimal.Decimal | None = None
2914
+ serialNumber: str | None = None
2915
+ deliveryType: str | None = None
2916
+ commodityType: str | None = None
2917
+ fineness: decimal.Decimal | None = None
2918
+ weight: decimal.Decimal | None = None
2919
+
2920
+
2872
2921
  # Type alias to work around https://github.com/python/mypy/issues/1775
2873
2922
  _ClientFeesDetail = ClientFeesDetail
@@ -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.0"
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://gdcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement'
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(token: str, query_id: str, max_tries: int | None = 5) -> bytes:
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(token, query_id)
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, query_id: str, url: str | None = None
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(url, token, query=query_id)
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(url: str, token: str, query: str) -> requests.Response:
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
- TIMEOUT_INCREMENT = 5
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={"v": "3", "t": token, "q": query},
215
+ params=params,
160
216
  headers={"user-agent": "Java"},
161
- timeout=req_count * TIMEOUT_INCREMENT,
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
- statement = download(args.token, args.query)
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
@@ -105,6 +106,8 @@ class Code(str, enum.Enum):
105
106
  STCG = "ST" # Short-term P/L
106
107
  STOCKYIELD = "SY" # Positions that may be eligible for Stock Yield.
107
108
  TRANSFER = "T" # Transfer
109
+ ADR = "ADR" # ADR fee
110
+ LIQUIDATION_FORCED = "LF" # Liquidation - Loss Flattening
108
111
 
109
112
 
110
113
  @enum.unique
@@ -127,6 +130,15 @@ class AssetClass(str, enum.Enum):
127
130
  MUTUALFUND = "FUND"
128
131
 
129
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
+
130
142
  @enum.unique
131
143
  class TradeType(str, enum.Enum):
132
144
  EXCHTRADE = "ExchTrade"
@@ -144,6 +156,8 @@ class BuySell(str, enum.Enum):
144
156
  CANCELBUY = "BUY (Ca.)"
145
157
  SELL = "SELL"
146
158
  CANCELSELL = "SELL (Ca.)"
159
+ EXCHANGEBUY = "EXCHANGE BUY"
160
+ EXCHANGESELL = "EXCHANGE SELL"
147
161
 
148
162
 
149
163
  @enum.unique
@@ -170,6 +184,8 @@ class OrderType(str, enum.Enum):
170
184
  TRAIL = "TRAIL"
171
185
  REL = "REL"
172
186
  MIT = "MIT"
187
+ LIT = "LIT"
188
+ SNAPMID = "SNAPMID"
173
189
 
174
190
  @enum.unique
175
191
  class Reorg(str, enum.Enum):
@@ -237,6 +253,7 @@ class TransferType(str, enum.Enum):
237
253
  ACATS = "ACATS"
238
254
  ATON = "ATON"
239
255
  FOP = "FOP"
256
+ OTC = "OTC"
240
257
 
241
258
 
242
259
  @enum.unique
@@ -262,6 +279,7 @@ ENUMS = [
262
279
  CashAction,
263
280
  Code,
264
281
  AssetClass,
282
+ SubCategory,
265
283
  TradeType,
266
284
  BuySell,
267
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
- contained_elements = {
136
- child.tag: parsed
137
- for child in elem
138
- if (parsed := parse_element(child)) is not None
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(v)
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
- return Type(value) if value != "" else None
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.0
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ibflex"
7
- version = "1.0"
7
+ version = "1.2"
8
8
  description = "Parse Interactive Brokers Flex XML reports and convert to Python types"
9
9
  readme = "README.rst"
10
10
  license = "MIT"
@@ -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://gdcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement</Url>'
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
- # Illegal enum values raise FlexParserError
301
- with self.assertRaises(parser.FlexParserError):
302
- parser.parse_element_attr(TestClass, "foobar", "3")
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