diff --git a/README.rst b/README.rst
index 2f66158..57e3fd9 100644
--- a/README.rst
+++ b/README.rst
@@ -2,6 +2,23 @@
Python parser for Interactive Brokers Flex XML statements
=========================================================
+**VENDORED PACKAGE NOTICE**
+---------------------------
+
+This is a vendored and enhanced version of the original ibflex library by Christopher Singley
+(https://github.com/csingley/ibflex).
+
+This fork
+ * includes additional data fields beyond the upstream version
+ * incorporates similar improvements from robcohen/ibflex2
+ * includes an opt-in mechanism to be tolerant against IBKR adding new but unimportant attributes on a regular basis.
+
+The version number
+includes a "+vroonhof.vendored" suffix to distinguish it from the original package.
+
+**ORGINAL README**
+------------------
+
``ibflex`` is a Python library for converting brokerage statement data in
Interactive Brokers' Flex XML format into standard Python data structures,
so it can be conveniently processed and analyzed with Python scripts.
diff --git a/ibflex/Types.py b/ibflex/Types.py
index 6ab7555..7f159e0 100644
--- a/ibflex/Types.py
+++ b/ibflex/Types.py
@@ -479,6 +479,12 @@ class EquitySummaryByReportDateInBase(FlexElement):
marginFinancingChargeAccrualsShort: Optional[decimal.Decimal] = None
cryptoLong: Optional[decimal.Decimal] = None
cryptoShort: Optional[decimal.Decimal] = None
+ liteSurchargeAccruals: Optional[decimal.Decimal] = None
+ liteSurchargeAccrualsLong: Optional[decimal.Decimal] = None
+ liteSurchargeAccrualsShort: Optional[decimal.Decimal] = None
+ cgtWithholdingAccruals: Optional[decimal.Decimal] = None
+ cgtWithholdingAccrualsLong: Optional[decimal.Decimal] = None
+ cgtWithholdingAccrualsShort: Optional[decimal.Decimal] = None
@dataclass(frozen=True)
@@ -709,6 +715,8 @@ class CashReportCurrency(FlexElement):
salesTaxYTD: Optional[decimal.Decimal] = None
salesTaxPaxos: Optional[decimal.Decimal] = None
otherIncome: Optional[decimal.Decimal] = None
+ otherIncomeMTD: Optional[decimal.Decimal] = None
+ otherIncomeYTD: Optional[decimal.Decimal] = None
otherIncomeSec: Optional[decimal.Decimal] = None
otherIncomeCom: Optional[decimal.Decimal] = None
otherFeesMTD: Optional[decimal.Decimal] = None
@@ -1144,7 +1152,8 @@ class Trade(FlexElement):
subCategory: Optional[str] = None
issuerCountryCode: Optional[str] = None
rtn: Optional[str] = None
- initialInvestment: Optional[decimal.Decimal] = None
+ initialInvestment: Optional[bool] = None
+ positionActionID: Optional[str] = None
@dataclass(frozen=True)
@@ -1296,7 +1305,8 @@ class Lot(FlexElement):
issuerCountryCode: Optional[str] = None
relatedTradeID: Optional[str] = None
rtn: Optional[str] = None
- initialInvestment: Optional[decimal.Decimal] = None
+ initialInvestment: Optional[bool] = None
+ positionActionID: Optional[str] = None
@dataclass(frozen=True)
@@ -1385,6 +1395,9 @@ class SymbolSummary(FlexElement):
tradeID: Optional[str] = None
orderID: Optional[decimal.Decimal] = None
execID: Optional[str] = None
+ ibExecID: Optional[str] = None
+ extExecID: Optional[str] = None
+ exchOrderId: Optional[str] = None
brokerageOrderID: Optional[str] = None
orderReference: Optional[str] = None
volatilityOrderLink: Optional[str] = None
@@ -1392,12 +1405,19 @@ class SymbolSummary(FlexElement):
origTradePrice: Optional[decimal.Decimal] = None
origTradeDate: Optional[datetime.date] = None
origTradeID: Optional[str] = None
+ transactionID: Optional[str] = None
# Despite the name, `orderTime` actually contains date/time data.
orderTime: Optional[datetime.datetime] = None
+ openDateTime: Optional[datetime.datetime] = None
+ holdingPeriodDateTime: Optional[datetime.datetime] = None
dateTime: Optional[datetime.datetime] = None
reportDate: Optional[datetime.date] = None
settleDate: Optional[datetime.date] = None
+ settleDateTarget: Optional[datetime.date] = None # expected date of ownership transfer
+ taxes: Optional[decimal.Decimal] = None
tradeDate: Optional[datetime.date] = None
+ tradePrice: Optional[decimal.Decimal] = None
+ tradeMoney: Optional[decimal.Decimal] = None # TradeMoney = Proceeds + Fees + Commissions
exchange: Optional[str] = None
buySell: Optional[enums.BuySell] = None
quantity: Optional[decimal.Decimal] = None
@@ -1427,6 +1447,40 @@ class SymbolSummary(FlexElement):
relatedTradeID: Optional[str] = None
origTransactionID: Optional[str] = None
relatedTransactionID: Optional[str] = None
+ positionActionID: Optional[str] = None
+ changeInPrice: Optional[decimal.Decimal] = None
+ changeInQuantity: Optional[decimal.Decimal] = None
+ closePrice: Optional[decimal.Decimal] = None
+ commodityType: Optional[str] = None
+ cost: Optional[decimal.Decimal] = None
+ deliveryType: Optional[str] = None
+ exchOrderId: Optional[str] = None
+ extExecID: Optional[str] = None
+ fifoPnlRealized: Optional[decimal.Decimal] = None
+ fineness: Optional[decimal.Decimal] = None
+ holdingPeriodDateTime: Optional[datetime.datetime] = None
+ ibCommission: Optional[decimal.Decimal] = None
+ ibCommissionCurrency: Optional[str] = None
+ ibExecID: Optional[str] = None
+ ibOrderID: Optional[str] = None
+ initialInvestment: Optional[bool] = None
+ mtmPnl: Optional[decimal.Decimal] = None
+ netCash: Optional[decimal.Decimal] = None
+ netCashInBase: Optional[decimal.Decimal] = None
+ notes: Optional[str] = None
+ openCloseIndicator: Optional[enums.OpenClose] = None
+ openDateTime: Optional[datetime.datetime] = None
+ origOrderID: Optional[str] = None
+ rtn: Optional[str] = None
+ serialNumber: Optional[str] = None
+ settleDateTarget: Optional[datetime.date] = None
+ taxes: Optional[decimal.Decimal] = None
+ tradeMoney: Optional[decimal.Decimal] = None
+ tradePrice: Optional[decimal.Decimal] = None
+ transactionID: Optional[str] = None
+ weight: Optional[str] = None
+ whenRealized: Optional[datetime.datetime] = None
+ whenReopened: Optional[datetime.datetime] = None
@dataclass(frozen=True)
@@ -1535,7 +1589,8 @@ class AssetSummary(FlexElement):
origTransactionID: Optional[str] = None
relatedTransactionID: Optional[str] = None
rtn: Optional[str] = None
- initialInvestment: Optional[decimal.Decimal] = None
+ initialInvestment: Optional[bool] = None
+ positionActionID: Optional[str] = None
@dataclass(frozen=True)
@@ -1638,12 +1693,13 @@ class Order(FlexElement):
origTransactionID: Optional[str] = None
relatedTransactionID: Optional[str] = None
rtn: Optional[str] = None
- initialInvestment: Optional[decimal.Decimal] = None
+ initialInvestment: Optional[bool] = None
serialNumber: Optional[str] = None
deliveryType: Optional[str] = None
commodityType: Optional[str] = None
fineness: Optional[decimal.Decimal] = None
weight: Optional[str] = None
+ positionActionID: Optional[str] = None
@dataclass(frozen=True)
@@ -1796,7 +1852,7 @@ class OptionEAE(FlexElement):
origTransactionID: Optional[str] = None
relatedTransactionID: Optional[str] = None
rtn: Optional[str] = None
- initialInvestment: Optional[decimal.Decimal] = None
+ initialInvestment: Optional[bool] = None
serialNumber: Optional[str] = None
deliveryType: Optional[str] = None
commodityType: Optional[str] = None
@@ -2122,7 +2178,12 @@ class Transfer(FlexElement):
commodityType: Optional[str] = None
fineness: Optional[decimal.Decimal] = None
weight: Optional[str] = None
-
+ figi: Optional[str] = None
+ settleDate: Optional[datetime.date] = None
+ issuerCountryCode: Optional[str] = None
+ levelOfDetail: Optional[str] = None
+ positionInstructionID: Optional[str] = None
+ positionInstructionSetID: Optional[str] = None
@dataclass(frozen=True)
class UnsettledTransfer(FlexElement):
@@ -2245,6 +2306,11 @@ class CorporateAction(FlexElement):
commodityType: Optional[str] = None
fineness: Optional[decimal.Decimal] = None
weight: Optional[str] = None
+ figi: Optional[str] = None
+ issuerCountryCode: Optional[str] = None
+ costBasis: Optional[decimal.Decimal] = None
+
+
@dataclass(frozen=True)
@@ -2480,7 +2546,7 @@ class SecurityInfo(FlexElement):
origTransactionID: Optional[str] = None
relatedTransactionID: Optional[str] = None
rtn: Optional[str] = None
- initialInvestment: Optional[decimal.Decimal] = None
+ initialInvestment: Optional[bool] = None
serialNumber: Optional[str] = None
deliveryType: Optional[str] = None
commodityType: Optional[str] = None
diff --git a/ibflex/__init__.py b/ibflex/__init__.py
index 684a0d7..b6dcd6b 100644
--- a/ibflex/__init__.py
+++ b/ibflex/__init__.py
@@ -4,6 +4,8 @@
from .Types import *
from . import parser
from .parser import parse
+from .parser import enable_unknown_attribute_tolerance
+from .parser import disable_unknown_attribute_tolerance
from . import utils
from . import client
diff --git a/ibflex/__version__.py b/ibflex/__version__.py
index 5cf7d39..a816cfe 100644
--- a/ibflex/__version__.py
+++ b/ibflex/__version__.py
@@ -16,8 +16,8 @@
__description__ = (
"Parse Interactive Brokers Flex XML reports and convert to Python types"
),
-__url__ = "https://github.com/csingley/ibflex"
-__version__ = "0.16"
+__url__ = "https://github.com/vroonhof/ibflex"
+__version__ = "0.16+vroonhof.vendored"
__author__ = "Christopher Singley"
__author_email__ = "csingley@gmail.com"
__license__ = "MIT"
diff --git a/ibflex/client.py b/ibflex/client.py
index f203e8b..041f53a 100755
--- a/ibflex/client.py
+++ b/ibflex/client.py
@@ -136,8 +136,6 @@ def request_statement(
"""First part of the 2-step download process.
"""
url = url or REQUEST_URL
- ### AKE FIX
- url = 'https://ndcdyn.interactivebrokers.com/portal.flexweb/api/v1/flexQuery'
response = submit_request(url, token, query=query_id)
stmt_access = parse_stmt_response(response)
if isinstance(stmt_access, StatementError):
diff --git a/ibflex/enums.py b/ibflex/enums.py
index d5a4f3a..7b4a8ad 100644
--- a/ibflex/enums.py
+++ b/ibflex/enums.py
@@ -77,6 +77,7 @@ class Code(str, enum.Enum):
INVESTOR = "INV" # Investment Transfer from Investor
MARGINLOW = "L" # Ordered by IB (Margin Violation)
WASHSALE = "LD" # Adjusted by Loss Disallowed from Wash Sale
+ LIQUIDATION_FORCED = "LF" # Forced Liquidation
LIFO = "LI" # Last In, First Out (LIFO) tax lot-matching method
LTCG = "LT" # Long-term P/L
LOAN = "Lo" # Direct Loan
@@ -236,6 +237,7 @@ class TransferType(str, enum.Enum):
ACATS = "ACATS"
ATON = "ATON"
FOP = "FOP"
+ OTC = "OTC"
@enum.unique
diff --git a/ibflex/parser.py b/ibflex/parser.py
index af6b270..f582187 100755
--- a/ibflex/parser.py
+++ b/ibflex/parser.py
@@ -21,6 +21,56 @@ class FlexParserError(Exception):
""" Error experienced while parsing Flex XML data. """
+###############################################################################
+# UNKNOWN ATTRIBUTE TOLERANCE
+###############################################################################
+_UNKNOWN_ATTRIBUTE_TOLERANCE = False
+
+
+def enable_unknown_attribute_tolerance():
+ """Enable tolerance for unknown XML attributes in IB Flex data.
+
+ When enabled, unknown attributes and element types in the XML data are
+ silently ignored instead of raising FlexParserError. This is useful when
+ Interactive Brokers adds new fields to their exports that are not yet
+ defined in the Types module.
+
+ This function is only available in the forked version of ibflex.
+ Attempting to call it on the original upstream package will raise
+ AttributeError (the method does not exist there), providing a clear
+ signal that the feature is not supported.
+
+ Default: off (strict mode - unknown attributes raise errors).
+
+ See also: disable_unknown_attribute_tolerance()
+ """
+ global _UNKNOWN_ATTRIBUTE_TOLERANCE
+ _UNKNOWN_ATTRIBUTE_TOLERANCE = True
+
+
+def disable_unknown_attribute_tolerance():
+ """Disable tolerance for unknown XML attributes (the default behavior).
+
+ After calling this, unknown attributes in XML data will raise
+ FlexParserError as usual.
+
+ See also: enable_unknown_attribute_tolerance()
+ """
+ global _UNKNOWN_ATTRIBUTE_TOLERANCE
+ _UNKNOWN_ATTRIBUTE_TOLERANCE = False
+
+
+def _get_known_attributes(Class):
+ """Get all known attribute names for a FlexElement subclass,
+ including inherited attributes from base classes.
+ """
+ attrs = set()
+ for klass in Class.__mro__:
+ if hasattr(klass, '__annotations__'):
+ attrs.update(klass.__annotations__.keys())
+ return attrs
+
+
DataType = Union[
None, str, int, bool, decimal.Decimal, datetime.date, datetime.time,
datetime.datetime, enums.EnumType, Tuple[str, ...], Tuple[enums.Code, ...]
@@ -99,22 +149,40 @@ def parse_element_container(elem: ET.Element) -> Tuple[Types.FlexElement, ...]:
return tuple(itertools.chain.from_iterable(fxlots))
instances = tuple(parse_data_element(child) for child in elem)
+ if _UNKNOWN_ATTRIBUTE_TOLERANCE:
+ instances = tuple(inst for inst in instances if inst is not None)
return instances
def parse_data_element(
elem: ET.Element
-) -> Types.FlexElement:
+) -> Optional[Types.FlexElement]:
"""Parse an XML data element into a Types.FlexElement subclass instance.
+
+ Returns None if unknown_attribute_tolerance is enabled and the element
+ type is not recognized.
"""
# Look up XML element's matching FlexElement subclass in ibflex.Types.
- Class = getattr(Types, elem.tag)
+ try:
+ Class = getattr(Types, elem.tag)
+ except AttributeError:
+ if _UNKNOWN_ATTRIBUTE_TOLERANCE:
+ return None
+ raise
+
+ # When tolerance is enabled, pre-compute known attributes and filter
+ known = _get_known_attributes(Class) if _UNKNOWN_ATTRIBUTE_TOLERANCE else None
# Parse element attributes
+ if known is not None:
+ attrib_items = [(k, v) for k, v in elem.attrib.items() if k in known]
+ else:
+ attrib_items = list(elem.attrib.items())
+
try:
attrs = dict(
parse_element_attr(Class, k, v)
- for k, v in elem.attrib.items()
+ for k, v in attrib_items
)
except KeyError as exc:
msg = f"{Class.__name__} has no attribute " + str(exc)
@@ -125,6 +193,12 @@ def parse_data_element(
contained_elements = {child.tag: parse_element(child) for child in elem}
if contained_elements:
assert elem.tag in ("FlexQueryResponse", "FlexStatement")
+ if _UNKNOWN_ATTRIBUTE_TOLERANCE:
+ # Filter out unknown or unparseable contained elements
+ contained_elements = {
+ k: v for k, v in contained_elements.items()
+ if k in known and v is not None
+ }
attrs.update(contained_elements)
try:
@@ -170,9 +244,11 @@ def parse_element_attr(
# INPUT VALUE PREP FUNCTIONS FOR DATA CONVERTERS
# These are just implementation details for converters and don't need testing.
###############################################################################
-def prep_date(value: str) -> Tuple[int, int, int]:
+def prep_date(value: str) -> Optional[Tuple[int, int, int]]:
"""Returns a tuple of (year, month, day).
"""
+ if value == "MULTI":
+ return None # Summaries have MULTI as date value.
date_format = DATE_FORMATS[len(value)][value.count('/')]
return datetime.datetime.strptime(value, date_format).timetuple()[:3]
@@ -184,9 +260,11 @@ def prep_time(value: str) -> Tuple[int, int, int]:
return datetime.datetime.strptime(value, time_format).timetuple()[3:6]
-def prep_datetime(value: str) -> Tuple[int, ...]:
+def prep_datetime(value: str) -> Optional[Tuple[int, ...]]:
"""Returns a tuple of (year, month, day, hour, minute, second).
"""
+ if value == "MULTI":
+ return None # Summaries have MULTI as date value.
# HACK - some old data has ", " separator instead of ",".
value = value.replace(", ", ",")
@@ -328,8 +406,8 @@ def optional_convert(value):
convert_string = make_optional(make_converter(str, prep=utils.identity_func))
convert_int = make_converter(int, prep=utils.identity_func)
-# IB sends "Y"/"N" for True/False
-convert_bool = make_converter(bool, prep=lambda x: {"Y": True, "N": False}[x])
+# IB sends "Y"/"N" or "Yes"/"No" for True/False
+convert_bool = make_converter(bool, prep=lambda x: {"Y": True, "N": False, "Yes": True, "No": False}[x])
# IB sends numeric data with place delimiters (commas)
convert_decimal = make_converter(
decimal.Decimal,
@@ -463,6 +541,7 @@ def convert_enum(Type, value):
"CNH", # RMB traded in HK
"BASE_SUMMARY", # Fake currency code used in IB NAV/Performance reports
"", # Lot element allows blank currency ?!
+ "RUS", # Russian-related currency code used by IBKR
)
diff --git a/tests/test_equity_summary_fields.py b/tests/test_equity_summary_fields.py
new file mode 100644
index 0000000..56107b1
--- /dev/null
+++ b/tests/test_equity_summary_fields.py
@@ -0,0 +1,35 @@
+import unittest
+from ibflex import parser
+
+class TestEquitySummaryNewFields(unittest.TestCase):
+ def test_new_fields_parsing(self):
+ xml_data = b"""
+
+
+
+
+
+
+
+
+
+"""
+ try:
+ response = parser.parse(xml_data)
+ self.assertIsInstance(response, parser.Types.FlexQueryResponse)
+
+ equity_summary = response.FlexStatements[0].EquitySummaryInBase[0]
+
+ # Check the new fields
+ self.assertEqual(equity_summary.liteSurchargeAccrualsLong, 0)
+ self.assertEqual(equity_summary.liteSurchargeAccrualsShort, 0)
+ self.assertEqual(equity_summary.cgtWithholdingAccruals, 0)
+ self.assertEqual(equity_summary.cgtWithholdingAccrualsLong, 0)
+ self.assertEqual(equity_summary.cgtWithholdingAccrualsShort, 0)
+
+ print("\nSuccessfully parsed XML data and verified new fields")
+ except parser.FlexParserError as e:
+ self.fail(f"FlexParserError raised: {e}")
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 92fab3f..a7b2dac 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -333,9 +333,11 @@ def testConvertInt(self):
parser.convert_int("")
def testConvertBool(self):
- """ Legal boolean values are 'Y'/'N' """
+ """ Legal boolean values are 'Y'/'N' or 'Yes'/'No' """
self.assertEqual(parser.convert_bool("Y"), True)
self.assertEqual(parser.convert_bool("N"), False)
+ self.assertEqual(parser.convert_bool("Yes"), True)
+ self.assertEqual(parser.convert_bool("No"), False)
# Empty string raises FlexParserError.
with self.assertRaises(parser.FlexParserError):
@@ -476,6 +478,10 @@ class TestEnum(enum.Enum):
parser.convert_enum(enums.TransferType, "ACATS"),
enums.TransferType.ACATS,
)
+ self.assertEqual(
+ parser.convert_enum(enums.TransferType, "OTC"),
+ enums.TransferType.OTC,
+ )
def testMakeOptional(self):
"""make_optional() wraps converter functions to return None for empty string.
@@ -536,5 +542,239 @@ def testMakeOptional(self):
)
+class UnknownAttributeToleranceTestCase(unittest.TestCase):
+ """Tests for the unknown attribute tolerance feature.
+
+ This feature allows the parser to silently ignore unknown XML attributes
+ and element types, which is useful when Interactive Brokers adds new fields
+ to their exports.
+ """
+
+ def setUp(self):
+ """Ensure tolerance is off before each test."""
+ parser.disable_unknown_attribute_tolerance()
+
+ def tearDown(self):
+ """Ensure tolerance is off after each test."""
+ parser.disable_unknown_attribute_tolerance()
+
+ def test_tolerance_default_off(self):
+ """Tolerance is off by default."""
+ self.assertFalse(parser._UNKNOWN_ATTRIBUTE_TOLERANCE)
+
+ def test_enable_disable(self):
+ """enable/disable functions toggle the flag correctly."""
+ self.assertFalse(parser._UNKNOWN_ATTRIBUTE_TOLERANCE)
+ parser.enable_unknown_attribute_tolerance()
+ self.assertTrue(parser._UNKNOWN_ATTRIBUTE_TOLERANCE)
+ parser.disable_unknown_attribute_tolerance()
+ self.assertFalse(parser._UNKNOWN_ATTRIBUTE_TOLERANCE)
+
+ def test_tolerance_functions_accessible_from_package(self):
+ """The tolerance functions are accessible from the top-level ibflex package.
+
+ On the original upstream ibflex package these functions do not exist,
+ so calling ibflex.enable_unknown_attribute_tolerance() would raise
+ AttributeError, providing a clear version guard.
+ """
+ import ibflex
+ self.assertTrue(hasattr(ibflex, 'enable_unknown_attribute_tolerance'))
+ self.assertTrue(hasattr(ibflex, 'disable_unknown_attribute_tolerance'))
+ # Verify they are callable
+ self.assertTrue(callable(ibflex.enable_unknown_attribute_tolerance))
+ self.assertTrue(callable(ibflex.disable_unknown_attribute_tolerance))
+
+ def test_unknown_attr_raises_without_tolerance(self):
+ """Without tolerance, unknown XML attributes raise FlexParserError."""
+ # AccountInformation with an unknown attribute "newIBField"
+ elem = ET.fromstring(
+ ''
+ )
+ with self.assertRaises(parser.FlexParserError):
+ parser.parse_data_element(elem)
+
+ def test_unknown_attr_ignored_with_tolerance(self):
+ """With tolerance enabled, unknown XML attributes are silently ignored."""
+ parser.enable_unknown_attribute_tolerance()
+
+ elem = ET.fromstring(
+ ''
+ )
+ instance = parser.parse_data_element(elem)
+ self.assertIsInstance(instance, Types.AccountInformation)
+ self.assertEqual(instance.accountId, "U123456")
+ self.assertEqual(instance.currency, "USD")
+ # Unknown attributes are not present on the parsed object
+ self.assertFalse(hasattr(instance, 'newIBField'))
+ self.assertFalse(hasattr(instance, 'anotherNewField'))
+
+ def test_known_attrs_still_parsed_with_tolerance(self):
+ """With tolerance, known attributes are still correctly parsed."""
+ parser.enable_unknown_attribute_tolerance()
+
+ elem = ET.fromstring(
+ ''
+ )
+ instance = parser.parse_data_element(elem)
+ self.assertIsInstance(instance, Types.AccountInformation)
+ self.assertEqual(instance.accountId, "U123456")
+ self.assertEqual(instance.acctAlias, "test")
+ self.assertEqual(instance.currency, "USD")
+ self.assertEqual(instance.name, "Test User")
+ import datetime
+ self.assertEqual(instance.dateOpened, datetime.date(2020, 1, 15))
+
+ def test_disable_restores_strict_behavior(self):
+ """After disabling tolerance, unknown attributes raise errors again."""
+ parser.enable_unknown_attribute_tolerance()
+
+ elem = ET.fromstring(
+ ''
+ )
+ # Should succeed with tolerance on
+ instance = parser.parse_data_element(elem)
+ self.assertIsInstance(instance, Types.AccountInformation)
+
+ parser.disable_unknown_attribute_tolerance()
+
+ # Should fail with tolerance off
+ with self.assertRaises(parser.FlexParserError):
+ parser.parse_data_element(elem)
+
+ def test_unknown_element_type_raises_without_tolerance(self):
+ """Without tolerance, unknown XML element types raise AttributeError."""
+ elem = ET.fromstring('')
+ with self.assertRaises(AttributeError):
+ parser.parse_data_element(elem)
+
+ def test_unknown_element_type_returns_none_with_tolerance(self):
+ """With tolerance, unknown element types return None."""
+ parser.enable_unknown_attribute_tolerance()
+
+ elem = ET.fromstring('')
+ result = parser.parse_data_element(elem)
+ self.assertIsNone(result)
+
+ def test_unknown_elements_filtered_in_container(self):
+ """With tolerance, unknown element types are filtered out of containers."""
+ parser.enable_unknown_attribute_tolerance()
+
+ container = ET.Element("Trades")
+ # Add a known Trade element
+ ET.SubElement(container, "Trade", attrib={
+ "accountId": "U123456",
+ "currency": "USD",
+ "fxRateToBase": "1",
+ "assetCategory": "STK",
+ "symbol": "AAPL",
+ "description": "APPLE INC",
+ "conid": "265598",
+ "tradeID": "123",
+ "reportDate": "2020-01-15",
+ "tradeDate": "2020-01-15",
+ "quantity": "100",
+ "tradePrice": "150.00",
+ "tradeMoney": "15000.00",
+ "proceeds": "-15000.00",
+ "taxes": "0",
+ "ibCommission": "-1.00",
+ "ibCommissionCurrency": "USD",
+ "netCash": "-15001.00",
+ "buySell": "BUY",
+ })
+ # Add an unknown element type
+ ET.SubElement(container, "BrandNewTradeType", attrib={
+ "unknownField": "value"
+ })
+
+ result = parser.parse_element_container(container)
+ # Only the known Trade should be in the result
+ self.assertEqual(len(result), 1)
+ self.assertIsInstance(result[0], Types.Trade)
+
+ def test_full_parse_with_unknown_attrs(self):
+ """Full round-trip: parse a FlexQueryResponse with unknown attributes."""
+ parser.enable_unknown_attribute_tolerance()
+
+ xml_data = (
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ response = parser.parse(xml_data.encode())
+ self.assertIsInstance(response, Types.FlexQueryResponse)
+ self.assertEqual(response.queryName, "test")
+ self.assertEqual(len(response.FlexStatements), 1)
+ stmt = response.FlexStatements[0]
+ self.assertEqual(stmt.accountId, "U123456")
+
+ def test_full_parse_unknown_attrs_fails_without_tolerance(self):
+ """Without tolerance, unknown attributes in full parse raise errors."""
+ xml_data = (
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ with self.assertRaises(parser.FlexParserError):
+ parser.parse(xml_data.encode())
+
+ def test_unknown_contained_element_in_statement(self):
+ """With tolerance, unknown contained elements in FlexStatement are ignored."""
+ parser.enable_unknown_attribute_tolerance()
+
+ xml_data = (
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ ''
+ )
+ response = parser.parse(xml_data.encode())
+ self.assertIsInstance(response, Types.FlexQueryResponse)
+ self.assertEqual(len(response.FlexStatements), 1)
+ self.assertEqual(response.FlexStatements[0].accountId, "U123456")
+
+ def test_multiple_unknown_attrs_on_trade(self):
+ """Unknown attributes on Trade elements are ignored with tolerance."""
+ parser.enable_unknown_attribute_tolerance()
+
+ elem = ET.fromstring(
+ ''
+ )
+ instance = parser.parse_data_element(elem)
+ self.assertIsInstance(instance, Types.Trade)
+ self.assertEqual(instance.symbol, "AAPL")
+ self.assertEqual(instance.tradeID, "123")
+ self.assertFalse(hasattr(instance, 'newField1'))
+
+
if __name__ == '__main__':
unittest.main(verbosity=3)
diff --git a/tests/test_types.py b/tests/test_types.py
index bf53cf4..f1161ab 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -757,6 +757,35 @@ def testParse(self):
self.assertEqual(instance.assetCategory, enums.AssetClass.CASH)
+class TradeLiquidationForcedTestCase(unittest.TestCase):
+ """Test parsing of Trade with LF (Forced Liquidation) notes code."""
+ data = ET.fromstring(
+ ('')
+ )
+
+ def testParse(self):
+ instance = parser.parse_data_element(self.data)
+ self.assertIsInstance(instance, Types.Trade)
+ self.assertEqual(instance.currency, "USD")
+ self.assertEqual(instance.symbol, "AAPL")
+ self.assertEqual(instance.description, "APPLE INC")
+ self.assertEqual(instance.dateTime, datetime.datetime(2025, 1, 15, 14, 30, 45))
+ self.assertEqual(instance.tradeDate, datetime.date(2025, 1, 15))
+ self.assertEqual(instance.quantity, decimal.Decimal("10.0"))
+ self.assertEqual(instance.tradePrice, decimal.Decimal("150.0"))
+ self.assertEqual(instance.proceeds, decimal.Decimal("1500.0"))
+ self.assertEqual(instance.notes, (enums.Code.LIQUIDATION_FORCED, ))
+ self.assertEqual(instance.buySell, enums.BuySell.SELL)
+ self.assertEqual(instance.levelOfDetail, "EXECUTION")
+ self.assertEqual(instance.assetCategory, enums.AssetClass.STOCK)
+
+
class OptionEAETestCase(unittest.TestCase):
data = ET.fromstring(
('')
+ )
+
+ def testParse(self):
+ instance = parser.parse_data_element(self.data)
+ self.assertIsInstance(instance, Types.Transfer)
+ self.assertEqual(instance.accountId, "U1234567")
+ self.assertEqual(instance.assetCategory, enums.AssetClass.CRYPTOCURRENCY)
+ self.assertEqual(instance.symbol, "BTC.USD-PAXOS")
+ self.assertEqual(instance.description, "Bitcoin cryptocurrency")
+ self.assertEqual(instance.type, enums.TransferType.OTC)
+ self.assertEqual(instance.direction, enums.InOut.OUT)
+ self.assertEqual(instance.quantity, decimal.Decimal("-0.5"))
+ self.assertEqual(instance.positionAmount, decimal.Decimal("-20000"))
+ self.assertEqual(instance.positionAmountInBase, decimal.Decimal("-16000"))
+
+
+
class TransferLotTestCase(unittest.TestCase):
data = ET.fromstring(
('')
+ )
+
+ def testParse(self):
+ instance = parser.parse_data_element(self.data)
+ self.assertIsInstance(instance, Types.Trade)
+ self.assertEqual(instance.accountId, "U123456")
+ self.assertEqual(instance.currency, "USD")
+ self.assertEqual(instance.assetCategory, enums.AssetClass.STOCK)
+ self.assertEqual(instance.symbol, "TEST")
+ self.assertEqual(instance.initialInvestment, True)
+ self.assertEqual(instance.quantity, decimal.Decimal("100"))
+
+
+class EquitySummaryLiteSurchargeAccrualsTestCase(unittest.TestCase):
+ """Test case for EquitySummaryByReportDateInBase.liteSurchargeAccruals field.
+
+ Tests the fix for https://github.com/vroonhof/opensteuerauszug/issues/106
+ where liteSurchargeAccruals attribute was missing.
+ """
+ data = ET.fromstring(
+ ('')
+ )
+
+ def testParse(self):
+ instance = parser.parse_data_element(self.data)
+ self.assertIsInstance(instance, Types.EquitySummaryByReportDateInBase)
+ self.assertEqual(instance.accountId, "U123456")
+ self.assertEqual(instance.reportDate, datetime.date(2024, 1, 1))
+ self.assertEqual(instance.cash, decimal.Decimal("1000.00"))
+ self.assertEqual(instance.total, decimal.Decimal("1000.00"))
+ self.assertEqual(instance.liteSurchargeAccruals, decimal.Decimal("5.50"))
+
+
if __name__ == '__main__':
unittest.main(verbosity=3)