diff --git a/.flake8 b/.flake8 index ee9d7c0..ab8846e 100644 --- a/.flake8 +++ b/.flake8 @@ -35,6 +35,9 @@ ignore = D104, # Missing docstring in public package D105, # Missing docstring in magic method D107, # Missing docstring in __init__ + DAR101, # Missing parameter(s) in Docstring + DAR201, # Missing "Returns" in Docstring + E800, # commented out code N814, # camelcase imported as constant WPS115, # upper-case constant in a class diff --git a/README.md b/README.md index d4ff259..8899dda 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,12 @@ $ pip install investments --upgrade --user - конвертация по курсу ЦБ - раздельный результат сделок по акциям и опционам + дивиденды - учёт начисленных процентов на остаток по счету -- пока **НЕ** учитывает комисии по сделкам (т.е. налог будет немного больше, в пользу налоговой) +- учитывает комисии по сделкам - пока **НЕ** поддерживаются сплиты - пока **НЕ** поддерживаются сделки Forex, сделка пропускается и выводится сообщение о том, что это может повлиять на итоговый отчет *Пример отчета:* +TODO UPDATE example ![ibtax report example](./images/ibtax_2016.jpg) diff --git a/investments/calculators.py b/investments/calculators.py new file mode 100644 index 0000000..0259a0d --- /dev/null +++ b/investments/calculators.py @@ -0,0 +1,16 @@ +"""Калькулятор цены сделки с учётом комиссий.""" + +from investments.money import Money + + +def compute_total_cost(quantity: int, price_per_piece: Money, fee_per_piece: Money) -> Money: + """Полная сумма сделки (цена +/- комиссии) в базовой валюте (Basis в отчёте).""" + assert price_per_piece.currency is fee_per_piece.currency + fee = abs(quantity) * abs(fee_per_piece) + price = abs(quantity) * price_per_piece + if quantity > 0: + # buy trade + return -1 * (price + fee) + + # sell trade + return price - fee diff --git a/investments/data_providers/cbr.py b/investments/data_providers/cbr.py index 9037625..c31670f 100644 --- a/investments/data_providers/cbr.py +++ b/investments/data_providers/cbr.py @@ -42,6 +42,7 @@ def __init__(self, currency: Currency, year_from: int = 2000, cache_dir: str = N end_date = (datetime.datetime.utcnow() + datetime.timedelta(days=1)).strftime('%d/%m/%Y') r = requests.get(f'http://www.cbr.ru/scripts/XML_dynamic.asp?date_req1=01/01/{year_from}&date_req2={end_date}&VAL_NM_RQ={currency_code}') + tree = ET.fromstring(r.text) rates_data: List[Tuple[datetime.date, Money]] = [] @@ -65,10 +66,18 @@ def __init__(self, currency: Currency, year_from: int = 2000, cache_dir: str = N def dataframe(self) -> pandas.DataFrame: return self._df - def get_rate(self, date: datetime.date) -> Money: - return self._df.loc[date].item() + def get_rate(self, dt: datetime.date) -> Money: + if isinstance(dt, datetime.datetime): + dt = datetime.datetime.combine(dt.date(), datetime.datetime.min.time()) + + if isinstance(dt, datetime.date): + dt = datetime.datetime.combine(dt, datetime.datetime.min.time()) + + return self._df.loc[dt].item() def convert_to_rub(self, source: Money, rate_date: datetime.date) -> Money: + assert isinstance(rate_date, datetime.date) + if source.currency == Currency.RUB: return Money(source.amount, Currency.RUB) diff --git a/investments/ibtax/ibtax.py b/investments/ibtax/ibtax.py index 225d789..98d4959 100644 --- a/investments/ibtax/ibtax.py +++ b/investments/ibtax/ibtax.py @@ -5,6 +5,7 @@ import pandas # type: ignore +from investments.calculators import compute_total_cost from investments.currency import Currency from investments.data_providers import cbr from investments.dividend import Dividend @@ -12,28 +13,42 @@ from investments.interests import Interest from investments.money import Money from investments.report_parsers.ib import InteractiveBrokersReportParser -from investments.trades_fifo import PortfolioElement, TradesAnalyzer +from investments.trades_fifo import TradesAnalyzer, FinishedTrade, PortfolioElement # noqa: I001 -def prepare_trades_report(df: pandas.DataFrame, cbr_client_usd: cbr.ExchangeRatesRUB) -> pandas.DataFrame: +def prepare_trades_report(finished_trades: List[FinishedTrade], cbr_client_usd: cbr.ExchangeRatesRUB, verbose: bool) -> pandas.DataFrame: + # todo unittest for + trade_date_column = 'trade_date' tax_date_column = 'settle_date' - df['date'] = df['datetime'].dt.normalize() + df = pandas.DataFrame(finished_trades, columns=finished_trades[0]._fields) # noqa: WPS437 + + df[trade_date_column] = df[trade_date_column].dt.normalize() df[tax_date_column] = pandas.to_datetime(df[tax_date_column]) tax_years = df.groupby('N')[tax_date_column].max().map(lambda x: x.year).rename('tax_year') df = df.join(tax_years, how='left', on='N') - df = df.join(cbr_client_usd.dataframe, how='left', on=tax_date_column) - df['total_rub'] = df.apply(lambda x: cbr_client_usd.convert_to_rub(x['total'], x[tax_date_column]).round(digits=2), axis=1) + df['price_rub'] = df.apply(lambda x: cbr_client_usd.convert_to_rub(x['price'], x[tax_date_column]).round(digits=2), axis=1) + df['fee_per_piece_rub'] = df.apply(lambda x: cbr_client_usd.convert_to_rub(x['fee_per_piece'], x[trade_date_column]).round(digits=2), axis=1) + + df['fee_per_piece'] = df.apply(lambda x: x['fee_per_piece'].round(digits=2), axis=1) + df['fee'] = df.apply(lambda x: (x['fee_per_piece'] * abs(x['quantity'])).round(digits=2), axis=1) + df['total'] = df.apply(lambda x: compute_total_cost(x['quantity'], x['price'], x['fee_per_piece']).round(digits=2), axis=1) + df['total_rub'] = df.apply(lambda x: compute_total_cost(x['quantity'], x['price_rub'], x['fee_per_piece_rub']).round(digits=2), axis=1) + + df = df.join(cbr_client_usd.dataframe['rate'].rename('settle_rate'), how='left', on=tax_date_column) + df = df.join(cbr_client_usd.dataframe['rate'].rename('fee_rate'), how='left', on=trade_date_column) df['profit_rub'] = df['total_rub'] - df.loc[df['quantity'] >= 0, 'profit_rub'] *= -1 profit = df.groupby('N')['profit_rub'].sum().reset_index().set_index('N') df = df.join(profit, how='left', on='N', lsuffix='del') df.drop(columns=['profit_rubdel'], axis=0, inplace=True) - df.loc[~df.index.isin(df.groupby('N')['datetime'].idxmax()), 'profit_rub'] = Money(0, Currency.RUB) + df.loc[~df.index.isin(df.groupby('N')[trade_date_column].idxmax()), 'profit_rub'] = Money(0, Currency.RUB) + + if not verbose: + df = df.drop(columns=['fee_per_piece', 'fee_per_piece_rub', 'price_rub']) return df @@ -65,8 +80,7 @@ def prepare_fees_report(fees: List[Fee], cbr_client_usd: cbr.ExchangeRatesRUB) - (i + 1, pandas.to_datetime(x.date), x.amount, x.description, x.date.year) for i, x in enumerate(fees) ] - df = pandas.DataFrame(df_data, columns=['N', 'date', 'amount', 'description', 'tax_year']) - + df = pandas.DataFrame(df_data, columns=['N', operation_date_column, 'amount', 'description', 'tax_year']) df = df.join(cbr_client_usd.dataframe, how='left', on=operation_date_column) df['amount_rub'] = df.apply(lambda x: cbr_client_usd.convert_to_rub(x['amount'], x[operation_date_column]).round(digits=2), axis=1) @@ -79,10 +93,8 @@ def prepare_interests_report(interests: List[Interest], cbr_client_usd: cbr.Exch (i + 1, pandas.to_datetime(x.date), x.amount, x.description, x.date.year) for i, x in enumerate(interests) ] - df = pandas.DataFrame(df_data, columns=['N', 'date', 'amount', 'description', 'tax_year']) - + df = pandas.DataFrame(df_data, columns=['N', operation_date_column, 'amount', 'description', 'tax_year']) df = df.join(cbr_client_usd.dataframe, how='left', on=operation_date_column) - df['amount_rub'] = df.apply(lambda x: cbr_client_usd.convert_to_rub(x['amount'], x[operation_date_column]).round(digits=2), axis=1) return df @@ -138,11 +150,11 @@ def show_report(trades: Optional[pandas.DataFrame], dividends: Optional[pandas.D _show_dividends_report(dividends, year) if trades is not None: - trades_year = trades[trades['tax_year'] == year].drop(columns=['tax_year', 'datetime']) + trades_year = trades[trades['tax_year'] == year].drop(columns=['tax_year']) trades_year['N'] -= trades_year['N'].iloc[0] - 1 _show_header('TRADES') - print(trades_year.set_index(['N', 'ticker', 'date']).to_string()) + print(trades_year.set_index(['N', 'ticker', 'trade_date']).to_string()) print('\n\n') _show_header('TRADES RESULTS BEFORE TAXES') @@ -153,7 +165,7 @@ def show_report(trades: Optional[pandas.DataFrame], dividends: Optional[pandas.D tp = tp['index'].apply(pandas.Series).join(tp).pivot(index=0, columns=1, values='total_rub') tp.index.name = '' tp.columns.name = '' - tp['profit'] = tp['income'] - tp['expenses'] + tp['profit'] = tp['income'] + tp['expenses'] print(tp.reset_index().to_string()) print('\n\n') @@ -226,7 +238,7 @@ def main(): return # fixme first_year without dividends - first_year = min(trades[0].datetime.year, dividends[0].date.year) if dividends else trades[0].datetime.year + first_year = min(trades[0].trade_date.year, dividends[0].date.year) if dividends else trades[0].trade_date.year cbr_client_usd = cbr.ExchangeRatesRUB(currency=Currency.USD, year_from=first_year, cache_dir=args.cache_dir) dividends_report = prepare_dividends_report(dividends, cbr_client_usd, args.verbose) if dividends else None @@ -237,11 +249,7 @@ def main(): finished_trades = analyzer.finished_trades portfolio = analyzer.final_portfolio - if finished_trades: - finished_trades_df = pandas.DataFrame(finished_trades, columns=finished_trades[0]._fields) # noqa: WPS437 - trades_report = prepare_trades_report(finished_trades_df, cbr_client_usd) - else: - trades_report = None + trades_report = prepare_trades_report(finished_trades, cbr_client_usd, args.verbose) if finished_trades else None show_report(trades_report, dividends_report, fees_report, interests_report, args.years) show_portfolio_report(portfolio) diff --git a/investments/money.py b/investments/money.py index 93a5447..4403c54 100644 --- a/investments/money.py +++ b/investments/money.py @@ -48,6 +48,9 @@ def __add__(self, other) -> 'Money': raise TypeError(f'different currencies: {self._currency} & {other.currency}') return Money(self._amount + other.amount, self._currency) + def __abs__(self) -> 'Money': + return Money(abs(self.amount), self.currency) + def __radd__(self, other) -> 'Money': return self.__add__(other) diff --git a/investments/report_parsers/ib.py b/investments/report_parsers/ib.py index dcc16a9..f8aac83 100644 --- a/investments/report_parsers/ib.py +++ b/investments/report_parsers/ib.py @@ -110,11 +110,11 @@ def __repr__(self): return f'IbParser(trades={len(self.trades)}, dividends={len(self.dividends)}, fees={len(self.fees)}, interests={len(self.interests)})' # noqa: WPS221 @property - def trades(self) -> List: + def trades(self) -> List[Trade]: return self._trades @property - def dividends(self) -> List: + def dividends(self) -> List[Dividend]: return self._dividends @property @@ -161,7 +161,7 @@ def parse_csv(self, *, activity_csvs: List[str], trade_confirmation_csvs: List[s }) # 4. sort - self._trades.sort(key=lambda x: x.datetime) + self._trades.sort(key=lambda x: x.trade_date) self._dividends.sort(key=lambda x: x.date) self._interests.sort(key=lambda x: x.date) self._deposits_and_withdrawals.sort(key=lambda x: x[0]) @@ -231,7 +231,7 @@ def _parse_trades(self, f: Dict[str, str]): self._trades.append(Trade( ticker=ticker, - datetime=dt, + trade_date=dt, settle_date=settle_date, quantity=int(f['Quantity']) * quantity_multiplier, price=Money(f['T. Price'], currency), diff --git a/investments/report_parsers/open_fr.py b/investments/report_parsers/open_fr.py index 2bc2375..c5b9472 100644 --- a/investments/report_parsers/open_fr.py +++ b/investments/report_parsers/open_fr.py @@ -98,7 +98,7 @@ def parse_xml(self, xml_file_name: str): self._parse_non_trade_operations(tree) self._parse_trades(tree) - self._trades.sort(key=lambda x: x.datetime) + self._trades.sort(key=lambda x: x.trade_date) self._dividends.sort(key=lambda x: x.date) self._deposits_and_withdrawals.sort(key=lambda x: x[0]) @@ -121,7 +121,7 @@ def _parse_cb_convertation(self, f): dt = _parse_datetime(f['operation_date']) self._trades.append(Trade( ticker=ticker, - datetime=dt, + trade_date=dt, settle_date=dt, quantity=qnty, price=Money(0, Currency.RUB), # TODO: other currencies @@ -155,7 +155,7 @@ def _parse_money_payment(self, f, bonds_redemption): isin, quantity_buyout = m.group('isin'), int(m.group('quantity')) self._trades.append(Trade( ticker=self._tickers.get(isin=isin), - datetime=dt, + trade_date=dt, settle_date=dt, quantity=-1 * quantity_buyout, price=money_total / quantity_buyout, @@ -171,7 +171,7 @@ def _parse_money_payment(self, f, bonds_redemption): for (price, quantity_coupons) in ((Money(0, currency), 1), (money_total, -1)): self._trades.append(Trade( ticker=ticker, - datetime=dt, + trade_date=dt, settle_date=dt, quantity=quantity_coupons, price=price, @@ -184,7 +184,7 @@ def _parse_money_payment(self, f, bonds_redemption): quantity_redemption = bonds_redemption[key] self._trades.append(Trade( ticker=ticker, - datetime=dt, + trade_date=dt, settle_date=dt, quantity=quantity_redemption, price=-1 * money_total / int(quantity_redemption), @@ -275,7 +275,7 @@ def _parse_trades(self, xml_tree: ET.ElementTree): self._trades.append(Trade( ticker=ticker, - datetime=_parse_datetime(f['conclusion_time']), + trade_date=_parse_datetime(f['conclusion_time']), settle_date=_parse_datetime(f['execution_date']), quantity=int(qnty), price=price, diff --git a/investments/trade.py b/investments/trade.py index ebfdc3d..945031e 100644 --- a/investments/trade.py +++ b/investments/trade.py @@ -7,8 +7,21 @@ class Trade(NamedTuple): ticker: Ticker - datetime: datetime.datetime + trade_date: datetime.datetime settle_date: datetime.date quantity: int + + # цена одной бумаги price: Money + + # комиссия за сделку fee: Money + + @property + def settle_datetime(self) -> datetime.datetime: + return datetime.datetime.combine(self.settle_date, datetime.datetime.min.time()) + + @property + def fee_per_piece(self) -> Money: + """Комиссия за сделку за одну бумагу, полезно для расчёта налогов.""" + return self.fee / abs(self.quantity) diff --git a/investments/trades_fifo.py b/investments/trades_fifo.py index d1c0558..276726f 100644 --- a/investments/trades_fifo.py +++ b/investments/trades_fifo.py @@ -2,6 +2,7 @@ from collections import defaultdict from typing import Any, Dict, Iterable, List, NamedTuple, Optional, Tuple +from investments.calculators import compute_total_cost from investments.money import Money from investments.ticker import Ticker from investments.trade import Trade @@ -10,12 +11,21 @@ class FinishedTrade(NamedTuple): N: int ticker: Ticker - datetime: datetime.datetime + + # дата сделки, нужна для расчёта комиссии в рублях на дату + trade_date: datetime.datetime + + # дата поставки, нужна для расчёта цены сделки в рублях на дату settle_date: datetime.date + + # количество бумаг, "+/-" quantity: int + + # цена одной бумаги, "+" price: Money - total: Money - profit: Money + + # комиссия за сделку с одной бумагой, "-" + fee_per_piece: Money class PortfolioElement(NamedTuple): @@ -24,12 +34,9 @@ class PortfolioElement(NamedTuple): class TradesAnalyzer: - _finished_trades: List[FinishedTrade] - _portfolio: List[PortfolioElement] - def __init__(self, trades: Iterable[Trade]): - self._finished_trades = [] - self._portfolio = [] + self._finished_trades: List[FinishedTrade] = [] + self._portfolio: List[PortfolioElement] = [] self.analyze_trades(trades) def analyze_trades(self, trades: Iterable[Trade]): @@ -49,36 +56,34 @@ def analyze_trades(self, trades: Iterable[Trade]): break assert q != 0 - self._finished_trades.append(FinishedTrade( - finished_trade_id, - trade.ticker, - matched_trade.datetime, - matched_trade.settle_date, - q, - matched_trade.price, - abs(q) * matched_trade.price, - Money(0, trade.price.currency), - )) + total_cost = compute_total_cost(q, matched_trade.price, matched_trade.fee_per_piece) + + finished_trade = FinishedTrade( + finished_trade_id, trade.ticker, matched_trade.trade_date, matched_trade.settle_date, q, + matched_trade.price, matched_trade.fee_per_piece, + ) + self._finished_trades.append(finished_trade) - profit = q * (trade.price - matched_trade.price) + q = -1 * q + + profit = compute_total_cost(q, trade.price, trade.fee_per_piece) + total_cost if total_profit is None: total_profit = profit else: total_profit += profit - quantity -= -1 * q + quantity -= q if total_profit is not None: q = trade.quantity - quantity self._finished_trades.append(FinishedTrade( finished_trade_id, trade.ticker, - trade.datetime, + trade.trade_date, trade.settle_date, q, trade.price, - abs(q) * trade.price, - total_profit, + trade.fee_per_piece, )) finished_trade_id += 1 @@ -96,15 +101,15 @@ def final_portfolio(self) -> List[PortfolioElement]: return self._portfolio -def sign(v: int) -> int: - assert v != 0 - return -1 if v < 0 else 1 - - class _TradesFIFO: def __init__(self): self._portfolio = defaultdict(list) + @staticmethod + def sign(v: int) -> int: + assert v != 0 + return -1 if v < 0 else 1 + def put(self, quantity: int, trade: Trade): """ Put trade to the storage. @@ -113,10 +118,10 @@ def put(self, quantity: int, trade: Trade): quantity (int): The real quantity of the trade, >0 for BUY trades & <0 for SELL trades trade (Trade): Base trade, quantity field not used """ - assert sign(quantity) == sign(trade.quantity) + assert self.sign(quantity) == self.sign(trade.quantity) assert abs(quantity) <= abs(trade.quantity) if self._portfolio[trade.ticker]: - assert sign(quantity) == sign(self._portfolio[trade.ticker][0]['quantity']) + assert self.sign(quantity) == self.sign(self._portfolio[trade.ticker][0]['quantity']) self._portfolio[trade.ticker].append({ 'trade': trade, @@ -139,10 +144,10 @@ def match(self, quantity: int, ticker: Ticker) -> Tuple[Optional[Trade], int]: return None, 0 front = self._portfolio[ticker][0] - fqsign = sign(front['quantity']) + fqsign = self.sign(front['quantity']) # only match BUY with SELL and vice versa - if sign(quantity) == fqsign: + if self.sign(quantity) == fqsign: return None, 0 q = fqsign * min(abs(quantity), abs(front['quantity'])) diff --git a/tests/calculators_test.py b/tests/calculators_test.py new file mode 100644 index 0000000..8e8ad2e --- /dev/null +++ b/tests/calculators_test.py @@ -0,0 +1,13 @@ +from investments.currency import Currency +from investments.calculators import compute_total_cost +from investments.money import Money + + +def test_compute_total_cost(): + buy_cost = compute_total_cost(10, Money(1.7, Currency.USD), Money(-0.1, Currency.USD)) + assert buy_cost.amount == -1 * ((10 * 1.7) + (10 * 0.1)) + assert buy_cost.currency is Currency.USD + + sell_cost = compute_total_cost(-10, Money(1.7, Currency.USD), Money(-0.1, Currency.USD)) + assert sell_cost.amount == (10 * 1.7) - (10 * 0.1) + assert sell_cost.currency is Currency.USD diff --git a/tests/data_providers/cbr_test.py b/tests/data_providers/cbr_test.py index 7848b3d..a1ca352 100644 --- a/tests/data_providers/cbr_test.py +++ b/tests/data_providers/cbr_test.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, date from decimal import Decimal import pytest # type: ignore @@ -8,13 +8,13 @@ from investments.data_providers.cbr import ExchangeRatesRUB from investments.money import Money - test_cases = [ (datetime(2015, 1, 15), Currency.USD, Money('66.0983', Currency.RUB)), (datetime(2015, 3, 7), Currency.USD, Money('59.9938', Currency.RUB)), (datetime(2015, 3, 8), Currency.USD, Money('59.9938', Currency.RUB)), (datetime(2020, 3, 31), Currency.USD, Money('77.7325', Currency.RUB)), (datetime(2020, 1, 9), Currency.USD, Money('61.9057', Currency.RUB)), + (date(2020, 2, 4), Currency.EUR, Money('70.7921', Currency.RUB)), (datetime(2015, 3, 7), Currency.EUR, Money('66.1012', Currency.RUB)), (datetime(2015, 1, 15), Currency.EUR, Money('77.9629', Currency.RUB)), diff --git a/tests/ibtax/prepare_trades_report.py b/tests/ibtax/prepare_trades_report.py new file mode 100644 index 0000000..b142b9a --- /dev/null +++ b/tests/ibtax/prepare_trades_report.py @@ -0,0 +1,39 @@ +import datetime + +from investments.currency import Currency +from investments.data_providers.cbr import ExchangeRatesRUB +from investments.ibtax.ibtax import prepare_trades_report +from investments.money import Money +from investments.ticker import Ticker, TickerKind +from investments.trades_fifo import FinishedTrade + + +def test_simple_trades(): + ticker = Ticker(symbol='VT', kind=TickerKind.Stock) + + trades = [ + FinishedTrade(N=1, ticker=ticker, trade_date=datetime.datetime(2020, 1, 30, 0, 0), settle_date=datetime.datetime(2020, 2, 3, 0, 0), quantity=7, + price=Money(80.62, Currency.USD), fee_per_piece=Money(-0.123375, Currency.USD)), + + FinishedTrade(N=1, ticker=ticker, trade_date=datetime.datetime(2020, 1, 31, 0, 0), settle_date=datetime.datetime(2020, 2, 4, 0, 0), quantity=-7, + price=Money(81.82, Currency.USD), fee_per_piece=Money('-0.1309628571428571428571428571', Currency.USD)), + + FinishedTrade(N=2, ticker=ticker, trade_date=datetime.datetime(2020, 1, 30, 0, 0), settle_date=datetime.datetime(2020, 2, 3, 0, 0), quantity=1, + price=Money(80.62, Currency.USD), fee_per_piece=Money('-0.123375', Currency.USD)), + + FinishedTrade(N=2, ticker=ticker, trade_date=datetime.datetime(2020, 1, 31, 0, 0), settle_date=datetime.datetime(2020, 2, 4, 0, 0), quantity=9, + price=Money(80.62, Currency.USD), fee_per_piece=Money(-0.1, Currency.USD)), + + FinishedTrade(N=2, ticker=ticker, trade_date=datetime.datetime(2020, 2, 10, 0, 0), settle_date=datetime.datetime(2020, 2, 12, 0, 0), quantity=-10, + price=Money(81.82, Currency.USD), fee_per_piece=Money('-0.101812674', Currency.USD)), + ] + cbr_client = ExchangeRatesRUB(Currency.USD) + + res: dict = prepare_trades_report(trades, cbr_client, False).to_dict() + + assert res['settle_rate'] == {0: Money(63.1385, Currency.RUB), 1: Money(63.9091, Currency.RUB), 2: Money(63.1385, Currency.RUB), 3: Money(63.9091, Currency.RUB), 4: Money(63.9490, Currency.RUB)} + assert res['fee_rate'] == {0: Money(62.3934, Currency.RUB), 1: Money(63.0359, Currency.RUB), 2: Money(62.3934, Currency.RUB), 3: Money(63.0359, Currency.RUB), 4: Money(63.4720, Currency.RUB)} + assert res['fee'] == {0: Money(-0.84, Currency.USD), 1: Money(-0.91, Currency.USD), 2: Money(-0.12, Currency.USD), 3: Money(-0.90, Currency.USD), 4: Money(-1.00, Currency.USD)} + assert res['total'] == {0: Money(-565.18, Currency.USD), 1: Money(571.83, Currency.USD), 2: Money(-80.74, Currency.USD), 3: Money(-726.48, Currency.USD), 4: Money(817.20, Currency.USD)} + assert res['total_rub'] == {0: Money(-35685.51, Currency.RUB), 1: Money(36545.46, Currency.RUB), 2: Money(-5097.93, Currency.RUB), 3: Money(-46427.85, Currency.RUB), 4: Money(52258.50, Currency.RUB)} + assert res['profit_rub'] == {0: Money(0, Currency.RUB), 1: Money(859.95, Currency.RUB), 2: Money(0, Currency.RUB), 3: Money(0, Currency.RUB), 4: Money(732.72, Currency.RUB)} diff --git a/tests/money_test.py b/tests/money_test.py index 9477bd6..37c8884 100644 --- a/tests/money_test.py +++ b/tests/money_test.py @@ -1,3 +1,5 @@ +from decimal import Decimal + import pytest from investments.currency import Currency @@ -41,6 +43,10 @@ def test_money(): assert r.amount == 2 assert r.currency == Currency.RUB + negative_money = Money(-1, Currency.RUB) + assert negative_money.amount == Decimal('-1') + assert abs(negative_money).amount == Decimal('1') + def test_money_zero(): rub3 = Money(3, Currency.RUB) diff --git a/tests/report_parsers/ib_test.py b/tests/report_parsers/ib_test.py index a0a8114..e396441 100644 --- a/tests/report_parsers/ib_test.py +++ b/tests/report_parsers/ib_test.py @@ -167,20 +167,24 @@ def test_parse_trades_with_fees(): # buy trade assert p.trades[0].ticker.symbol == 'VT' - assert p.trades[0].datetime == _parse_datetime('2020-01-31, 09:30:00') + assert p.trades[0].trade_date == _parse_datetime('2020-01-31, 09:30:00') assert p.trades[0].settle_date == _parse_date('2020-02-04') assert p.trades[0].quantity == 10 assert p.trades[0].price.amount == Decimal('80.62') assert p.trades[0].price.currency == Currency.USD assert p.trades[0].fee.amount == Decimal('-1') assert p.trades[0].fee.currency == Currency.USD + assert p.trades[0].fee_per_piece.currency == Currency.USD + assert p.trades[0].fee_per_piece.amount == Decimal('-0.1') # sell trade assert p.trades[1].ticker.symbol == 'VT' - assert p.trades[1].datetime == _parse_datetime('2020-02-10, 09:38:00') + assert p.trades[1].trade_date == _parse_datetime('2020-02-10, 09:38:00') assert p.trades[1].settle_date == _parse_date('2020-02-12') assert p.trades[1].quantity == -10 assert p.trades[1].price.amount == Decimal('81.82') assert p.trades[1].price.currency == Currency.USD assert p.trades[1].fee.amount == Decimal('-1.01812674') assert p.trades[1].fee.currency == Currency.USD + assert p.trades[1].fee_per_piece.amount == Decimal('-0.101812674') + assert p.trades[1].fee_per_piece.currency == Currency.USD diff --git a/tests/trades_fifo_test.py b/tests/trades_fifo_test.py index e3f4a78..372c290 100644 --- a/tests/trades_fifo_test.py +++ b/tests/trades_fifo_test.py @@ -1,4 +1,5 @@ import datetime +from decimal import Decimal import pytest @@ -6,47 +7,43 @@ from investments.money import Money from investments.ticker import Ticker, TickerKind from investments.trade import Trade -from investments.trades_fifo import TradesAnalyzer +from investments.trades_fifo import TradesAnalyzer, FinishedTrade analyze_trades_fifo_testdata = [ # trades: [(Date, Symbol, Quantity, Price)] - # expect_trades: (N, Symbol, Quantity, Total, Profit) - - # basic case with custom fees - ([('2018-01-01', 'TEST', 100, 4.2), ('2018-01-04', 'TEST', 50, 17.5), ('2018-01-07', 'TEST', -100, 50.3)], - [(1, 'TEST', 100, 420, 0), (1, 'TEST', -100, 5030, 4610)]), + # expect_trades: (N, Symbol, Quantity) # basic cases ([('2018-01-01', 'TEST', 100, 4.2), ('2018-01-04', 'TEST', 50, 17.5), ('2018-01-07', 'TEST', -100, 50.3)], - [(1, 'TEST', 100, 420, 0), (1, 'TEST', -100, 5030, 4610)]), + [(1, 'TEST', 100), (1, 'TEST', -100)]), ([('2018-01-01', 'TEST', 100, 4.2), ('2018-01-04', 'TEST', 50, 17.5), ('2018-01-07', 'TEST', -130, 50.3)], - [(1, 'TEST', 100, 420, 0), (1, 'TEST', 30, 525, 0), (1, 'TEST', -130, 6539, 5594)]), + [(1, 'TEST', 100), (1, 'TEST', 30), (1, 'TEST', -130)]), ([('2018-01-01', 'TEST', -100, 4.2), ('2018-01-04', 'TEST', 30, 17.5)], - [(1, 'TEST', -30, 126, 0), (1, 'TEST', 30, 525, -399)]), + [(1, 'TEST', -30), (1, 'TEST', 30)]), # issue #8 - sell all & open short in one trade ([('2018-01-01', 'TEST', 10, 4.2), ('2018-01-04', 'TEST', -10, 17.5), ('2018-01-05', 'TEST', -3, 17.5)], - [(1, 'TEST', 10, 42, 0), (1, 'TEST', -10, 175, 133)]), + [(1, 'TEST', 10), (1, 'TEST', -10)]), ([('2018-01-01', 'TEST', 10, 4.2), ('2018-01-05', 'TEST', -13, 17.5)], - [(1, 'TEST', 10, 42, 0), (1, 'TEST', -10, 175, 133)]), + [(1, 'TEST', 10), (1, 'TEST', -10)]), ] @pytest.mark.parametrize("trades,expect_trades", analyze_trades_fifo_testdata) -def test_analyze_trades_fifo(trades, expect_trades): +def test_analyze_trades_without_fees(trades, expect_trades): request_trades = [] for date, ticker, qty, price in trades: dt = datetime.datetime.strptime(date, '%Y-%m-%d') request_trades.append(Trade( ticker=Ticker(symbol=ticker, kind=TickerKind.Stock), - datetime=dt, + trade_date=dt, settle_date=dt.date(), quantity=qty, price=Money(price, Currency.USD), - fee=Money(-1, Currency.USD), + fee=Money(0, Currency.USD), )) finished_trades = TradesAnalyzer(request_trades).finished_trades @@ -57,5 +54,45 @@ def test_analyze_trades_fifo(trades, expect_trades): assert expected[0] == trade.N, f'expect trade N={expected[0]} but got {trade.N}' assert expected[1] == trade.ticker.symbol, f'expect trade ticker={expected[1]} but got {trade.ticker.symbol}' assert expected[2] == trade.quantity, f'expect trade quantity={expected[2]} but got {trade.quantity}' - assert expected[3] == trade.total.amount, f'expect trade total={expected[3]} but got {trade.total.amount}' - assert expected[4] == trade.profit.amount, f'expect trade profit={expected[4]} but got {trade.profit.amount}' + + +def test_trades_fees_simple(): + request_trades = [] + ticker = Ticker(symbol='VT', kind=TickerKind.Stock) + + # buy 10 + request_trades.append(Trade( + ticker=ticker, + trade_date=datetime.datetime(year=2020, month=1, day=31), # 63,0359₽ + settle_date=datetime.datetime(year=2020, month=2, day=4), # 63,9091₽ + quantity=10, + price=Money(80.62, Currency.USD), + fee=Money(-1, Currency.USD), + )) + # sell 10 + request_trades.append(Trade( + ticker=ticker, + trade_date=datetime.datetime(year=2020, month=2, day=10), # 63,4720₽ + settle_date=datetime.datetime(year=2020, month=2, day=12), # 63,9490₽ + quantity=-10, + price=Money(81.82, Currency.USD), + fee=Money(Decimal('-1.01812674'), Currency.USD), + )) + + finished_trades = TradesAnalyzer(request_trades).finished_trades + + assert len(finished_trades) == 2 + + buy_trade: FinishedTrade = finished_trades[0] + assert buy_trade.trade_date == datetime.datetime(year=2020, month=1, day=31) + assert buy_trade.settle_date == datetime.datetime(year=2020, month=2, day=4) + assert buy_trade.quantity == 10 + assert buy_trade.price.amount == Decimal('80.62') + assert buy_trade.fee_per_piece.amount == Decimal('-0.1') + + sell_trade: FinishedTrade = finished_trades[1] + assert sell_trade.trade_date == datetime.datetime(year=2020, month=2, day=10) + assert sell_trade.settle_date == datetime.datetime(year=2020, month=2, day=12) + assert sell_trade.quantity == -10 + assert sell_trade.price.amount == Decimal('81.82') + assert sell_trade.fee_per_piece.amount == Decimal('-0.101812674') diff --git a/tests/trades_portfolio_test.py b/tests/trades_portfolio_test.py index 042f994..bb01bf6 100644 --- a/tests/trades_portfolio_test.py +++ b/tests/trades_portfolio_test.py @@ -15,11 +15,11 @@ def test_analyze_portfolio_different_kinds(): dt = datetime.datetime.now() request_trades = [ - Trade(ticker=ticker_stock, datetime=dt, settle_date=dt.date(), quantity=-3, price=Money(4.2, Currency.USD), + Trade(ticker=ticker_stock, trade_date=dt, settle_date=dt.date(), quantity=-3, price=Money(4.2, Currency.USD), fee=Money(1, Currency.USD)), - Trade(ticker=ticker_stock, datetime=dt, settle_date=dt.date(), quantity=8, price=Money(4.2, Currency.USD), + Trade(ticker=ticker_stock, trade_date=dt, settle_date=dt.date(), quantity=8, price=Money(4.2, Currency.USD), fee=Money(1, Currency.USD)), - Trade(ticker=ticker_option, datetime=dt, settle_date=dt.date(), quantity=10, price=Money(4.2, Currency.USD), + Trade(ticker=ticker_option, trade_date=dt, settle_date=dt.date(), quantity=10, price=Money(4.2, Currency.USD), fee=Money(1, Currency.USD)), ] @@ -53,7 +53,7 @@ def test_analyze_portfolio(trades: list, expect_portfolio: dict): dt = datetime.datetime.strptime(date, '%Y-%m-%d') request_trades.append(Trade( ticker=Ticker(symbol=ticker, kind=TickerKind.Stock), - datetime=dt, + trade_date=dt, settle_date=dt.date(), quantity=qty, price=Money(1, Currency.USD),