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 calculating dividends gains. #577

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ __pycache__
env
dist
calculations.pdf
dividends.pdf
exchange_rates.csv
spin_offs.csv
16 changes: 13 additions & 3 deletions cgt_calc/args_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
import datetime

from .const import (
DEFAULT_CG_REPORT_PATH,
DEFAULT_DG_REPORT_PATH,
DEFAULT_EXCHANGE_RATES_FILE,
DEFAULT_REPORT_PATH,
DEFAULT_SPIN_OFF_FILE,
)

Expand Down Expand Up @@ -99,9 +100,18 @@ def create_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--report",
type=str,
default=DEFAULT_REPORT_PATH,
default=DEFAULT_CG_REPORT_PATH,
nargs="?",
help="where to save the generated PDF report (default: %(default)s)",
help="where to save the generated PDF with Capital Gains report "
"(default: %(default)s)",
)
parser.add_argument(
"--dividend-report",
type=str,
default=DEFAULT_DG_REPORT_PATH,
nargs="?",
help="where to save the generated PDF with Dividend Gains report "
"(default: %(default)s)",
)
parser.add_argument(
"--no-report",
Expand Down
13 changes: 11 additions & 2 deletions cgt_calc/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@
2024: 3000,
}

DEFAULT_REPORT_PATH: Final = "calculations.pdf"
DIVIDEND_ALLOWANCES: Final[dict[int, int]] = {
2021: 2000,
2022: 2000,
2023: 1000,
2024: 500,
}

DEFAULT_CG_REPORT_PATH: Final = "calculations.pdf"
DEFAULT_DG_REPORT_PATH: Final = "dividends.pdf"

INTERNAL_START_DATE: Final = datetime.date(2010, 1, 1)

Expand All @@ -38,7 +46,8 @@
DEFAULT_SPIN_OFF_FILE: Final = "spin_offs.csv"

# Latex template for calculations report
TEMPLATE_NAME: Final = "template.tex.j2"
CG_TEMPLATE_NAME: Final = "template.tex.j2"
DG_TEMPLATE_NAME: Final = "dividend.template.tex.j2"

BED_AND_BREAKFAST_DAYS: Final = 30

Expand Down
47 changes: 47 additions & 0 deletions cgt_calc/dividends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Dividends."""

import datetime
from decimal import Decimal
import logging

from .model import Dividend, TaxTreaty
from .util import approx_equal

LOGGER = logging.getLogger(__name__)
DOUBLE_TAXATION_RULES = {
"GBP": TaxTreaty("UK", Decimal(0), Decimal(0)),
"USD": TaxTreaty("USA", Decimal(0.15), Decimal(0.15)),
"PLN": TaxTreaty("Poland", Decimal(0.19), Decimal(0.1)),
}


def process_dividend(
date: datetime.date, symbol: str, amount: Decimal, tax: Decimal, currency: str
) -> Dividend:
"""Create dividend with matching tax treaty rule based on currency."""
try:
treaty = DOUBLE_TAXATION_RULES[currency]
except KeyError:
LOGGER.warning(
"Taxation treaty for %s country is missing (ticker: %s), double "
"taxation rules cannot be determined!",
currency,
symbol,
)
treaty = None
else:
assert treaty is not None
expected_tax = treaty.country_rate * amount
if approx_equal(expected_tax, tax):
LOGGER.warning(
"Determined double taxation treaty does not match the base "
"taxation rules (expected %.2f base tax for %s but %.2f was deducted) "
"for %s ticker!",
expected_tax,
treaty.country,
tax,
symbol,
)
treaty = None

return Dividend(date, symbol, amount, tax, treaty)
80 changes: 62 additions & 18 deletions cgt_calc/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@

from . import render_latex
from .args_parser import create_parser
from .const import BED_AND_BREAKFAST_DAYS, CAPITAL_GAIN_ALLOWANCES, INTERNAL_START_DATE
from .const import (
BED_AND_BREAKFAST_DAYS,
CAPITAL_GAIN_ALLOWANCES,
DIVIDEND_ALLOWANCES,
INTERNAL_START_DATE,
)
from .currency_converter import CurrencyConverter
from .current_price_fetcher import CurrentPriceFetcher
from .dates import get_tax_year_end, get_tax_year_start, is_date
from .dividends import process_dividend
from .exceptions import (
AmountMissingError,
CalculatedAmountDiscrepancyError,
Expand All @@ -34,6 +40,8 @@
CalculationEntry,
CalculationLog,
CapitalGainsReport,
Dividend,
DividendsReport,
HmrcTransactionData,
HmrcTransactionLog,
PortfolioEntry,
Expand All @@ -44,7 +52,7 @@
from .parsers import read_broker_transactions, read_initial_prices
from .spin_off_handler import SpinOffHandler
from .transaction_log import add_to_list, has_key
from .util import round_decimal
from .util import approx_equal, round_decimal

LOGGER = logging.getLogger(__name__)

Expand All @@ -57,12 +65,6 @@ def get_amount_or_fail(transaction: BrokerTransaction) -> Decimal:
return amount


# It is not clear how Schwab or other brokers round the dollar value,
# so assume the values are equal if they are within $0.01.
def _approx_equal(val_a: Decimal, val_b: Decimal) -> bool:
return abs(val_a - val_b) < Decimal("0.01")


class CapitalGainsCalculator:
"""Main calculator class."""

Expand Down Expand Up @@ -96,6 +98,8 @@ def __init__(
self.portfolio: dict[str, Position] = defaultdict(Position)
self.spin_offs: dict[datetime.date, list[SpinOff]] = defaultdict(list)

self.dividends: list[Dividend] = []

def date_in_tax_year(self, date: datetime.date) -> bool:
"""Check if date is within current tax year."""
assert is_date(date)
Expand Down Expand Up @@ -131,7 +135,7 @@ def add_acquisition(

amount = get_amount_or_fail(transaction)
calculated_amount = quantity * price + transaction.fees
if not _approx_equal(amount, -calculated_amount):
if not approx_equal(amount, -calculated_amount):
raise CalculatedAmountDiscrepancyError(transaction, -calculated_amount)
amount = -amount

Expand Down Expand Up @@ -265,7 +269,7 @@ def add_disposal(
if price is None:
raise PriceMissingError(transaction)
calculated_amount = quantity * price - transaction.fees
if not _approx_equal(amount, calculated_amount):
if not approx_equal(amount, calculated_amount):
raise CalculatedAmountDiscrepancyError(transaction, calculated_amount)
add_to_list(
self.disposal_list,
Expand All @@ -276,15 +280,29 @@ def add_disposal(
self.converter.to_gbp_for(transaction.fees, transaction),
)

def _handle_dividends(
self,
dividends: dict[tuple[str, datetime.date], tuple[Decimal, str]],
dividends_taxes: dict[tuple[str, datetime.date], Decimal],
) -> tuple[Decimal, Decimal]:
sum_dividends, sum_taxes = Decimal(0), Decimal(0)
for (ticker, date), (amount, currency) in dividends.items():
tax = dividends_taxes.get((ticker, date), Decimal(0))
sum_taxes += tax
sum_dividends += amount
dividend = process_dividend(date, ticker, amount, tax, currency)
self.dividends.append(dividend)
return sum_dividends, sum_taxes

def convert_to_hmrc_transactions(
self,
transactions: list[BrokerTransaction],
) -> None:
"""Convert broker transactions to HMRC transactions."""
# We keep a balance per broker,currency pair
balance: dict[tuple[str, str], Decimal] = defaultdict(lambda: Decimal(0))
dividends = Decimal(0)
dividends_tax = Decimal(0)
dividends: dict[tuple[str, datetime.date], tuple[Decimal, str]] = {}
dividends_tax: dict[tuple[str, datetime.date], Decimal] = {}
interest = Decimal(0)
total_sells = Decimal(0)
balance_history: list[Decimal] = []
Expand Down Expand Up @@ -331,12 +349,21 @@ def convert_to_hmrc_transactions(
amount = get_amount_or_fail(transaction)
new_balance += amount
if self.date_in_tax_year(transaction.date):
dividends += self.converter.to_gbp_for(amount, transaction)
elif transaction.action in [ActionType.TAX, ActionType.ADJUSTMENT]:
symbol = transaction.symbol
assert symbol is not None
dividends[(symbol, transaction.date)] = (
self.converter.to_gbp_for(amount, transaction),
transaction.currency,
)
elif transaction.action in [ActionType.DIVIDEND_TAX, ActionType.ADJUSTMENT]:
amount = get_amount_or_fail(transaction)
new_balance += amount
if self.date_in_tax_year(transaction.date):
dividends_tax += self.converter.to_gbp_for(amount, transaction)
symbol = transaction.symbol
assert symbol is not None
dividends_tax[(symbol, transaction.date)] = (
self.converter.to_gbp_for(amount, transaction)
)
elif transaction.action is ActionType.INTEREST:
amount = get_amount_or_fail(transaction)
new_balance += amount
Expand Down Expand Up @@ -366,15 +393,20 @@ def convert_to_hmrc_transactions(
)
raise CalculationError(msg)
balance[(transaction.broker, transaction.currency)] = new_balance

sum_dividends, sum_dividends_tax = self._handle_dividends(
dividends, dividends_tax
)

print("First pass completed")
print("Final portfolio:")
for stock, position in self.portfolio.items():
print(f" {stock}: {position}")
print("Final balance:")
for (broker, currency), amount in balance.items():
print(f" {broker}: {round_decimal(amount, 2)} ({currency})")
print(f"Dividends: £{round_decimal(dividends, 2)}")
print(f"Dividend taxes: £{round_decimal(-dividends_tax, 2)}")
print(f"Dividends: £{round_decimal(sum_dividends, 2)}")
print(f"Dividend taxes at source: £{round_decimal(-sum_dividends_tax, 2)}")
print(f"Interest: £{round_decimal(interest, 2)}")
print(f"Disposal proceeds: £{round_decimal(total_sells, 2)}")
print()
Expand Down Expand Up @@ -787,6 +819,13 @@ def calculate_capital_gain(
show_unrealized_gains=self.calc_unrealized_gains,
)

def calculate_dividends_gain(self) -> DividendsReport:
"""Prepare report for dividend gains."""
allowance = DIVIDEND_ALLOWANCES.get(self.tax_year)
return DividendsReport(
self.tax_year, self.dividends, Decimal(allowance) if allowance else None
)

def make_portfolio_entry(
self, symbol: str, quantity: Decimal, amount: Decimal
) -> PortfolioEntry:
Expand Down Expand Up @@ -860,11 +899,16 @@ def main() -> int:
report = calculator.calculate_capital_gain()
print(report)

dividends_report = calculator.calculate_dividends_gain()
print(dividends_report)

# Generate PDF report.
if not args.no_report:
render_latex.render_calculations(
report,
output_path=Path(args.report),
dividends_report,
cg_output_path=Path(args.report),
dg_output_path=Path(args.dividend_report),
skip_pdflatex=args.no_pdflatex,
)
print("All done!")
Expand Down
Loading
Loading