Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to export output to csv, json or HTML #29

Merged
merged 6 commits into from
Dec 12, 2024
Merged
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
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,7 @@ exclude_also = [
minversion = "6.0"
testpaths = ["tests"]
addopts = "--cov --no-header"
markers = [
"""network: mark a test that requires an Internet \
connection (deselect with '-m "not network"')"""
]
51 changes: 39 additions & 12 deletions src/investir/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from investir.logging import configure_logger
from investir.parser import ParserFactory
from investir.prettytable import OutputFormat
from investir.taxcalculator import TaxCalculator
from investir.transaction import Acquisition, Disposal, Transaction
from investir.trhistory import TrHistory
Expand Down Expand Up @@ -72,6 +73,12 @@ def __init__(self, opt1: str, opt2: str) -> None:
]


OutputFormatOpt = Annotated[
OutputFormat,
typer.Option("--output", "-o", help="Output format."),
]


def abort(message: str) -> None:
logger.critical(message)
raise typer.Exit(code=1)
Expand Down Expand Up @@ -230,7 +237,7 @@ def main_callback( # noqa: PLR0913


@app.command("orders")
def orders_command(
def orders_command( # noqa: PLR0913
files: FilesArg,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
Expand All @@ -240,6 +247,7 @@ def orders_command(
disposals_only: Annotated[
bool, typer.Option("--disposals", help="Show only disposals.")
] = False,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show share buy/sell orders.
Expand All @@ -257,22 +265,23 @@ def orders_command(

filters = create_filters(tax_year=tax_year, ticker=ticker, tr_type=tr_type)
if table := tr_hist.get_orders_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("dividends")
def dividends_command(
files: FilesArg,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show share dividends paid out.
"""
tr_hist, _ = parse(files)
filters = create_filters(tax_year=tax_year, ticker=ticker)
if table := tr_hist.get_dividends_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("transfers")
Expand All @@ -285,6 +294,7 @@ def transfers_command(
withdrawals_only: Annotated[
bool, typer.Option("--withdrawals", help="Show only withdrawals.")
] = False,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show cash deposits and cash withdrawals.
Expand All @@ -303,22 +313,26 @@ def transfers_command(

filters = create_filters(tax_year=tax_year, amount_op=amount_op)
if table := tr_hist.get_transfers_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("interest")
def interest_command(files: FilesArg, tax_year: TaxYearOpt = None) -> None:
def interest_command(
files: FilesArg,
tax_year: TaxYearOpt = None,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show interest earned on cash.
"""
tr_hist, _ = parse(files)
filters = create_filters(tax_year=tax_year)
if table := tr_hist.get_interest_table(filters):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


@app.command("capital-gains")
def capital_gains_command(
def capital_gains_command( # noqa: PLR0913
files: FilesArg,
gains_only: Annotated[
bool, typer.Option("--gains", help="Show only capital gains.")
Expand All @@ -328,13 +342,19 @@ def capital_gains_command(
] = False,
tax_year: TaxYearOpt = None,
ticker: TickerOpt = None,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show capital gains report.
"""
if gains_only and losses_only:
raise MutuallyExclusiveOption("--gains", "--losses")

if format != OutputFormat.TEXT and tax_year is None:
raise click.exceptions.UsageError(
f"The {format.value} format requires the option --tax-year to be used"
)

_, tax_calculator = parse(files)
tax_year = Year(tax_year) if tax_year else None
ticker = Ticker(ticker) if ticker else None
Expand All @@ -354,10 +374,16 @@ def capital_gains_command(

if table:
print(end="\n" if tax_year_idx == 0 and config.logging_enabled else "")
print(boldify(f"Capital Gains Tax Report {tax_year_short_date(tax_year)}"))
print(tax_year_full_date(tax_year))
print(table.to_string(leading_nl=True))
print(summary)

if format == OutputFormat.TEXT:
print(
boldify(f"Capital Gains Tax Report {tax_year_short_date(tax_year)}")
)
print(tax_year_full_date(tax_year))
print(table.to_string(format))
print(summary)
else:
print(table.to_string(format, leading_nl=False))


@app.command("holdings")
Expand All @@ -367,14 +393,15 @@ def holdings_command(
show_gain_loss: Annotated[
bool, typer.Option("--show-gain-loss", help="Show unrealised gain/loss.")
] = False,
format: OutputFormatOpt = OutputFormat.TEXT,
) -> None:
"""
Show current holdings.
"""
_, tax_calculator = parse(files)
ticker = Ticker(ticker) if ticker else None
if table := tax_calculator.get_holdings_table(ticker, show_gain_loss):
print(table.to_string(leading_nl=config.logging_enabled))
print(table.to_string(format, leading_nl=config.logging_enabled))


def main() -> None:
Expand Down
100 changes: 76 additions & 24 deletions src/investir/prettytable.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from collections.abc import Callable
from collections.abc import Callable, Sequence, Set
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import Any

import prettytable

from investir.utils import boldify, unboldify


class OutputFormat(str, Enum):
TEXT = "text"
CSV = "csv"
JSON = "json"
HTML = "html"


def date_format(format: str) -> Callable[[str, Any], str]:
def _date_format(_field, val) -> str:
if isinstance(val, date):
Expand All @@ -30,13 +38,76 @@ def _decimal_format(_field, val) -> str:


class PrettyTable(prettytable.PrettyTable):
def __init__(self, *args, **kwargs) -> None:
kwargs["field_names"] = list(map(lambda f: boldify(f), kwargs["field_names"]))
def __init__(
self,
field_names: Sequence[str],
hidden_fields: Sequence[str] | None = None,
show_total_fields: Sequence[str] | None = None,
**kwargs,
) -> None:
super().__init__(field_names, **kwargs)

self.hrules = prettytable.HEADER
self.vrules = prettytable.NONE
self._hidden_fields: Set[str] = frozenset(hidden_fields or [])
self._show_total_fields: Set[str] = frozenset(show_total_fields or [])

def __bool__(self) -> bool:
return len(self.rows) > 0

def to_string(self, format: OutputFormat, leading_nl: bool = True) -> str:
if (
self.rows
and self._show_total_fields
and format in (OutputFormat.TEXT, OutputFormat.HTML)
):
self._insert_totals_row()

self._apply_formatting(bold_titles=format == OutputFormat.TEXT)

start_nl = "\n" if leading_nl else ""
end_nl = "\n" if format == OutputFormat.TEXT else ""

fields = [
f for f in self.field_names if unboldify(f) not in self._hidden_fields
]

kwargs: dict[str, Any] = {"fields": fields}
if format == OutputFormat.JSON:
kwargs["default"] = str

table_str = self.get_formatted_string(format, **kwargs)

if format == OutputFormat.CSV:
table_str = table_str.rstrip()

super().__init__(*args, **kwargs)
return f"{start_nl}{table_str}{end_nl}"

def _insert_totals_row(self) -> None:
totals_row = []

for i, f in enumerate(self.field_names):
if unboldify(f) in self._show_total_fields:
total = sum(
(
round(row[i], 2)
for row in self.rows
if row[i] and row[i] != "n/a"
),
Decimal("0.0"),
)
totals_row.append(total)
else:
totals_row.append("")

self.add_row(totals_row)

def _apply_formatting(self, bold_titles: bool) -> None:
if bold_titles:
self.field_names = list(map(lambda f: boldify(f), self.field_names))

for f in self.field_names:
plain_f = unboldify(f)
plain_f = unboldify(f) if bold_titles else f

match plain_f.split()[0].strip(), plain_f.split()[-1]:
case ("Date", _) | (_, "Date"):
Expand All @@ -52,22 +123,3 @@ def __init__(self, *args, **kwargs) -> None:

case _:
self.align[f] = "l"

self.hrules = prettytable.HEADER
self.vrules = prettytable.NONE
self.invisible_fields: set[str] = set()

def hide_field(self, field_name: str) -> None:
self.invisible_fields.add(field_name)

def to_string(self, leading_nl: bool = True) -> str:
nl = "\n" if leading_nl else ""

fields = [
f for f in self.field_names if unboldify(f) not in self.invisible_fields
]

return f"{nl}{self.get_string(fields=fields)}\n"

def __bool__(self) -> bool:
return len(self.rows) > 0
30 changes: 16 additions & 14 deletions src/investir/taxcalculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,22 +208,31 @@ def get_holdings_table(
self._calculate_capital_gains()

table = PrettyTable(
field_names=[
field_names=(
"Security Name",
"ISIN",
"Cost (£)",
"Quantity",
"Current Value (£)",
"Gain/Loss (£)",
"Weight (%)",
]
),
hidden_fields=(
"Current Value (£)",
"Gain/Loss (£)",
"Weight (%)",
)
if not show_gain_loss
else (),
show_total_fields=(
"Current Value (£)",
"Gain/Loss (£)",
"Weight (%)",
)
if show_gain_loss
else (),
)

if not show_gain_loss:
table.hide_field("Current Value (£)")
table.hide_field("Gain/Loss (£)")
table.hide_field("Weight (%)")

holdings = []

if ticker_filter is None:
Expand All @@ -250,7 +259,6 @@ def get_holdings_table(
)

portfolio_value = sum(val for val in holding2value.values())
total_gain_loss = Decimal("0.0")
last_idx = len(holdings) - 1

for idx, (isin, holding) in enumerate(holdings):
Expand All @@ -260,7 +268,6 @@ def get_holdings_table(
if holding_value := holding2value.get(isin):
gain_loss = holding.cost - holding_value
weight = holding_value / portfolio_value * 100
total_gain_loss += gain_loss

table.add_row(
[
Expand All @@ -275,11 +282,6 @@ def get_holdings_table(
divider=idx == last_idx,
)

if table.rows and show_gain_loss:
table.add_row(
["", "", "", "", portfolio_value, total_gain_loss, Decimal("100.0")]
)

return table

def disposal_years(self) -> Sequence[Year]:
Expand Down
Loading
Loading