diff --git a/pp_terminal/commands/simulate_share_sell.py b/pp_terminal/commands/simulate_share_sell.py index 2199911..3f7c953 100644 --- a/pp_terminal/commands/simulate_share_sell.py +++ b/pp_terminal/commands/simulate_share_sell.py @@ -27,7 +27,7 @@ import typer from pandera.typing import DataFrame -from pp_terminal.data.filters import filter_by_account_and_security, filter_by_security, filter_by_account +from pp_terminal.data.filters import filter_by_security, filter_by_account from pp_terminal.domain.cost_basis import enrich_fifo_lots, finalize_sell_lots from pp_terminal.data.tax import load_prepaid_tax_data from pp_terminal.domain.sell_strategy import SellStrategy, FixedSharesStrategy, MinTaxStrategy @@ -85,9 +85,14 @@ def prepare_share_sell_df( # pylint: disable=too-many-arguments,too-many-positi raise InputError(f"No price data for: {', '.join(missing_prices)}") all_enriched = [] - for (acc_id, sec_id, _currency), _shares_held in holdings.items(): + seen_security_ids: set[str] = set() + for (_acc_id, sec_id, _currency), _shares_held in holdings.items(): + if sec_id in seen_security_ids: + continue + seen_security_ids.add(sec_id) + # All accounts needed so cost_basis.py can resolve TRANSFER_OUT → TRANSFER_IN pairs. transactions = snapshot.securities_account_transactions.pipe( - filter_by_account_and_security, security_id=sec_id, account_id=acc_id + filter_by_security, security_id=sec_id ) sale_price = price if price else latest_prices.loc[sec_id] enriched = enrich_fifo_lots( diff --git a/pp_terminal/commands/view_transactions.py b/pp_terminal/commands/view_transactions.py new file mode 100644 index 0000000..9f531e6 --- /dev/null +++ b/pp_terminal/commands/view_transactions.py @@ -0,0 +1,121 @@ +""" + Copyright (C) 2025-26 Dipl.-Ing. Christoph Massmann + + This file is part of pp-terminal. + + pp-terminal is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + pp-terminal is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with pp-terminal. If not, see . +""" + +from datetime import datetime +from typing import cast + +import pandas as pd +import typer +from typing_extensions import Annotated + +from pp_terminal.data.filters import ( + filter_by_account, filter_by_security, filter_by_type, + filter_earlier_than, filter_later_than, +) +from pp_terminal.domain.portfolio import Portfolio, get_security_by_id, resolve_security_id +from pp_terminal.domain.schemas import TransactionType +from pp_terminal.exceptions import InputError +from pp_terminal.output.strategy import OutputStrategy, Console +from pp_terminal.output.table_decorator import TableOptions +from pp_terminal.utils.helper import footer + +app = typer.Typer() +console = Console() + +_RESULT_COLUMNS = ['date', 'securityName', 'securityId', 'accountId', 'type', 'amount', 'shares', 'currency', 'fees', 'taxes'] + + +def prepare_transactions_df( # pylint: disable=too-many-arguments,too-many-positional-arguments + portfolio: Portfolio, + security_id: str | None = None, + account_id: str | None = None, + from_date: datetime | None = None, + to_date: datetime | None = None, + transaction_types: list[TransactionType] | None = None, +) -> pd.DataFrame: + df = portfolio.securities_account_transactions + + if security_id: + df = df.pipe(filter_by_security, security_id=security_id) + if account_id: + df = df.pipe(filter_by_account, account_id=account_id) + if from_date: + df = df.pipe(filter_later_than, target_date=from_date) + if to_date: + df = df.pipe(filter_earlier_than, target_date=to_date) + if transaction_types: + df = df.pipe(filter_by_type, transaction_types=transaction_types) + + if df.empty: + return pd.DataFrame(columns=_RESULT_COLUMNS) + + df = df.reset_index().sort_values('date') + df['securityName'] = df['securityId'].map( + lambda sid: get_security_by_id(portfolio, sid).name if sid and sid in portfolio.securities.index else '' + ) + + return df[_RESULT_COLUMNS] + + +def _parse_types(types: list[str] | None) -> list[TransactionType] | None: + if not types: + return None + result = [] + for t in types: + try: + result.append(TransactionType[t.upper()]) + except KeyError as e: + valid = [tt.name for tt in TransactionType] + raise InputError(f"Unknown transaction type '{t}'. Valid types: {', '.join(valid)}") from e + return result + + +@app.command(name="transactions") +def view_transactions( # pylint: disable=too-many-arguments,too-many-positional-arguments + ctx: typer.Context, + security: Annotated[str | None, typer.Argument(help="Security ISIN or ID (defaults to all)")] = None, + account_id: Annotated[str | None, typer.Option("--account-id", "-a", help="Account ID filter")] = None, + from_date: Annotated[datetime | None, typer.Option("--from", formats=["%Y-%m-%d"], help="Start date (inclusive)")] = None, + to_date: Annotated[datetime | None, typer.Option("--to", formats=["%Y-%m-%d"], help="End date (inclusive)")] = None, + types: Annotated[list[str] | None, typer.Option("--type", help="Transaction type filter (e.g. BUY, SELL, DIVIDENDS)")] = None, +) -> None: + """Show transactions with optional filters by security, account, date range, or type.""" + portfolio = cast(Portfolio, ctx.obj.portfolio) + output = cast(OutputStrategy, ctx.obj.output) + + security_id = None + if security: + try: + security_id = resolve_security_id(portfolio, security) + except InputError as e: + raise typer.BadParameter(str(e)) from e + + transaction_types = _parse_types(types) + + df = prepare_transactions_df(portfolio, security_id, account_id, from_date, to_date, transaction_types) + + if df.empty: + console.print(output.empty_result()) + return + + console.print(*output.result_table( + df, + TableOptions(title="Transactions", show_index=False, show_total=False) + )) + console.print(output.text(footer()), style="dim") diff --git a/pp_terminal/domain/cost_basis.py b/pp_terminal/domain/cost_basis.py index f51045f..1d979d6 100644 --- a/pp_terminal/domain/cost_basis.py +++ b/pp_terminal/domain/cost_basis.py @@ -19,6 +19,7 @@ from datetime import datetime import logging +from typing import Any import pandas as pd from pandera.typing import DataFrame @@ -37,7 +38,43 @@ def _filter_purchase_transactions(transactions: DataFrame[TransactionSchema]) -> return TransactionSchema.validate(valid_purchases) -def _get_remaining_lots_after_fifo_matching(transactions: DataFrame[TransactionSchema]) -> DataFrame[TaxLotSchema]: +def _build_transfer_in_lookup(transfer_in_transactions: DataFrame[TransactionSchema]) -> dict[tuple[object, ...], str]: + lookup: dict[tuple[object, ...], str] = {} + for (date, dest_account_id, security_id), row in transfer_in_transactions.iterrows(): + key = (date, security_id, round(float(row['shares']), 4)) + lookup[key] = dest_account_id + return lookup + + +def _consume_lots_fifo( + remaining_lots: list[dict[str, Any]], + account_id: str, + shares_to_match: float, + dest_account_id: str | None, +) -> tuple[float, list[dict[str, Any]]]: + transferred_lots: list[dict[str, Any]] = [] + for lot in remaining_lots: + if shares_to_match <= 0: + break + if lot['accountId'] != account_id: + continue + lot_shares = lot['shares'] + shares_from_lot = min(shares_to_match, lot_shares) + new_shares = lot_shares - shares_from_lot + shares_to_match -= shares_from_lot + + if dest_account_id and lot_shares > 0: + ratio = shares_from_lot / lot_shares + transferred_lots.append({**lot, 'accountId': dest_account_id, 'shares': shares_from_lot, 'fees': lot['fees'] * ratio}) + + if lot_shares > 0: + lot['fees'] = lot['fees'] * (new_shares / lot_shares) + lot['shares'] = new_shares + + return shares_to_match, transferred_lots + + +def _get_remaining_lots_after_fifo_matching(transactions: DataFrame[TransactionSchema]) -> DataFrame[TaxLotSchema]: # pylint: disable=too-many-locals """ Match all sell transactions to purchase lots using FIFO and return remaining lots. @@ -52,6 +89,11 @@ def _get_remaining_lots_after_fifo_matching(transactions: DataFrame[TransactionS for schema validation and interface consistency, internally we use list of dicts for ~10x faster mutation during the matching algorithm. DataFrame .loc[] access has significant overhead that doesn't add value for sequential state updates. + + Depot transfers (TRANSFER_OUT → TRANSFER_IN pairs, as recorded by Portfolio + Performance's "Wertpapierübertrag" feature) are handled by moving FIFO lots from the + source account to the destination account while preserving the original acquisition + cost. """ lots = _filter_purchase_transactions(transactions) lots['purchasePrice'] = lots['amount'].abs() / lots['shares'] # save actual market price per share @@ -62,40 +104,33 @@ def _get_remaining_lots_after_fifo_matching(transactions: DataFrame[TransactionS return TaxLotSchema.validate(lots) sell_transactions = transactions.pipe(filter_by_type, transaction_types=[TransactionType.SELL, TransactionType.DELIVERY_OUTBOUND]) - if sell_transactions.empty: - return TaxLotSchema.validate(lots) - - # Convert to list of dicts for fast mutation during FIFO matching - remaining_lots = lots.reset_index().to_dict('records') - sales_sorted = sell_transactions.sort_index(level='date') + transfer_out_transactions = transactions.pipe(filter_by_type, transaction_types=[TransactionType.TRANSFER_OUT]) + transfer_in_transactions = transactions.pipe(filter_by_type, transaction_types=[TransactionType.TRANSFER_IN]) - for (_date, account_id, _security_id), row in sales_sorted.iterrows(): - shares_to_match = float(row['shares']) + transfer_in_lookup = _build_transfer_in_lookup(transfer_in_transactions) if not transfer_in_transactions.empty else {} - # Match against lots in FIFO order (only from same account) - for lot in remaining_lots: - if shares_to_match <= 0: - break + outgoing_frames = [f for f in [sell_transactions, transfer_out_transactions] if not f.empty] + if not outgoing_frames: + return TaxLotSchema.validate(lots) + all_outgoing = pd.concat(outgoing_frames).sort_index(level='date') if len(outgoing_frames) > 1 else outgoing_frames[0].sort_index(level='date') - if lot['accountId'] != account_id: - continue + remaining_lots = lots.reset_index().to_dict('records') - # Consume shares from this lot - lot_shares = lot['shares'] - shares_from_lot = min(shares_to_match, lot_shares) - new_shares = lot_shares - shares_from_lot - shares_to_match -= shares_from_lot + for (_date, account_id, _security_id), row in all_outgoing.iterrows(): + is_transfer_out = str(row['type']) == TransactionType.TRANSFER_OUT.name + shares_to_sell = float(row['shares']) - # Proportionally reduce fees based on remaining shares - if lot_shares > 0: - lot['fees'] = lot['fees'] * (new_shares / lot_shares) + dest_account_id = transfer_in_lookup.get((_date, _security_id, round(shares_to_sell, 4))) if is_transfer_out else None + if is_transfer_out and dest_account_id is None: + log.warning('No matching TRANSFER_IN for TRANSFER_OUT of %.4f shares of %s on %s — lots will be dropped', shares_to_sell, _security_id, _date) - lot['shares'] = new_shares + unmatched, transferred_lots = _consume_lots_fifo(remaining_lots, account_id, shares_to_sell, dest_account_id) + if is_transfer_out: + remaining_lots.extend(transferred_lots) - if shares_to_match > 0.0001: # Allow small floating point errors - log.warning('Sale of %.8f shares for security %s could not be fully matched to purchase lots', shares_to_match, _security_id) + if unmatched > 0.0001: # Allow small floating point errors + log.warning('Sale/transfer of %.8f shares for security %s could not be fully matched to purchase lots', unmatched, _security_id) - # Filter out exhausted lots and convert back to DataFrame remaining_lots = [lot for lot in remaining_lots if lot['shares'] > 0.0001] if not remaining_lots: return TaxLotSchema.empty() diff --git a/pp_terminal/domain/portfolio.py b/pp_terminal/domain/portfolio.py index 7e9f044..3ea7392 100644 --- a/pp_terminal/domain/portfolio.py +++ b/pp_terminal/domain/portfolio.py @@ -139,6 +139,16 @@ def get_securities_account_by_id(portfolio: Portfolio, account_id: str) -> Accou return Account(**account_data) +def resolve_security_id(portfolio: Portfolio, security: str) -> str: + """Resolve an ISIN or securityId UUID to an internal securityId. Raises InputError if not found.""" + isin_matches = portfolio.securities.index[portfolio.securities['isin'] == security] + if len(isin_matches) == 1: + return str(isin_matches[0]) + if security in portfolio.securities.index: + return security + raise InputError(f"Security '{security}' not found by ID or ISIN") + + def get_security_by_id(portfolio: Portfolio, security_id: str) -> Security: if security_id not in portfolio.securities.index: raise InputError(f"Security '{security_id}' not found in portfolio") diff --git a/pp_terminal/mcp_server.py b/pp_terminal/mcp_server.py index 0536dcb..22caf95 100644 --- a/pp_terminal/mcp_server.py +++ b/pp_terminal/mcp_server.py @@ -32,10 +32,11 @@ from pp_terminal.commands.view_accounts import prepare_accounts_df from pp_terminal.commands.view_securities import prepare_securities_df from pp_terminal.commands.view_taxonomies import prepare_taxonomies_df +from pp_terminal.commands.view_transactions import prepare_transactions_df from pp_terminal.data.filters import clean_for_display from pp_terminal.data.tax import load_prepaid_tax_data from pp_terminal.domain.portfolio import Portfolio -from pp_terminal.domain.schemas import AccountType +from pp_terminal.domain.schemas import AccountType, TransactionType from pp_terminal.data.pp_portfolio_builder import CachedPpPortfolioBuilder from pp_terminal.exceptions import InputError from pp_terminal.utils.cache import checksum @@ -191,6 +192,48 @@ def query_accounts( df = prepare_accounts_df(portfolio, config, by_date, parsed_type) return _clean_records(df.reset_index()) + @mcp.tool() + def query_transactions( + security: str | None = None, + account_id: str | None = None, + from_date: str | None = None, + to_date: str | None = None, + transaction_type: str | None = None, + ) -> list[dict[str, Any]]: + """List securities account transactions with optional filters. + + Each row is one transaction showing: date, securityName, securityId, accountId, + type, amount, shares, currency, fees, taxes. + + Args: + security: ISIN or securityId UUID (defaults to all securities) + account_id: Filter by account ID + from_date: Start date as ISO string, inclusive (e.g. '2023-01-01') + to_date: End date as ISO string, inclusive (e.g. '2023-12-31') + transaction_type: Transaction type filter (e.g. 'BUY', 'SELL', 'DIVIDENDS', 'TRANSFER_IN') + """ + portfolio = _ensure_fresh_portfolio() + + security_id = _resolve_security(portfolio, security) if security else None + + parsed_types = None + if transaction_type: + try: + parsed_types = [TransactionType[transaction_type.upper()]] + except KeyError as e: + valid = [tt.name for tt in TransactionType] + raise InputError(f"Unknown transaction type '{transaction_type}'. Valid types: {', '.join(valid)}") from e + + df = prepare_transactions_df( + portfolio, + security_id=security_id, + account_id=account_id, + from_date=datetime.fromisoformat(from_date) if from_date else None, + to_date=datetime.fromisoformat(to_date) if to_date else None, + transaction_types=parsed_types, + ) + return _clean_records(df) + @mcp.tool() def simulate_vap( year: int | None = None, diff --git a/pyproject.toml b/pyproject.toml index 3bbc130..3618198 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ dependencies = [ "mcp (>=1.0.0,<2.0.0)", ] scripts = { pp-terminal = "pp_terminal.main:app" } -entry-points = { "pp_terminal.commands" = { "view.accounts" = "pp_terminal.commands.view_accounts:app", "view.securities" = "pp_terminal.commands.view_securities:app", "view.taxonomies" = "pp_terminal.commands.view_taxonomies:app", "simulate.vorabpauschale" = "pp_terminal.commands.simulate_vap:app", "simulate.interest" = "pp_terminal.commands.simulate_interest:app", "simulate.share-sell" = "pp_terminal.commands.simulate_share_sell:app", "validate" = "pp_terminal.commands.validate:app", "export" = "pp_terminal.commands.export:app" } } +entry-points = { "pp_terminal.commands" = { "view.accounts" = "pp_terminal.commands.view_accounts:app", "view.securities" = "pp_terminal.commands.view_securities:app", "view.taxonomies" = "pp_terminal.commands.view_taxonomies:app", "view.transactions" = "pp_terminal.commands.view_transactions:app", "simulate.vorabpauschale" = "pp_terminal.commands.simulate_vap:app", "simulate.interest" = "pp_terminal.commands.simulate_interest:app", "simulate.share-sell" = "pp_terminal.commands.simulate_share_sell:app", "validate" = "pp_terminal.commands.validate:app", "export" = "pp_terminal.commands.export:app" } } dynamic = ["version"] diff --git a/tests/commands/test_integration.py b/tests/commands/test_integration.py index 7c6105f..546f1c2 100644 --- a/tests/commands/test_integration.py +++ b/tests/commands/test_integration.py @@ -90,6 +90,25 @@ def test_view_securities_csv_output(request: TopRequest) -> None: assert result.output == expected_output +def test_view_transactions_csv_output(request: TopRequest) -> None: + runner = CliRunner() + fixtures_dir = request.path.parent.parent / 'fixtures' + xml_file = fixtures_dir / 'kommer.ids.xml' + golden_file = fixtures_dir / 'expected_view_transactions_kommer.csv' + + result = runner.invoke(app, [ + '--file', str(xml_file), + '--output', 'csv', + '--no-cache', + 'view', 'transactions' + ]) + + assert result.exit_code == 0, f"Command failed with: {result.output}" + + expected_output = Path(golden_file).read_text(encoding='utf-8') + assert result.output == expected_output + + def test_view_accounts_json_output(request: TopRequest) -> None: runner = CliRunner() fixtures_dir = request.path.parent.parent / 'fixtures' diff --git a/tests/commands/test_view_transactions.py b/tests/commands/test_view_transactions.py new file mode 100644 index 0000000..0bda519 --- /dev/null +++ b/tests/commands/test_view_transactions.py @@ -0,0 +1,106 @@ +""" + Copyright (C) 2025-26 Dipl.-Ing. Christoph Massmann + + This file is part of pp-terminal. + + pp-terminal is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + pp-terminal is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with pp-terminal. If not, see . +""" + +from datetime import datetime + +import pytest + +from pp_terminal.commands.view_transactions import prepare_transactions_df +from pp_terminal.domain.portfolio import Portfolio +from pp_terminal.domain.schemas import TransactionType + + +def test_returns_all_transactions(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells) + assert len(df) == 6 # 4 purchases + 2 sells + + +def test_filter_by_security(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells, security_id='sec-1') + assert len(df) == 6 + assert (df['securityId'] == 'sec-1').all() + + +def test_filter_by_account(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells, account_id='acc-1') + assert len(df) == 4 # 3 BUYs in acc-1 + 1 SELL in acc-1 + assert (df['accountId'] == 'acc-1').all() + + +def test_filter_by_date_range(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df( + portfolio_with_sells, + from_date=datetime(2021, 1, 1), + to_date=datetime(2022, 12, 31) + ) + assert len(df) == 2 # 2021-03-10 DELIVERY_INBOUND + 2022-01-05 BUY + assert all(datetime(2021, 1, 1) <= d <= datetime(2022, 12, 31) for d in df['date']) + + +def test_filter_by_type(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells, transaction_types=[TransactionType.BUY]) + assert len(df) == 3 + assert (df['type'] == TransactionType.BUY.name).all() + + +def test_filter_by_multiple_types(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells, transaction_types=[TransactionType.SELL, TransactionType.DELIVERY_OUTBOUND]) + assert len(df) == 2 + + +def test_empty_result_for_unknown_security(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells, security_id='nonexistent') + assert df.empty + + +def test_security_name_column_populated(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells) + assert 'securityName' in df.columns + assert (df['securityName'] == 'Test Security').all() + + +def test_result_columns(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells) + expected_columns = ['date', 'securityName', 'securityId', 'accountId', 'type', 'amount', 'shares', 'currency', 'fees', 'taxes'] + assert list(df.columns) == expected_columns + + +def test_sorted_by_date(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells) + assert list(df['date']) == sorted(df['date']) + + +def test_from_date_only(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells, from_date=datetime(2023, 1, 1)) + assert len(df) == 1 # only 2023-06-15 DELIVERY_OUTBOUND + assert df.iloc[0]['date'] == datetime(2023, 6, 15) + + +def test_to_date_only(portfolio_with_sells: Portfolio) -> None: + df = prepare_transactions_df(portfolio_with_sells, to_date=datetime(2020, 6, 20)) + assert len(df) == 2 # 2020-01-15 BUY + 2020-06-20 BUY + + +@pytest.mark.parametrize("security_id,account_id,expected_count", [ + ('sec-1', 'acc-1', 4), # all acc-1 transactions + ('sec-1', 'acc-2', 2), # DELIVERY_INBOUND + DELIVERY_OUTBOUND in acc-2 +]) +def test_combined_filters(portfolio_with_sells: Portfolio, security_id: str, account_id: str, expected_count: int) -> None: + df = prepare_transactions_df(portfolio_with_sells, security_id=security_id, account_id=account_id) + assert len(df) == expected_count diff --git a/tests/fixtures/expected_view_transactions_kommer.csv b/tests/fixtures/expected_view_transactions_kommer.csv new file mode 100644 index 0000000..9fb51c9 --- /dev/null +++ b/tests/fixtures/expected_view_transactions_kommer.csv @@ -0,0 +1,17 @@ +date,securityName,securityId,accountId,type,amount,shares,currency,fees,taxes +2019-01-07,iShares Core Euro Government Bond UCITS ETF (Dist),99b9419f-8c70-422e-8e8e-05eadb4507ec,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-2936.88,24.00,EUR,9.95,0.00 +2019-01-07,iShares Core MSCI Europe UCITS ETF EUR (Dist),c770a389-0a84-442c-ad85-2a58c3066924,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-1320.35,61.00,EUR,9.90,0.00 +2019-01-07,iShares MSCI EM UCITS ETF (Dist),47094920-535c-4508-9a92-80c01933f567,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-2122.72,64.00,EUR,0.00,9.95 +2019-01-07,iShares MSCI North America UCITS ETF,daab10fd-c3fb-4430-a368-0ce0cdf551c8,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-1635.28,39.00,EUR,0.00,9.95 +2019-01-07,Lyxor MSCI Pacific UCITS ETF,ff0a2b77-9749-45b0-8333-cb1d9787812c,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-481.45,10.00,EUR,4.95,0.00 +2019-01-08,iShares Diversified Commodity Swap UCITS ETF,97000a3b-0a3d-4779-ad6c-1234bfea5e72,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-1408.43,331.00,EUR,0.00,9.95 +2020-01-25,Bitcoin,4fcafd74-6fe9-4699-8fe7-8ec44136392e,57ede399-7ef8-4696-a874-1f425e25d1f5,DELIVERY_INBOUND,10.00,0.00,EUR,0.00,0.00 +2020-01-25,Bitcoin,4fcafd74-6fe9-4699-8fe7-8ec44136392e,57ede399-7ef8-4696-a874-1f425e25d1f5,DELIVERY_INBOUND,100.00,0.01,EUR,0.00,0.00 +2020-11-21,Bitcoin,4fcafd74-6fe9-4699-8fe7-8ec44136392e,57ede399-7ef8-4696-a874-1f425e25d1f5,DELIVERY_OUTBOUND,100.00,0.01,EUR,0.00,0.00 +2021-01-11,Bitcoin,4fcafd74-6fe9-4699-8fe7-8ec44136392e,57ede399-7ef8-4696-a874-1f425e25d1f5,DELIVERY_OUTBOUND,195.62,0.01,EUR,0.00,0.00 +2021-02-16,Bitcoin,4fcafd74-6fe9-4699-8fe7-8ec44136392e,57ede399-7ef8-4696-a874-1f425e25d1f5,DELIVERY_INBOUND,195.62,0.00,EUR,0.00,0.00 +2021-09-20,Bitcoin,4fcafd74-6fe9-4699-8fe7-8ec44136392e,57ede399-7ef8-4696-a874-1f425e25d1f5,DELIVERY_OUTBOUND,175.10,0.00,EUR,0.00,0.00 +2021-11-01,Mercedes Benz Group AG (former Daimler),15b10f6f-15a9-4129-83cb-a99dc43d9fa7,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-869.80,10.00,EUR,6.00,0.00 +2022-04-27,Amazon.com Inc.,3cfa101b-0558-4ec4-a9e5-baab4eeddb2a,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-2670.00,20.00,USD,6.00,0.00 +2022-06-13,Bitcoin,4fcafd74-6fe9-4699-8fe7-8ec44136392e,57ede399-7ef8-4696-a874-1f425e25d1f5,DELIVERY_INBOUND,47.62,0.00,EUR,0.00,0.00 +2022-10-01,NVIDIA Corp.,22100879-5a9f-4873-a376-8383ed635627,dc6fac85-6c6e-47f1-a968-2b5b84d90997,BUY,-256.40,2.00,EUR,6.00,0.00