From 6f4650a9cab64a60288b7a85ea592c8687284fc4 Mon Sep 17 00:00:00 2001 From: TiloHeidasch Date: Wed, 7 Oct 2026 17:17:11 +0200 Subject: [PATCH 1/3] Add read-only fund details command --- pytr/fund_details.py | 270 +++++++++++++++++++++++++++++++++++++ pytr/main.py | 30 +++++ tests/test_fund_details.py | 238 ++++++++++++++++++++++++++++++++ 3 files changed, 538 insertions(+) create mode 100644 pytr/fund_details.py create mode 100644 tests/test_fund_details.py diff --git a/pytr/fund_details.py b/pytr/fund_details.py new file mode 100644 index 0000000..e23851e --- /dev/null +++ b/pytr/fund_details.py @@ -0,0 +1,270 @@ +import argparse +import asyncio +import re +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from typing import Any + +from pytr.rates import ISIN_RE + +DEFAULT_TIMEOUT = 5 + + +class FundDetailsError(ValueError): + """A concise, user-facing error while reading fund details.""" + + +def normalize_isin(value: str) -> str: + """Normalize and validate one ISIN without doing any I/O.""" + isin = value.upper() + if not ISIN_RE.fullmatch(isin): + raise ValueError(f"Invalid ISIN: {value}") + return isin + + +def parse_isin(value: str) -> str: + """argparse type for the fund-details ISIN argument.""" + try: + return normalize_isin(value) + except ValueError as error: + raise argparse.ArgumentTypeError(str(error)) from None + + +@dataclass(frozen=True) +class FundDetailsResult: + metadata: Mapping[str, Any] + exchange: str + bid: Decimal | None + ask: Decimal | None + quote_status: str + quote_timestamp: Any = None + + @property + def quote_available(self) -> bool: + """Compatibility view for callers that only need the fully available state.""" + return self.quote_status == "available" + + @property + def spread(self) -> Decimal | None: + if self.quote_status != "available" or self.bid is None or self.ask is None: + return None + if ( + not self.bid.is_finite() + or not self.ask.is_finite() + or self.bid < 0 + or self.ask < 0 + or self.ask < self.bid + ): + return None + return self.ask - self.bid + + +def _path(data: Mapping[str, Any], *keys: str) -> Any: + value: Any = data + for key in keys: + if not isinstance(value, Mapping): + return None + value = value.get(key) + return value + + +def _decimal(value: Any) -> Decimal | None: + if value is None or isinstance(value, bool): + return None + try: + number = value if isinstance(value, Decimal) else Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return None + return number if number.is_finite() else None + + +def _quote_value(payload: Mapping[str, Any], side: str) -> Any: + value = payload.get(side) + if isinstance(value, Mapping): + return value.get("price") + return value + + +def _quote_timestamp(payload: Mapping[str, Any]) -> Any: + for side in ("bid", "ask", "last"): + value = payload.get(side) + if isinstance(value, Mapping) and "time" in value and value["time"] not in (None, ""): + return value["time"] + return None + + +def _decimal_text(value: Decimal) -> str: + return format(value.normalize(), "f") + + +def _percentage(value: Any, fraction: bool = False) -> str: + number = _decimal(value) + if number is None: + return "n/a" + if fraction: + number *= Decimal("100") + return f"{_decimal_text(number)}%" + + +async def _receive_matching(tr, subscription_id: Any, subscription_type: str, timeout: Any) -> Any: + async def receive() -> Any: + while True: + response_id, subscription, response = await tr.recv() + if response_id != subscription_id: + continue + if not isinstance(subscription, Mapping) or subscription.get("type") != subscription_type: + raise FundDetailsError("Unexpected subscription response.") + return response + + return await asyncio.wait_for(receive(), timeout=timeout) + + +async def fetch_fund_details( + tr, + isin: str, + exchange: str | None = None, + timeout: Any = DEFAULT_TIMEOUT, +) -> FundDetailsResult: + """Read instrument metadata and one exchange ticker, without other API calls.""" + normalized_isin = normalize_isin(isin) + try: + timeout_value = Decimal(str(timeout)) + except (InvalidOperation, ValueError, TypeError): + timeout_value = None + if timeout_value is None or not timeout_value.is_finite() or timeout_value <= 0: + raise ValueError("The timeout must be finite and greater than zero.") + + metadata_subscription_id: str | None = None + ticker_subscription_id: str | None = None + try: + try: + metadata_subscription_id = await tr.instrument_details(normalized_isin) + metadata = await _receive_matching( + tr, + metadata_subscription_id, + "instrument", + timeout, + ) + except FundDetailsError: + raise + except Exception as error: + raise FundDetailsError("Could not fetch instrument metadata.") from error + + if not isinstance(metadata, Mapping): + raise FundDetailsError("Could not fetch instrument metadata.") + + exchange_ids = metadata.get("exchangeIds") + if not isinstance(exchange_ids, list) or not exchange_ids or not all(isinstance(item, str) for item in exchange_ids): + raise FundDetailsError("Instrument metadata contains no exchange.") + + selected_exchange = exchange if exchange is not None else exchange_ids[0] + if selected_exchange not in exchange_ids: + raise FundDetailsError(f"Unknown exchange: {selected_exchange}") + + bid: Decimal | None = None + ask: Decimal | None = None + quote_status = "unavailable" + quote_timestamp: Any = None + try: + ticker_subscription_id = await tr.ticker(normalized_isin, exchange=selected_exchange) + ticker = await _receive_matching(tr, ticker_subscription_id, "ticker", timeout) + if isinstance(ticker, Mapping): + bid = _decimal(_quote_value(ticker, "bid")) + ask = _decimal(_quote_value(ticker, "ask")) + quote_timestamp = _quote_timestamp(ticker) + if bid is not None and ask is not None and bid >= 0 and ask >= 0 and ask >= bid: + quote_status = "available" + else: + quote_status = "partial" + except Exception: + # Metadata is still useful when the quote subscription times out or fails. + bid = None + ask = None + quote_status = "unavailable" + quote_timestamp = None + + return FundDetailsResult(metadata, selected_exchange, bid, ask, quote_status, quote_timestamp) + finally: + try: + for subscription_id in (ticker_subscription_id, metadata_subscription_id): + if subscription_id is not None: + try: + await tr.unsubscribe(subscription_id) + except Exception: + # Cleanup must not prevent the websocket from being closed. + pass + finally: + await tr.close() + + +def _display(value: Any) -> str: + if value is None or value == "": + return "n/a" + return str(value) + + +def _market_cap(metadata: Mapping[str, Any]) -> str: + value = _path(metadata, "marketCap", "value") + if value is None or value == "": + return "n/a" + currency = _path(metadata, "marketCap", "currencyId") + return f"{value} {currency}" if currency not in (None, "") else str(value) + + +def _ytm_note(metadata: Mapping[str, Any]) -> str | None: + description = metadata.get("description") + if not isinstance(description, str): + return None + if not re.search(r"\b(?:ytm|yield[\s-]+to[\s-]+maturity)\b", description, re.IGNORECASE): + return None + normalized = " ".join(description.split()) + return normalized or None + + +def format_fund_details(result: FundDetailsResult) -> str: + """Render a stable, deliberately small view of the approved response fields.""" + metadata = result.metadata + fund_info = metadata.get("fundInfo") + lines = [ + f"Name: {_display(metadata.get('name'))}", + f"Short name: {_display(metadata.get('shortName'))}", + f"Type: {_display(metadata.get('typeId'))}", + f"WKN: {_display(metadata.get('wkn'))}", + f"ISIN: {_display(metadata.get('isin'))}", + f"Exchange: {_display(result.exchange)}", + f"YTM: {_percentage(_path(fund_info, 'weightedAvgYieldToMaturity') if isinstance(fund_info, Mapping) else None, fraction=True)}", + f"TER: {_percentage(_path(fund_info, 'ter') if isinstance(fund_info, Mapping) else None)}", + f"Net asset value: {_display(_path(fund_info, 'netAssetValue') if isinstance(fund_info, Mapping) else None)}", + f"Net asset value date: {_display(_path(fund_info, 'netAssetValueDate') if isinstance(fund_info, Mapping) else None)}", + f"Duration: {_display(_path(fund_info, 'duration') if isinstance(fund_info, Mapping) else None)}", + f"Maturity date: {_display(_path(fund_info, 'maturityDate') if isinstance(fund_info, Mapping) else None)}", + f"Use of profits: {_display(_path(fund_info, 'useOfProfitsDisplayName') if isinstance(fund_info, Mapping) else None)}", + f"Market cap: {_market_cap(metadata)}", + f"Bid: {_display(result.bid)}", + f"Ask: {_display(result.ask)}", + f"Spread: {_display(result.spread)}", + f"Quote timestamp: {_display(result.quote_timestamp)}", + f"Quote status: {result.quote_status}", + ] + note = _ytm_note(metadata) + if note is not None: + lines.insert(13, f"YTM note: {note}") + return "\n".join(lines) + + +class FundDetails: + def __init__(self, tr, isin: str, exchange: str | None = None, timeout: Any = DEFAULT_TIMEOUT): + self.tr = tr + self.isin = isin + self.exchange = exchange + self.timeout = timeout + + async def details_loop(self) -> FundDetailsResult: + self.result = await fetch_fund_details(self.tr, self.isin, self.exchange, self.timeout) + return self.result + + def get(self) -> FundDetailsResult: + result = asyncio.run(self.details_loop()) + print(format_fund_details(result)) + return result diff --git a/pytr/main.py b/pytr/main.py index ecc4a87..3b210cb 100644 --- a/pytr/main.py +++ b/pytr/main.py @@ -17,6 +17,8 @@ from pytr.details import Details from pytr.dl import DL from pytr.event import Event +from pytr.fund_details import FundDetails, FundDetailsError +from pytr.fund_details import parse_isin as parse_fund_isin from pytr.portfolio import PORTFOLIO_COLUMNS, Portfolio from pytr.rates import RATE_COLUMNS, Rates, parse_isin_input from pytr.savings_plans import SavingsPlans @@ -257,6 +259,18 @@ def formatter(prog): ) parser_details.add_argument("isin", help="ISIN of intrument") + # fund_details + info = "Get read-only fund details for an ISIN" + parser_fund_details = parser_cmd.add_parser( + "fund_details", + formatter_class=formatter, + parents=[parser_login_args], + help=info, + description=info, + ) + parser_fund_details.add_argument("isin", help="ISIN of fund", type=parse_fund_isin) + parser_fund_details.add_argument("--exchange", help="Exchange to use for the quote") + # dl_docs info = ( "Download all pdf documents from the timeline and sort them into folders." @@ -609,6 +623,22 @@ def main(): ), args.isin, ).get() + elif args.command == "fund_details": + try: + FundDetails( + login( + phone_no=args.phone_no, + pin=args.pin, + store_credentials=args.store_credentials, + waf_token=args.waf_token, + v2=args.v2, + ), + args.isin, + exchange=args.exchange, + ).get() + except FundDetailsError as error: + print(f"Error: {error}") + return -1 elif args.command == "dl_docs": DL( None diff --git a/tests/test_fund_details.py b/tests/test_fund_details.py new file mode 100644 index 0000000..009ac8d --- /dev/null +++ b/tests/test_fund_details.py @@ -0,0 +1,238 @@ +import argparse +import asyncio +import sys +from decimal import Decimal + +import pytest + +from pytr import main as main_module +from pytr.fund_details import ( + FundDetailsError, + FundDetailsResult, + fetch_fund_details, + format_fund_details, + parse_isin, +) +from pytr.main import get_main_parser + +ISIN = "IE00B4L5Y983" + + +METADATA = { + "name": "Example Fund", + "shortName": "Example", + "typeId": "fund", + "wkn": "A0TEST", + "isin": ISIN, + "exchangeIds": ["XETRA", "LSX"], + "marketCap": {"value": "123456789.00", "currencyId": "EUR"}, + "description": "YTM is indicative.\nIt is not a guarantee.", + "fundInfo": { + "weightedAvgYieldToMaturity": "0.0435", + "ter": "0.120000", + "netAssetValue": "101.2300", + "netAssetValueDate": "2026-10-06", + "duration": "5.500", + "maturityDate": "2030-01-01", + "useOfProfitsDisplayName": "Accumulating", + }, +} + + +class FakeTradeRepublic: + def __init__(self, ticker_response=None, ticker_error=None): + self.calls = [] + self.unsubscribed = [] + self.closed = False + self.metadata_sent = False + self.ticker_response = ticker_response + self.ticker_error = ticker_error + + async def instrument_details(self, isin): + self.calls.append(("instrument_details", isin)) + return "instrument-subscription" + + async def ticker(self, isin, exchange): + self.calls.append(("ticker", isin, exchange)) + if self.ticker_error is not None: + raise self.ticker_error + return "ticker-subscription" + + async def recv(self): + if not self.metadata_sent: + self.metadata_sent = True + return "instrument-subscription", {"type": "instrument"}, METADATA + if self.ticker_response is not None: + response = self.ticker_response + self.ticker_response = None + return "ticker-subscription", {"type": "ticker"}, response + await asyncio.Event().wait() + + async def unsubscribe(self, subscription_id): + self.calls.append(("unsubscribe", subscription_id)) + self.unsubscribed.append(subscription_id) + + async def close(self): + self.calls.append(("close",)) + self.closed = True + + +def test_success_uses_only_metadata_and_selected_ticker_and_formats_decimal_spread(capsys): + tr = FakeTradeRepublic( + { + "bid": {"price": "10.1234", "time": 1720000000123}, + "ask": {"price": "10.2468", "time": 1720000000123}, + } + ) + + result = asyncio.run(fetch_fund_details(tr, ISIN.lower(), timeout=1)) + print(format_fund_details(result)) + + assert tr.calls == [ + ("instrument_details", ISIN), + ("ticker", ISIN, "XETRA"), + ("unsubscribe", "ticker-subscription"), + ("unsubscribe", "instrument-subscription"), + ("close",), + ] + assert tr.unsubscribed == ["ticker-subscription", "instrument-subscription"] + assert tr.closed + output = capsys.readouterr().out + assert "Name: Example Fund" in output + assert "ISIN: IE00B4L5Y983" in output + assert "Exchange: XETRA" in output + assert "YTM: 4.35%" in output + assert "TER: 0.12%" in output + assert "Net asset value: 101.2300" in output + assert "Market cap: 123456789.00 EUR" in output + assert "Bid: 10.1234" in output + assert "Ask: 10.2468" in output + assert "Spread: 0.1234" in output + assert "Quote timestamp: 1720000000123" in output + assert "Quote status: available" in output + assert "YTM note: YTM is indicative. It is not a guarantee." in output + + +def test_ticker_timeout_keeps_metadata_and_marks_quote_unavailable(capsys): + tr = FakeTradeRepublic() + + result = asyncio.run(fetch_fund_details(tr, ISIN, timeout=0.001)) + print(format_fund_details(result)) + + assert tr.calls == [ + ("instrument_details", ISIN), + ("ticker", ISIN, "XETRA"), + ("unsubscribe", "ticker-subscription"), + ("unsubscribe", "instrument-subscription"), + ("close",), + ] + assert tr.unsubscribed == ["ticker-subscription", "instrument-subscription"] + assert tr.closed + output = capsys.readouterr().out + assert "Name: Example Fund" in output + assert "Bid: n/a" in output + assert "Ask: n/a" in output + assert "Spread: n/a" in output + assert "Quote timestamp: n/a" in output + assert "Quote status: unavailable" in output + + +def test_unknown_exchange_does_not_subscribe_to_ticker(): + tr = FakeTradeRepublic({"bid": {"price": "1"}, "ask": {"price": "2"}}) + + with pytest.raises(FundDetailsError, match="Unknown exchange: NASDAQ"): + asyncio.run(fetch_fund_details(tr, ISIN, exchange="NASDAQ", timeout=1)) + + assert tr.calls == [ + ("instrument_details", ISIN), + ("unsubscribe", "instrument-subscription"), + ("close",), + ] + assert tr.unsubscribed == ["instrument-subscription"] + assert tr.closed + + +def test_ticker_error_is_metadata_only_and_no_other_api_is_used(): + tr = FakeTradeRepublic(ticker_error=RuntimeError("ticker unavailable")) + + result = asyncio.run(fetch_fund_details(tr, ISIN, timeout=1)) + + assert result.bid is None + assert result.ask is None + assert tr.calls == [ + ("instrument_details", ISIN), + ("ticker", ISIN, "XETRA"), + ("unsubscribe", "instrument-subscription"), + ("close",), + ] + assert tr.unsubscribed == ["instrument-subscription"] + assert tr.closed + + +def test_invalid_isin_is_rejected_by_parser_before_login(monkeypatch): + login_calls = [] + + def login_must_not_run(*args, **kwargs): + login_calls.append((args, kwargs)) + + monkeypatch.setattr(main_module, "login", login_must_not_run) + monkeypatch.setattr(sys, "argv", ["pytr", "fund_details", "not-an-isin"]) + with pytest.raises(SystemExit): + main_module.main() + + assert login_calls == [] + with pytest.raises(SystemExit): + get_main_parser().parse_args(["fund_details", "not-an-isin"]) + + assert parse_isin(ISIN.lower()) == ISIN + with pytest.raises(argparse.ArgumentTypeError): + parse_isin("not-an-isin") + + +def test_spread_is_only_calculated_when_ask_is_at_least_bid(): + tr = FakeTradeRepublic({"bid": {"price": "2.00"}, "ask": {"price": "1.99"}}) + + result = asyncio.run(fetch_fund_details(tr, ISIN, timeout=1)) + + assert result.spread is None + assert result.bid == Decimal("2.00") + assert result.ask == Decimal("1.99") + assert result.quote_status == "partial" + + +def test_ticker_with_one_valid_side_is_partial_and_preserves_that_side(): + tr = FakeTradeRepublic({"bid": {"price": "10.1234", "time": 1720000000123}}) + + result = asyncio.run(fetch_fund_details(tr, ISIN, timeout=1)) + + assert result.quote_status == "partial" + assert result.bid == Decimal("10.1234") + assert result.ask is None + assert result.spread is None + assert result.quote_timestamp == 1720000000123 + + +def test_crossed_ticker_is_partial_and_preserves_both_sides(): + tr = FakeTradeRepublic( + { + "bid": {"price": "10.00", "time": 1720000000123}, + "ask": {"price": "9.00", "time": 1720000000124}, + } + ) + + result = asyncio.run(fetch_fund_details(tr, ISIN, timeout=1)) + + assert result.quote_status == "partial" + assert result.bid == Decimal("10.00") + assert result.ask == Decimal("9.00") + assert result.spread is None + + +def test_malformed_percentage_fields_render_as_missing(): + metadata = dict(METADATA) + metadata["fundInfo"] = dict(METADATA["fundInfo"], weightedAvgYieldToMaturity="bad", ter="bad") + + output = format_fund_details(FundDetailsResult(metadata, "XETRA", None, None, "unavailable")) + + assert "YTM: n/a" in output + assert "TER: n/a" in output From 5f2aee3084f28376e18d04f7f8a9352d20c482b0 Mon Sep 17 00:00:00 2001 From: TiloHeidasch Date: Wed, 7 Oct 2026 17:21:55 +0200 Subject: [PATCH 2/3] Document fund details command --- README.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8e16e43..5edb715 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ __Table of Contents__ * [Quickstart](#quickstart) * [Usage](#usage) + * [Fund details](#fund-details) * [Authentication](#authentication) * [Web login](#web-login) * [Development](#development) @@ -61,12 +62,12 @@ If you want to use the cutting-edge version, use this command instead: ```console usage: pytr [-h] [-V] [-v {warning,info,debug}] [--debug-logfile DEBUG_LOGFILE] [--debug-log-filter DEBUG_LOG_FILTER] - {help,login,portfolio,rates,details,dl_docs,export_transactions,get_price_alarms,set_price_alarms,get_savings_plans,completion} ... + {help,login,portfolio,rates,details,fund_details,dl_docs,export_transactions,get_price_alarms,set_price_alarms,get_savings_plans,completion} ... Use "pytr command_name --help" to get detailed help to a specific command Commands: - {help,login,portfolio,rates,details,dl_docs,export_transactions,get_price_alarms,set_price_alarms,get_savings_plans,completion} + {help,login,portfolio,rates,details,fund_details,dl_docs,export_transactions,get_price_alarms,set_price_alarms,get_savings_plans,completion} Desired action to perform help Print this help message login Check if credentials file exists. If not create it and ask for input. Try to @@ -74,6 +75,7 @@ Commands: portfolio Show current portfolio rates Fetch current prices for a list of ISINs given as direct list or CSV input details Get details for an ISIN + fund_details Get read-only fund details for an ISIN dl_docs Download all pdf documents from the timeline and sort them into folders. Also export account transactions (account_transactions.csv) and JSON files with all events (events_with_documents.json and other_events.json) @@ -93,6 +95,24 @@ Options: ``` +### Fund details + +Use `fund_details` to show metadata and a current bid/ask quote for one fund or ETF. It uses only read-only instrument +metadata and ticker subscriptions. + +```sh +pytr fund_details IE0000MR4GH9 +``` + +The quote defaults to the first exchange returned by Trade Republic. Select another available exchange explicitly: + +```sh +pytr fund_details IE0000MR4GH9 --exchange TDG +``` + +The output includes the ISIN, YTM, TER, NAV and its date, duration, maturity, distribution policy, market-cap field, +bid, ask, spread, quote timestamp, and quote status. Metadata is still shown when a quote is unavailable. + ## Authentication ### Web login From 534f53553de3c4f8ef7ae6e986b757ffb274c551 Mon Sep 17 00:00:00 2001 From: TiloHeidasch Date: Wed, 7 Oct 2026 17:24:49 +0200 Subject: [PATCH 3/3] Keep README usage concise --- README.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/README.md b/README.md index 5edb715..2f37c9c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ __Table of Contents__ * [Quickstart](#quickstart) * [Usage](#usage) - * [Fund details](#fund-details) * [Authentication](#authentication) * [Web login](#web-login) * [Development](#development) @@ -95,24 +94,6 @@ Options: ``` -### Fund details - -Use `fund_details` to show metadata and a current bid/ask quote for one fund or ETF. It uses only read-only instrument -metadata and ticker subscriptions. - -```sh -pytr fund_details IE0000MR4GH9 -``` - -The quote defaults to the first exchange returned by Trade Republic. Select another available exchange explicitly: - -```sh -pytr fund_details IE0000MR4GH9 --exchange TDG -``` - -The output includes the ISIN, YTM, TER, NAV and its date, duration, maturity, distribution policy, market-cap field, -bid, ask, spread, quote timestamp, and quote status. Metadata is still shown when a quote is unavailable. - ## Authentication ### Web login