Skip to content

Commit

Permalink
Graceful failure if no transactions available for generating report (#60
Browse files Browse the repository at this point in the history
)

* Add check for when no transactions exist when generating report.
* Add an exceptions module for keeping application-specific exceptions.
  • Loading branch information
adri0 authored Feb 18, 2024
1 parent 8e01fce commit 324b79b
Show file tree
Hide file tree
Showing 13 changed files with 127 additions and 64 deletions.
28 changes: 15 additions & 13 deletions investd/__main__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import datetime
import logging
import os
from datetime import date
from pathlib import Path
from typing import Optional

import click

from investd import quotes, reports, sources
from investd.config import INVESTD_PERSIST, init_dirs
from investd import config, quotes, reports, sources
from investd.exceptions import InvestdException
from investd.transaction import TX_FILENAME

APP_NAME = "investd"
Expand All @@ -32,13 +32,11 @@
@click.option(
"--output",
type=click.Path(dir_okay=False, writable=True),
default=INVESTD_PERSIST / TX_FILENAME,
default=config.INVESTD_PERSIST / TX_FILENAME,
help="Output path",
)
def ingest_sources_cmd(output: Path):
df_tx = sources.ingest_all()
log.info(f"Writing {output}")
df_tx.to_csv(output, index=False)
sources.ingest_to_path(output)


@cli.command(name="report")
Expand All @@ -62,9 +60,8 @@ def ingest_sources_cmd(output: Path):
)
def report_cmd(report: str, ingest: bool, date: Optional[str]):
if ingest:
df_tx = sources.ingest_all()
df_tx.to_csv(INVESTD_PERSIST / TX_FILENAME, index=False)
os.environ["REPORT_DATE"] = date
sources.ingest_to_path()
os.environ["REPORT_DATE"] = date or str(datetime.date.today())
path_output = reports.generate_report(report)
log.info(f"Report created: {path_output}")
print(path_output)
Expand All @@ -81,16 +78,21 @@ def report_cmd(report: str, ingest: bool, date: Optional[str]):
@click.option("--symbols", "-y", default=None, help="Symbols e.g. AAPL,CDR.PL")
def quotes_cmd(start: Optional[str], end: Optional[str], symbols: Optional[str]):
quotes.download_quotes_to_csv(
start_date=date.fromisoformat(start) if start else None,
end_date=date.fromisoformat(end) if end else None,
start_date=datetime.date.fromisoformat(start) if start else None,
end_date=datetime.date.fromisoformat(end) if end else None,
symbols=symbols.split(",") if symbols else None,
include_exchange_rates=True,
)


def main() -> None:
init_dirs()
cli()
try:
cli()
except InvestdException as exc:
log.error(exc.msg, exc_info=True)
click.echo(exc.msg)
exit(1)


if __name__ == "__main__":
Expand Down
6 changes: 6 additions & 0 deletions investd/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class InvestdException(Exception):
msg: str


class NoTransactions(InvestdException):
msg = "No transactions! Make sure you have non-empty files in the sources folder according to each source."
17 changes: 12 additions & 5 deletions investd/quotes.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import logging
from datetime import date
from pathlib import Path
from typing import Iterable, Optional

import pandas as pd
import yfinance

from investd import config
from investd.common import Currency
from investd.config import INVESTD_PERSIST, INVESTD_REF_CURRENCY
from investd.transaction import load_transactions

log = logging.getLogger(__name__)

QUOTES_FILENAME = "quotes.csv"
SYMBOL_EXCH_ADJUST = {"FR": "PA", "UK": "L", "PL": "WA"}
SYMBOL_NAME_ADJUST = {"DAXEX": "EXS1"}
Expand Down Expand Up @@ -74,18 +77,22 @@ def download_quotes_to_csv(
id_vars=["date"], value_vars=df.columns, var_name="symbol", value_name="price"
)
df = df.sort_values(["date", "symbol"])

df.to_csv(output_path or INVESTD_PERSIST / QUOTES_FILENAME, index=False)
output_path = output_path or (config.INVESTD_PERSIST / QUOTES_FILENAME)
output_path.parent.mkdir(exist_ok=True, parents=True)
df.to_csv(output_path, index=False)


def load_quotes() -> pd.DataFrame:
return pd.read_csv(INVESTD_PERSIST / QUOTES_FILENAME, parse_dates=["date"])
if not (config.INVESTD_PERSIST / QUOTES_FILENAME).exists():
log.warn("Quotes files does not exist.")
return pd.DataFrame(columns=["date", "symbol", "price"])
return pd.read_csv((config.INVESTD_PERSIST / QUOTES_FILENAME), parse_dates=["date"])


def extract_exchange_rates_symbols(
df_tx: pd.DataFrame, ref_currency: Optional[Currency] = None
) -> set[str]:
ref_currency = ref_currency or INVESTD_REF_CURRENCY
ref_currency = ref_currency or config.INVESTD_REF_CURRENCY
return {
f"{cur}{ref_currency}=X"
for cur in df_tx["currency"].unique()
Expand Down
16 changes: 13 additions & 3 deletions investd/reports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from nbconvert.exporters import HTMLExporter, export
from nbconvert.preprocessors import ExecutePreprocessor

from investd.config import INVESTD_REF_CURRENCY, INVESTD_REPORTS
from investd import config
from investd.exceptions import NoTransactions
from investd.transaction import load_transactions


def generate_report(notebook_name: str) -> Path:
path_notebook = Path(__file__).parent / f"{notebook_name}.py"
_assert_transactions()
notebook = jupytext.read(path_notebook)
preprocessor = ExecutePreprocessor(timeout=10, kernel_name="python3")
nb_executed, _ = preprocessor.preprocess(
Expand All @@ -32,8 +35,15 @@ def generate_report(notebook_name: str) -> Path:

def _save_report(name: str, content: str) -> Path:
today = datetime.today().strftime("%Y-%m-%d")
filename = f"{name}_{today}_{INVESTD_REF_CURRENCY}.html"
report_path = INVESTD_REPORTS / filename
filename = f"{name}_{today}_{config.INVESTD_REF_CURRENCY}.html"
report_path = config.INVESTD_REPORTS / filename
report_path.parent.mkdir(exist_ok=True, parents=True)
with report_path.open("w") as out_file:
out_file.write(content)
return report_path


def _assert_transactions() -> None:
df_tx = load_transactions()
if df_tx.empty:
raise NoTransactions()
17 changes: 8 additions & 9 deletions investd/reports/overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
import numpy as np
import pandas as pd
import seaborn as sns
from IPython.display import Markdown, display
from IPython.display import display

from investd import views
from investd.config import INVESTD_REF_CURRENCY
from investd import config, views
from investd.quotes import load_quotes
from investd.transaction import load_transactions

Expand All @@ -29,7 +28,7 @@
pd.DataFrame(
{
"Reporting date": [report_date],
"Reference currency": [INVESTD_REF_CURRENCY],
"Reference currency": [config.INVESTD_REF_CURRENCY],
"Created date": [now.strftime("%Y-%m-%d")],
},
index=[""],
Expand All @@ -48,7 +47,7 @@
# %%

ref_amount_cols = [
col for col in df_portfolio.columns if str(INVESTD_REF_CURRENCY) in col
col for col in df_portfolio.columns if str(config.INVESTD_REF_CURRENCY) in col
]
row_total = df_portfolio.loc[:, ref_amount_cols].sum(axis=0)
row_total = pd.DataFrame(row_total, columns=["Total"]).transpose()
Expand All @@ -70,7 +69,7 @@ def highlight_total_row(row: pd.Series) -> list[str]:
df = views.invested_ref_amount_by_col(df_tx, "type")

display(df)
fig = df.plot.pie(y=str(INVESTD_REF_CURRENCY))
fig = df.plot.pie(y=str(config.INVESTD_REF_CURRENCY))
fig.get_legend().remove()

# %% [markdown]
Expand All @@ -80,7 +79,7 @@ def highlight_total_row(row: pd.Series) -> list[str]:
df = views.amounts_by_currency(df_tx)

display(df)
fig = df.plot.pie(y=str(INVESTD_REF_CURRENCY))
fig = df.plot.pie(y=str(config.INVESTD_REF_CURRENCY))
fig.get_legend().remove()

# %% [markdown]
Expand All @@ -99,6 +98,6 @@ def highlight_total_row(row: pd.Series) -> list[str]:
fig.autofmt_xdate()

cumsum = df_tx["amount_ref_currency"].cumsum()
df_cum = pd.DataFrame({INVESTD_REF_CURRENCY: cumsum, "Date": df_tx["timestamp"]})
df_cum = pd.DataFrame({config.INVESTD_REF_CURRENCY: cumsum, "Date": df_tx["timestamp"]})

fig = sns.lineplot(x="Date", y=INVESTD_REF_CURRENCY, data=df_cum, ax=ax)
fig = sns.lineplot(x="Date", y=config.INVESTD_REF_CURRENCY, data=df_cum, ax=ax)
15 changes: 14 additions & 1 deletion investd/sources/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import logging
from dataclasses import fields
from itertools import chain
from typing import Type
from pathlib import Path
from typing import Optional, Type

import pandas as pd

from investd import config
from investd.sources import bonds, bossa, revolut_stocks, xtb
from investd.sources.base import SourceBase
from investd.transaction import Transaction

log = logging.getLogger(__name__)

sources: list[Type[SourceBase]] = [
xtb.XTB,
revolut_stocks.RevolutStocks,
Expand All @@ -23,3 +28,11 @@ def ingest_all() -> pd.DataFrame:
)
df_tx.sort_values(by="timestamp", ascending=True, inplace=True)
return df_tx


def ingest_to_path(path_output: Optional[Path] = None) -> None:
path_output = path_output or (config.INVESTD_PERSIST / "transactions.csv")
df_tx = ingest_all()
log.info(f"Writing {path_output}")
path_output.parent.mkdir(exist_ok=True, parents=True)
df_tx.to_csv(path_output, index=False)
4 changes: 2 additions & 2 deletions investd/sources/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pathlib import Path
from typing import Generator, Iterable

from investd.config import INVESTD_SOURCES
from investd import config
from investd.transaction import Transaction

log = logging.getLogger(__name__)
Expand All @@ -20,7 +20,7 @@ def parse_source_file(self, tx_path: Path) -> Iterable[Transaction]:
pass

def load_transactions(self) -> Generator[Transaction, None, None]:
for path in (INVESTD_SOURCES / self.source_name).glob("*"):
for path in (config.INVESTD_SOURCES / self.source_name).glob("*"):
log.info(f"Loading {path}")
for tx in self.parse_source_file(path):
yield tx
10 changes: 7 additions & 3 deletions investd/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import pandas as pd
from pydantic.dataclasses import dataclass

from investd import config
from investd.common import Action, AssetType, Currency
from investd.config import INVESTD_PERSIST

TX_FILENAME = "transactions.csv"

Expand All @@ -30,8 +30,12 @@ class Transaction:

def load_transactions() -> pd.DataFrame:
"""Load transactions in persistence as a DataFrame."""
path = INVESTD_PERSIST / TX_FILENAME
df_tx = pd.read_csv(path, converters=_enum_fields(Transaction))
path = config.INVESTD_PERSIST / TX_FILENAME
df_tx = (
pd.read_csv(path, converters=_enum_fields(Transaction))
if path.exists()
else pd.DataFrame()
)
schema = _to_pandas_schema(Transaction)
return df_tx.astype(schema)

Expand Down
28 changes: 14 additions & 14 deletions investd/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

import pandas as pd

from investd import config
from investd.common import Action
from investd.config import INVESTD_REF_CURRENCY


def _add_signed_cols(df_tx: pd.DataFrame) -> pd.DataFrame:
Expand Down Expand Up @@ -62,8 +62,9 @@ def invested_ref_amount_by_col(df_tx: pd.DataFrame, col: str) -> pd.Series:
"""
df_tx = _add_signed_cols(df_tx)
grouped = df_tx.groupby(col)["amount_ref_currency_signed"].sum()
df = to_nice_df(grouped, columns=[str(INVESTD_REF_CURRENCY)])
df = add_pct_col(df, based_on_col=str(INVESTD_REF_CURRENCY))
ref_currency = config.INVESTD_REF_CURRENCY
df = to_nice_df(grouped, columns=[str(ref_currency)])
df = add_pct_col(df, based_on_col=str(ref_currency))
return df


Expand All @@ -76,10 +77,9 @@ def amounts_by_currency(df_tx: pd.DataFrame) -> pd.Series:
df_cur = df_tx.groupby("currency")[
["amount_signed", "amount_ref_currency_signed"]
].sum()
df_cur = to_nice_df(
df_cur, columns=["Original currency", str(INVESTD_REF_CURRENCY)]
)
df_cur = add_pct_col(df_cur, str(INVESTD_REF_CURRENCY))
ref_currency = config.INVESTD_REF_CURRENCY
df_cur = to_nice_df(df_cur, columns=["Original currency", str(ref_currency)])
df_cur = add_pct_col(df_cur, str(ref_currency))
return df_cur


Expand All @@ -106,9 +106,8 @@ def amount_over_time(df_tx: pd.DataFrame, period: str) -> pd.DataFrame:
)
df_ot.index = df_ot.index.to_period()
df_ot["cumsum"] = df_ot["amount_ref_currency_signed"].cumsum()
df_ot = to_nice_df(
df_ot, columns=[str(INVESTD_REF_CURRENCY), f"Cumulated {INVESTD_REF_CURRENCY}"]
)
ref_currency = config.INVESTD_REF_CURRENCY
df_ot = to_nice_df(df_ot, columns=[str(ref_currency), f"Cumulated {ref_currency}"])
return df_ot


Expand All @@ -134,11 +133,12 @@ def portfolio_value(
df_portfolio["amount_at_date"] = (
df_portfolio["quote"] * df_portfolio["quantity_signed"]
)
ref_currency = config.INVESTD_REF_CURRENCY
exchange_rate_to_ref_cur = (
df_portfolio["currency"]
.map(
lambda cur: quotes.get(f"{cur}{INVESTD_REF_CURRENCY}=X")
if cur != INVESTD_REF_CURRENCY
lambda cur: quotes.get(f"{cur}{ref_currency}=X")
if cur != ref_currency
else 1
)
.astype(float)
Expand All @@ -149,9 +149,9 @@ def portfolio_value(
df_portfolio = df_portfolio.rename(
{
"amount_at_date": "Amount at date",
"amount_ref_currency_at_date": f"Amount at date {INVESTD_REF_CURRENCY}",
"amount_ref_currency_at_date": f"Amount at date {ref_currency}",
"amount_signed": "Invested amount",
"amount_ref_currency_signed": f"Invested amount {INVESTD_REF_CURRENCY}",
"amount_ref_currency_signed": f"Invested amount {ref_currency}",
"quantity_signed": "Quantity",
},
axis=1,
Expand Down
2 changes: 1 addition & 1 deletion logging.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
version: 1
disable_existing_loggers: false
disable_existing_loggers: true
formatters:
default:
format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
Expand Down
Loading

0 comments on commit 324b79b

Please sign in to comment.