Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 90 additions & 20 deletions pytr/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ class PPEventType(EventType):
SWAP = "SWAP"
TAXES = "TAXES"
TAX_REFUND = "TAX_REFUND"
TRANSFER_IN = "TRANSFER_IN" # Currently not mapped to
TRANSFER_OUT = "TRANSFER_OUT" # Currently not mapped to
TRANSFER_IN = "TRANSFER_IN"
TRANSFER_OUT = "TRANSFER_OUT"


tr_event_type_mapping = {
Expand Down Expand Up @@ -107,6 +107,9 @@ class PPEventType(EventType):
"Sparplan ausgeführt": ConditionalEventType.TRADE_INVOICE,
"Stop-Sell-Order": ConditionalEventType.TRADE_INVOICE,
"Verkaufsorder": ConditionalEventType.TRADE_INVOICE,
# Transfers
"Aktien erhalten": PPEventType.TRANSFER_IN,
"Aktien übertragen": PPEventType.TRANSFER_OUT,
}

title_event_type_mapping = {
Expand Down Expand Up @@ -156,6 +159,9 @@ class PPEventType(EventType):
"Stop-Sell-Order": ConditionalEventType.TRADE_INVOICE,
"Verkaufsorder": ConditionalEventType.TRADE_INVOICE,
"Wertlos": ConditionalEventType.TRADE_INVOICE,
# Transfers
"Aktien erhalten": PPEventType.TRANSFER_IN,
"Aktien übertragen": PPEventType.TRANSFER_OUT,
}

events_known_ignored = [
Expand Down Expand Up @@ -335,7 +341,7 @@ def from_dict(cls, event_dict: Dict[Any, Any]):
event_type = timeline_legacy_migrated_events_title_type_mapping.get(title)
if event_type is None:
event_type = timeline_legacy_migrated_events_subtitle_type_mapping.get(subtitle)
if event_type is None:
if event_type is None and subtitle != "Wertpapiertransfer":
for item in event_dict.get("details", {}).get("sections", []):
ititle = item.get("title", "")
if ititle.startswith("Du hast "):
Expand Down Expand Up @@ -379,6 +385,18 @@ def from_dict(cls, event_dict: Dict[Any, Any]):
event_type = title_event_type_mapping.get(title, None)
if event_type is None:
event_type = subtitle_event_type_mapping.get(subtitle, None)
# Handle "Wertpapiertransfer" which can be either TRANSFER_IN or TRANSFER_OUT
if event_type is None and subtitle == "Wertpapiertransfer" and sections:
Comment thread
MedAzizKhayati marked this conversation as resolved.
for item in sections:
ititle = item.get("title")
if ititle is None:
continue
if "Aktien erhalten" in ititle or "erhalten" in ititle:
event_type = PPEventType.TRANSFER_IN
break
elif "Aktien gesendet" in ititle or "gesendet" in ititle:
event_type = PPEventType.TRANSFER_OUT
break
if event_type == ConditionalEventType.PRIVATE_MARKETS_ORDER and subtitle == "Vorabpauschale":
event_type = PPEventType.TAXES
if event_type is None and uebersicht_dict:
Expand Down Expand Up @@ -459,6 +477,13 @@ def from_dict(cls, event_dict: Dict[Any, Any]):

if event_type is not None and event_dict.get("status", "").lower() == "canceled":
event_type = None
elif event_type is not None and sections:
for section in sections:
if section.get("type") == "header":
header_status = section.get("data", {}).get("status", "").lower()
if header_status == "canceled":
event_type = None
break
elif (
event_type is None
and eventTypeStr not in events_known_ignored
Expand Down Expand Up @@ -512,23 +537,13 @@ def from_dict(cls, event_dict: Dict[Any, Any]):
PPEventType.SWAP,
PPEventType.TAXES,
]:
# Parse ISIN
for section in sections:
action = section.get("action", None)
if action and action.get("type", {}) == "instrumentDetail":
isin = section.get("action", {}).get("payload")
break
if section.get("type", {}) == "header":
isin = section.get("data", {}).get("icon")
if isinstance(isin, dict):
isin = isin.get("asset", "")
break
if isin is None:
isin = event_dict.get("icon", "")
isin = isin[isin.find("/") + 1 :]
isin = isin.split("/", 1)[0]
isin = cls._parse_isin(event_dict)

shares, shares2, value, note = cls._parse_shares_value_note(event_type, event_dict)
elif event_type in [PPEventType.TRANSFER_IN, PPEventType.TRANSFER_OUT]:
isin = cls._parse_isin(event_dict)
shares = cls._parse_transfer_shares(event_dict)
value = 0 # Transfers have no monetary value
else:
value = v if (v := event_dict.get("amount", {}).get("value", None)) is not None and v != 0.0 else None

Expand Down Expand Up @@ -607,12 +622,14 @@ def _parse_shares_value_note(

sections = event_dict.get("details", {}).get("sections", [{}])

transaction_dict = next(filter(lambda x: x.get("title") in ["Transaktion", "Geschäft"], sections), None)
transaction_dict = next(
filter(lambda x: x.get("title") in ["Transaktion", "Geschäft", "Transaction"], sections), None
)
if transaction_dict:
# old style event
dump_dict["maintitle"] = transaction_dict["title"]
data = transaction_dict.get("data", [{}])
shares_dict = next(filter(lambda x: x["title"] in ["Aktien", "Anteile"], data), None)
shares_dict = next(filter(lambda x: x["title"] in ["Aktien", "Anteile", "Shares"], data), None)

uebersicht_dict = next(filter(lambda x: x.get("title") in ["Übersicht", "Overview"], sections), None)
if uebersicht_dict:
Expand Down Expand Up @@ -759,6 +776,59 @@ def _parse_card_note(event_dict: Dict[Any, Any]) -> Optional[str]:

return None

@staticmethod
def _parse_isin(event_dict: Dict[Any, Any]) -> Optional[str]:
"""Parses the ISIN from a transfer or corporate-action event"""
sections = event_dict.get("details", {}).get("sections", [{}])

for section in sections:
action = section.get("action", None)
if action and action.get("type", {}) == "instrumentDetail":
isin = action.get("payload")
break
if section.get("type", {}) == "header":
isin = section.get("data", {}).get("icon")
if isinstance(isin, dict):
isin = isin.get("asset", "")
break
else:
isin = event_dict.get("icon", "")

if not isin:
return None

isin = isin[isin.find("/") + 1 :]
return isin.split("/", 1)[0]

@classmethod
def _parse_transfer_shares(cls, event_dict: Dict[Any, Any]) -> Optional[float]:
"""Parses the number of shares from a transfer event

Args:
event_dict (Dict[Any, Any]): The event dictionary

Returns:
Optional[float]: The number of shares transferred
"""
sections = event_dict.get("details", {}).get("sections", [{}])

# Try to find shares in "Transaction" / "Transaktion" section (for TRANSFER_OUT)
transaction_dict = next(filter(lambda x: x.get("title") in ["Transaction", "Transaktion"], sections), None)
if transaction_dict:
for item in transaction_dict.get("data", []):
if item.get("title") in ["Shares", "Aktien"]:
return cls._parse_float_from_text_value(item.get("detail", {}).get("text", ""), {}, "en")

# Try to find shares in "Übersicht" / "Overview" section (for TRANSFER_IN and Wertpapiertransfer)
uebersicht_dict = next(filter(lambda x: x.get("title") in ["Übersicht", "Overview"], sections), None)
if uebersicht_dict:
for item in uebersicht_dict.get("data", []):
# "Aktien" for newer events, "Anteile" for older Wertpapiertransfer events
if item.get("title") in ["Aktien", "Shares", "Anteile"]:
return cls._parse_float_from_text_value(item.get("detail", {}).get("text", ""), {}, "en")

return None

@staticmethod
def _parse_float_from_text_value(
unparsed_val: str,
Expand Down
117 changes: 117 additions & 0 deletions tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,123 @@ def test_events():
}
],
},
{
"filename": "transfer_out.json",
"event_type": PPEventType.TRANSFER_OUT,
"title": "Novo-Nordisk (B)",
"isin": "DK0062498333",
"shares": 42.0,
"value": 0,
"transactions": [
{
"Datum": "2025-09-18T17:03:45",
"Typ": "Umbuchung (Ausgang)",
"Wert": 0,
"Notiz": "Novo-Nordisk (B)",
"ISIN": "DK0062498333",
"Stück": 42.0,
}
],
},
{
"filename": "transfer_in.json",
"event_type": PPEventType.TRANSFER_IN,
"title": "British American Tobacco",
"isin": "GB0002875804",
"shares": 1.0,
"value": 0,
"transactions": [
{
"Datum": "2024-06-14T16:40:07",
"Typ": "Umbuchung (Eingang)",
"Wert": 0,
"Notiz": "British American Tobacco",
"ISIN": "GB0002875804",
"Stück": 1.0,
}
],
},
{
"filename": "wertpapiertransfer_in.json",
"event_type": PPEventType.TRANSFER_IN,
"title": "Metro",
"isin": "DE000BFB0019",
"shares": 76.0,
"value": 0,
"transactions": [
{
"Datum": "2023-06-13T20:38:45",
"Typ": "Umbuchung (Eingang)",
"Wert": 0,
"Notiz": "Metro",
"ISIN": "DE000BFB0019",
"Stück": 76.0,
}
],
},
{
"filename": "wertpapiertransfer_out.json",
"event_type": PPEventType.TRANSFER_OUT,
"title": "Netflix",
"isin": "US64110L1061",
"shares": 4.0,
"value": 0,
"transactions": [
{
"Datum": "2024-03-15T14:22:33",
"Typ": "Umbuchung (Ausgang)",
"Wert": 0,
"Notiz": "Netflix",
"ISIN": "US64110L1061",
"Stück": 4.0,
}
],
},
{
"filename": "wertpapiertransfer_in_legacy.json",
"event_type": PPEventType.TRANSFER_IN,
"title": "Metro",
"isin": "DE000BFB0019",
"shares": 76.0,
"value": 0,
"transactions": [
{
"Datum": "2022-04-10T10:15:30",
"Typ": "Umbuchung (Eingang)",
"Wert": 0,
"Notiz": "Metro",
"ISIN": "DE000BFB0019",
"Stück": 76.0,
}
],
},
{
"filename": "wertpapiertransfer_out_legacy.json",
"event_type": PPEventType.TRANSFER_OUT,
"title": "Netflix",
"isin": "US64110L1061",
"shares": 4.0,
"value": 0,
"transactions": [
{
"Datum": "2022-05-20T16:45:00",
"Typ": "Umbuchung (Ausgang)",
"Wert": 0,
"Notiz": "Netflix",
"ISIN": "US64110L1061",
"Stück": 4.0,
}
],
},
{
"filename": "transfer_out_cancelled.json",
"event_type": None,
"title": "MSCI World USD (Dist)",
"isin": None,
"shares": None,
"value": None,
"transactions": [],
},
{
"filename": "payment_inbound_credit_card.json",
"event_type": PPEventType.DEPOSIT,
Expand Down
93 changes: 93 additions & 0 deletions tests/transfer_in.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
{
"id": "c511b534-0c1a-4390-ac9c-8572eb47430c",
"timestamp": "2024-06-14T16:40:07.424+0000",
"title": "British American Tobacco",
"icon": "logos/GB0002875804/v2",
"subtitle": "Aktien erhalten",
"action": {
"type": "timelineDetail",
"payload": "c511b534-0c1a-4390-ac9c-8572eb47430c"
},
"source": "timelineActivity",
"details": {
"id": "c511b534-0c1a-4390-ac9c-8572eb47430c",
"sections": [
{
"title": "Du hast Aktien im Wert von 1.0 erhalten",
"data": {
"icon": {
"asset": "logos/GB0002875804/v2",
"badge": null
},
"timestamp": "2024-06-14T16:40:07.424Z",
"status": "executed"
},
"action": {
"payload": "GB0002875804",
"type": "instrumentDetail"
},
"type": "header"
},
{
"title": "Übersicht",
"data": [
{
"title": "Status",
"detail": {
"text": "Abgeschlossen",
"functionalStyle": "EXECUTED",
"type": "status"
},
"style": "plain"
},
{
"title": "Auftragsart",
"detail": {
"text": "Wertpapierübertrag",
"displayValue": {
"text": "Wertpapierübertrag"
},
"type": "text"
},
"style": "plain"
},
{
"title": "Aktien",
"detail": {
"text": "1.0",
"displayValue": {
"text": "1.0"
},
"type": "text"
},
"style": "plain"
},
{
"title": "Asset",
"detail": {
"text": "British American Tobacco",
"displayValue": {
"text": "British American Tobacco"
},
"type": "text"
},
"style": "plain"
}
],
"type": "table"
},
{
"title": "Dokumente",
"data": [
{
"title": "Ausführungsmitteilung",
"detail": "18.06.2024",
"id": "c511b534-0c1a-4390-ac9c-8572eb47430c",
"postboxType": "TRANSFER_INVOICE"
}
],
"type": "documents"
}
]
}
}
Loading