From 124525378cb05c53e9e1330613534ef0c8b2c7fb Mon Sep 17 00:00:00 2001 From: financelurker <82776065+financelurker@users.noreply.github.com> Date: Sun, 31 May 2026 11:41:37 +0200 Subject: [PATCH 1/2] Extend securities filters to also include short positions --- pp_terminal/commands/view_securities.py | 2 +- pp_terminal/domain/portfolio_snapshot.py | 2 +- tests/commands/test_view_securities_short.py | 95 ++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 tests/commands/test_view_securities_short.py diff --git a/pp_terminal/commands/view_securities.py b/pp_terminal/commands/view_securities.py index a2f9276..5df6725 100644 --- a/pp_terminal/commands/view_securities.py +++ b/pp_terminal/commands/view_securities.py @@ -69,7 +69,7 @@ def prepare_securities_df( df = df[~df['isRetired']] if in_stock: - df = df[df['shares'] > 0.001] + df = df[df['shares'].abs() > 0.001] validation_results = validate_securities(portfolio, config) df['messages'] = df['securityId'].map( diff --git a/pp_terminal/domain/portfolio_snapshot.py b/pp_terminal/domain/portfolio_snapshot.py index 1fe28ac..ed585e0 100644 --- a/pp_terminal/domain/portfolio_snapshot.py +++ b/pp_terminal/domain/portfolio_snapshot.py @@ -91,7 +91,7 @@ def shares(self) -> pd.Series: ]).groupby(['accountId', 'securityId', 'currency'])['shares'].sum() shares.name = 'shares' - return shares[shares > 0] + return shares[shares != 0] @property def values(self) -> pd.Series: diff --git a/tests/commands/test_view_securities_short.py b/tests/commands/test_view_securities_short.py new file mode 100644 index 0000000..a9285e0 --- /dev/null +++ b/tests/commands/test_view_securities_short.py @@ -0,0 +1,95 @@ +""" + Regression tests for short positions (e.g. written/short options). + + Background: a short position has a negative net share count. The portfolio + snapshot previously dropped any non-positive net position (`shares > 0`), + so shorts disappeared entirely and `view securities --in-stock` never + listed them. These tests lock in that: + * the snapshot keeps a short with its negative quantity, + * a flat (net zero) position stays excluded, + * `view securities --in-stock` lists the short. +""" +# pylint: disable=duplicate-code + +from datetime import datetime +from unittest.mock import Mock + +import pandas as pd +import pytest +from typer import Context + +from pp_terminal.commands.view_securities import print_securities +from pp_terminal.output.strategy import RichOutputStrategy +from pp_terminal.domain.portfolio import Portfolio +from pp_terminal.domain.portfolio_snapshot import PortfolioSnapshot +from pp_terminal.domain.schemas import AccountType, TransactionType + + +@pytest.fixture(name='short_portfolio') +def provide_short_portfolio() -> Portfolio: + """Portfolio with a long, a short (written put) and a flat position.""" + + accounts = pd.DataFrame([ + ['Depot1', AccountType.SECURITIES.value, 'account1', False, 'EUR'], + ], columns=['name', 'type', 'referenceAccount', 'isRetired', 'currency'], + index=['depot1']) + accounts.index.name = 'accountId' + + securities = pd.DataFrame([ + ['MSCI World ETF', 'IE00B4L5Y983', 'EUR'], # long + ['Put DAX 18000 Dec25', 'OPT001', 'EUR'], # short (written) + ['Closed Position', 'FLAT01', 'EUR'], # flat / net zero + ], columns=['name', 'wkn', 'currency'], index=['sec1', 'opt1', 'flat1']) + securities.index.name = 'securityId' + + transactions = pd.DataFrame([ + # long: net +70 + [datetime(2022, 1, 15), 'depot1', 'sec1', TransactionType.BUY.value, 5000.0, 50.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + [datetime(2023, 6, 10), 'depot1', 'sec1', TransactionType.BUY.value, 7000.0, 30.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + [datetime(2024, 1, 5), 'depot1', 'sec1', TransactionType.SELL.value, 2000.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + # short: written put, sold-to-open 2 contracts, no inbound -> net -2 + [datetime(2024, 3, 1), 'depot1', 'opt1', TransactionType.SELL.value, 600.0, 2.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + # flat: buy 10, sell 10 -> net 0 + [datetime(2023, 2, 1), 'depot1', 'flat1', TransactionType.BUY.value, 1000.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + [datetime(2023, 9, 1), 'depot1', 'flat1', TransactionType.SELL.value, 1100.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + ], columns=['date', 'accountId', 'securityId', 'type', 'amount', 'shares', 'accountType', 'currency', 'taxes', 'fees']) + transactions = transactions.set_index(['date', 'accountId', 'securityId']) + + prices = pd.DataFrame([ + [datetime(2024, 12, 31), 'sec1', 100.0], + [datetime(2024, 12, 31), 'opt1', 30.0], + [datetime(2024, 12, 31), 'flat1', 50.0], + ], columns=['date', 'securityId', 'price']) + prices = prices.set_index(['date', 'securityId']) + + portfolio = Portfolio(accounts, transactions, securities, prices) + portfolio.base_currency = 'EUR' + return portfolio + + +def test_snapshot_keeps_short_position(short_portfolio: Portfolio) -> None: + """A short (net negative) position must survive in the snapshot with its sign.""" + snapshot = PortfolioSnapshot(short_portfolio, datetime(2024, 12, 31)) + shares = snapshot.shares.groupby('securityId').sum() + + assert shares.loc['sec1'] == pytest.approx(70.0) # long unchanged + assert shares.loc['opt1'] == pytest.approx(-2.0) # short kept, negative + assert 'flat1' not in shares.index # net zero stays excluded + + +def test_short_listed_with_in_stock(short_portfolio: Portfolio, capsys: pytest.CaptureFixture[str]) -> None: + """`view securities --in-stock` must list the short and still hide the flat one.""" + ctx = Context(Mock()) + ctx.obj = Mock() + ctx.obj.portfolio = short_portfolio + ctx.obj.output = RichOutputStrategy() + ctx.obj.config = {} + + print_securities(ctx, by=datetime(2024, 12, 31), in_stock=True) + + output = capsys.readouterr().out + + assert 'MSCI World ETF' in output # long shown + assert 'Put DAX 18000' in output # short shown (was hidden before the fix) + assert 'Closed Position' not in output # flat / net zero filtered out + From ad67a025eda546f7aebedd68164c2535b3f6a3f3 Mon Sep 17 00:00:00 2001 From: Christoph Massmann Date: Tue, 9 Jun 2026 06:24:17 +0200 Subject: [PATCH 2/2] consolidate new short position tests into existing ones --- tests/commands/test_view_securities.py | 30 ++++++- tests/commands/test_view_securities_short.py | 95 -------------------- 2 files changed, 29 insertions(+), 96 deletions(-) delete mode 100644 tests/commands/test_view_securities_short.py diff --git a/tests/commands/test_view_securities.py b/tests/commands/test_view_securities.py index 1d8f69f..0e17cec 100644 --- a/tests/commands/test_view_securities.py +++ b/tests/commands/test_view_securities.py @@ -46,7 +46,9 @@ def provide_securities_portfolio() -> Portfolio: ['MSCI World ETF', 'IE00B4L5Y983', 'EUR'], ['S&P 500 ETF', 'IE00B5BMR087', 'USD'], ['No Holdings Security', 'IE00000000000', 'EUR'], - ], columns=['name', 'wkn', 'currency'], index=['sec1', 'sec2', 'sec3']) + ['Put DAX 18000 Dec25', 'OPT001', 'EUR'], # short (written) + ['Closed Position', 'FLAT01', 'EUR'], # flat / net zero + ], columns=['name', 'wkn', 'currency'], index=['sec1', 'sec2', 'sec3', 'opt1', 'flat1']) securities.index.name = 'securityId' transactions = pd.DataFrame([ @@ -54,6 +56,11 @@ def provide_securities_portfolio() -> Portfolio: [datetime(2023, 6, 10), 'depot1', 'sec1', TransactionType.BUY.value, 7000.0, 30.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], [datetime(2024, 1, 5), 'depot1', 'sec1', TransactionType.SELL.value, 2000.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], [datetime(2023, 3, 20), 'depot1', 'sec2', TransactionType.BUY.value, 9000.0, 25.5, AccountType.SECURITIES.value, 'USD', 0.0], + # short: written put, sold-to-open 2 contracts, no inbound -> net -2 + [datetime(2024, 3, 1), 'depot1', 'opt1', TransactionType.SELL.value, 600.0, 2.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + # flat: buy 10, sell 10 -> net 0 + [datetime(2023, 2, 1), 'depot1', 'flat1', TransactionType.BUY.value, 1000.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], + [datetime(2023, 9, 1), 'depot1', 'flat1', TransactionType.SELL.value, 1100.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], ], columns=['date', 'accountId', 'securityId', 'type', 'amount', 'shares', 'accountType', 'currency', 'taxes', 'fees']) transactions = transactions.set_index(['date', 'accountId', 'securityId']) @@ -61,6 +68,8 @@ def provide_securities_portfolio() -> Portfolio: [datetime(2024, 12, 31), 'sec1', 100.0], [datetime(2024, 12, 31), 'sec2', 200.0], [datetime(2024, 12, 31), 'sec3', 50.0], + [datetime(2024, 12, 31), 'opt1', 30.0], + [datetime(2024, 12, 31), 'flat1', 50.0], ], columns=['date', 'securityId', 'price']) prices = prices.set_index(['date', 'securityId']) @@ -149,6 +158,25 @@ def test_list_securities_share_calculation(securities_portfolio: Portfolio) -> N assert shares_by_security.loc['sec1'] == pytest.approx(70.0) # 50 + 30 - 10 assert shares_by_security.loc['sec2'] == pytest.approx(25.5) assert 'sec3' not in shares_by_security.index # No transactions + assert shares_by_security.loc['opt1'] == pytest.approx(-2.0) # short kept with sign + assert 'flat1' not in shares_by_security.index # net zero stays excluded + + +def test_list_securities_in_stock_includes_shorts(securities_portfolio: Portfolio, capsys: pytest.CaptureFixture[str]) -> None: + """`view securities --in-stock` must list shorts and still hide flat positions.""" + ctx = Context(Mock()) + ctx.obj = Mock() + ctx.obj.portfolio = securities_portfolio + ctx.obj.output = RichOutputStrategy() + ctx.obj.config = {} + + print_securities(ctx, by=datetime(2024, 12, 31), in_stock=True) + + output = capsys.readouterr().out + + assert 'MSCI World ETF' in output # long shown + assert 'Put DAX 18000' in output # short shown (was hidden before the fix) + assert 'Closed Position' not in output # flat / net zero filtered out def test_list_securities_sorted_by_name(securities_portfolio: Portfolio, capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/commands/test_view_securities_short.py b/tests/commands/test_view_securities_short.py deleted file mode 100644 index a9285e0..0000000 --- a/tests/commands/test_view_securities_short.py +++ /dev/null @@ -1,95 +0,0 @@ -""" - Regression tests for short positions (e.g. written/short options). - - Background: a short position has a negative net share count. The portfolio - snapshot previously dropped any non-positive net position (`shares > 0`), - so shorts disappeared entirely and `view securities --in-stock` never - listed them. These tests lock in that: - * the snapshot keeps a short with its negative quantity, - * a flat (net zero) position stays excluded, - * `view securities --in-stock` lists the short. -""" -# pylint: disable=duplicate-code - -from datetime import datetime -from unittest.mock import Mock - -import pandas as pd -import pytest -from typer import Context - -from pp_terminal.commands.view_securities import print_securities -from pp_terminal.output.strategy import RichOutputStrategy -from pp_terminal.domain.portfolio import Portfolio -from pp_terminal.domain.portfolio_snapshot import PortfolioSnapshot -from pp_terminal.domain.schemas import AccountType, TransactionType - - -@pytest.fixture(name='short_portfolio') -def provide_short_portfolio() -> Portfolio: - """Portfolio with a long, a short (written put) and a flat position.""" - - accounts = pd.DataFrame([ - ['Depot1', AccountType.SECURITIES.value, 'account1', False, 'EUR'], - ], columns=['name', 'type', 'referenceAccount', 'isRetired', 'currency'], - index=['depot1']) - accounts.index.name = 'accountId' - - securities = pd.DataFrame([ - ['MSCI World ETF', 'IE00B4L5Y983', 'EUR'], # long - ['Put DAX 18000 Dec25', 'OPT001', 'EUR'], # short (written) - ['Closed Position', 'FLAT01', 'EUR'], # flat / net zero - ], columns=['name', 'wkn', 'currency'], index=['sec1', 'opt1', 'flat1']) - securities.index.name = 'securityId' - - transactions = pd.DataFrame([ - # long: net +70 - [datetime(2022, 1, 15), 'depot1', 'sec1', TransactionType.BUY.value, 5000.0, 50.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], - [datetime(2023, 6, 10), 'depot1', 'sec1', TransactionType.BUY.value, 7000.0, 30.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], - [datetime(2024, 1, 5), 'depot1', 'sec1', TransactionType.SELL.value, 2000.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], - # short: written put, sold-to-open 2 contracts, no inbound -> net -2 - [datetime(2024, 3, 1), 'depot1', 'opt1', TransactionType.SELL.value, 600.0, 2.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], - # flat: buy 10, sell 10 -> net 0 - [datetime(2023, 2, 1), 'depot1', 'flat1', TransactionType.BUY.value, 1000.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], - [datetime(2023, 9, 1), 'depot1', 'flat1', TransactionType.SELL.value, 1100.0, 10.0, AccountType.SECURITIES.value, 'EUR', 0.0, 0.0], - ], columns=['date', 'accountId', 'securityId', 'type', 'amount', 'shares', 'accountType', 'currency', 'taxes', 'fees']) - transactions = transactions.set_index(['date', 'accountId', 'securityId']) - - prices = pd.DataFrame([ - [datetime(2024, 12, 31), 'sec1', 100.0], - [datetime(2024, 12, 31), 'opt1', 30.0], - [datetime(2024, 12, 31), 'flat1', 50.0], - ], columns=['date', 'securityId', 'price']) - prices = prices.set_index(['date', 'securityId']) - - portfolio = Portfolio(accounts, transactions, securities, prices) - portfolio.base_currency = 'EUR' - return portfolio - - -def test_snapshot_keeps_short_position(short_portfolio: Portfolio) -> None: - """A short (net negative) position must survive in the snapshot with its sign.""" - snapshot = PortfolioSnapshot(short_portfolio, datetime(2024, 12, 31)) - shares = snapshot.shares.groupby('securityId').sum() - - assert shares.loc['sec1'] == pytest.approx(70.0) # long unchanged - assert shares.loc['opt1'] == pytest.approx(-2.0) # short kept, negative - assert 'flat1' not in shares.index # net zero stays excluded - - -def test_short_listed_with_in_stock(short_portfolio: Portfolio, capsys: pytest.CaptureFixture[str]) -> None: - """`view securities --in-stock` must list the short and still hide the flat one.""" - ctx = Context(Mock()) - ctx.obj = Mock() - ctx.obj.portfolio = short_portfolio - ctx.obj.output = RichOutputStrategy() - ctx.obj.config = {} - - print_securities(ctx, by=datetime(2024, 12, 31), in_stock=True) - - output = capsys.readouterr().out - - assert 'MSCI World ETF' in output # long shown - assert 'Put DAX 18000' in output # short shown (was hidden before the fix) - assert 'Closed Position' not in output # flat / net zero filtered out -