Skip to content

Commit

Permalink
✨ Use fees for profit/lost trades calculation
Browse files Browse the repository at this point in the history
  • Loading branch information
esemi committed Oct 29, 2020
1 parent deec0a3 commit 4173438
Show file tree
Hide file tree
Showing 17 changed files with 248 additions and 91 deletions.
3 changes: 3 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ $ pip install investments --upgrade --user
- конвертация по курсу ЦБ
- раздельный результат сделок по акциям и опционам + дивиденды
- учёт начисленных процентов на остаток по счету
- пока **НЕ** учитывает комисии по сделкам (т.е. налог будет немного больше, в пользу налоговой)
- учитывает комисии по сделкам
- пока **НЕ** поддерживаются сплиты
- пока **НЕ** поддерживаются сделки Forex, сделка пропускается и выводится сообщение о том, что это может повлиять на итоговый отчет

*Пример отчета:*
TODO UPDATE example
![ibtax report example](./images/ibtax_2016.jpg)


Expand Down
16 changes: 16 additions & 0 deletions investments/calculators.py
Original file line number Diff line number Diff line change
@@ -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
13 changes: 11 additions & 2 deletions investments/data_providers/cbr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = []
Expand All @@ -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)

Expand Down
50 changes: 29 additions & 21 deletions investments/ibtax/ibtax.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,50 @@

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
from investments.fees import Fee
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

Expand Down Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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')
Expand All @@ -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')

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions investments/money.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 4 additions & 4 deletions investments/report_parsers/ib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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),
Expand Down
12 changes: 6 additions & 6 deletions investments/report_parsers/open_fr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion investments/trade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading

0 comments on commit 4173438

Please sign in to comment.