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)