investment-python 0.1.0b2__tar.gz → 0.1.0b4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/PKG-INFO +1 -1
  2. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/pyproject.toml +1 -1
  3. investment_python-0.1.0b4/src/investment/benchmark/chart_data.py +66 -0
  4. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/main.py +15 -7
  5. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/program_runner.py +11 -7
  6. investment_python-0.1.0b2/src/investment/marketquote/yahoo_finance_fetcher.py → investment_python-0.1.0b4/src/investment/marketquote/_yahoo_finance_fetcher.py +2 -2
  7. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/filter.py +4 -3
  8. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/repository.py +35 -5
  9. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/transaction.py +20 -1
  10. investment_python-0.1.0b4/src/investment/portfolio/twr/_market_price_repository.py +19 -0
  11. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/calculation.py +21 -19
  12. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/portfolio.py +12 -3
  13. investment_python-0.1.0b4/src/investment/returns/returns.py +30 -0
  14. investment_python-0.1.0b4/src/investment/vo/__init__.py +0 -0
  15. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/vo/value_objects.py +10 -2
  16. investment_python-0.1.0b2/src/investment/benchmark/chart_data.py +0 -56
  17. investment_python-0.1.0b2/src/investment/portfolio/twr/_market_price_repository.py +0 -46
  18. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/LICENSE +0 -0
  19. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/README.md +0 -0
  20. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/__init__.py +0 -0
  21. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/benchmark/__init__.py +0 -0
  22. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/__init__.py +0 -0
  23. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/__main__.py +0 -0
  24. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/__init__.py +0 -0
  25. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/_fx_rate_fetcher.py +0 -0
  26. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/metrics.py +0 -0
  27. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/__init__.py +0 -0
  28. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/__init__.py +0 -0
  29. {investment_python-0.1.0b2/src/investment/util → investment_python-0.1.0b4/src/investment/returns}/__init__.py +0 -0
  30. {investment_python-0.1.0b2/src/investment/vo → investment_python-0.1.0b4/src/investment/util}/__init__.py +0 -0
  31. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/util/decorator.py +0 -0
  32. {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/util/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: investment-python
3
- Version: 0.1.0b2
3
+ Version: 0.1.0b4
4
4
  Summary: Add your description here
5
5
  Author: Rui Xue
6
6
  Author-email: Rui Xue <ruixue.fi@gmail.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "investment-python"
3
- version = "0.1.0b2"
3
+ version = "0.1.0b4"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -0,0 +1,66 @@
1
+ import itertools
2
+ import statistics
3
+ from datetime import date
4
+ from typing import NamedTuple
5
+
6
+ from investment.marketquote.repository import fetch_historical_prices
7
+ from investment.vo.value_objects import IndexSeries, Period, PriceSeries
8
+
9
+
10
+ class ChartData(NamedTuple):
11
+ benchmark: tuple[str,PriceSeries]
12
+ stock: tuple[str,PriceSeries]
13
+ base:float=100
14
+
15
+
16
+ def _to_index(self, price_series:PriceSeries) -> dict[date,float]:
17
+ first_price:int = next(iter(price_series.cent_prices.values()))
18
+ return {date:price/first_price*100 for date,price in price_series.cent_prices.items()}
19
+
20
+ def benchmark_index(self) -> IndexSeries:
21
+ """Return the benchmark's price series rebased to ``base`` at its first date."""
22
+ benchmark_id = self.benchmark[0]
23
+ price_series = self.benchmark[1]
24
+ return IndexSeries(benchmark_id, self._to_index(price_series))
25
+
26
+ def stock_index(self) -> IndexSeries:
27
+ """Return the stock's price series rebased to ``base`` at its first date."""
28
+ security_id = self.stock[0]
29
+ price_series = self.stock[1]
30
+ return IndexSeries(security_id, self._to_index(price_series))
31
+
32
+ def coefficient(self)->float:
33
+ """Return the stock's beta relative to the benchmark over the period.
34
+
35
+ Beta = Cov(stock returns, benchmark returns) / Var(benchmark returns),
36
+ computed from daily returns over the trading dates common to both series.
37
+ """
38
+ benchmark_prices = self.benchmark[1].cent_prices
39
+ stock_prices = self.stock[1].cent_prices
40
+ common_dates = sorted(benchmark_prices.keys() & stock_prices.keys())
41
+ if len(common_dates) < 2:
42
+ raise ValueError(
43
+ "Not enough overlapping trading dates between benchmark and stock to compute a beta"
44
+ )
45
+
46
+ benchmark_returns = [
47
+ benchmark_prices[curr] / benchmark_prices[prev] - 1
48
+ for prev, curr in itertools.pairwise(common_dates)
49
+ ]
50
+ stock_returns = [
51
+ stock_prices[curr] / stock_prices[prev] - 1
52
+ for prev, curr in itertools.pairwise(common_dates)
53
+ ]
54
+
55
+ benchmark_variance = statistics.variance(benchmark_returns)
56
+ if benchmark_variance == 0:
57
+ raise ValueError("Benchmark returns have zero variance; beta is undefined")
58
+
59
+ return statistics.covariance(stock_returns, benchmark_returns) / benchmark_variance
60
+
61
+ @staticmethod
62
+ def generate(benchmark_id:str, company_id:str, period:Period) -> "ChartData":
63
+ benchmark_price_series:PriceSeries = fetch_historical_prices(benchmark_id, period)
64
+ stock_price_series: PriceSeries = fetch_historical_prices(company_id, period, currency=(
65
+ benchmark_price_series.currency))
66
+ return ChartData((benchmark_id, benchmark_price_series), (company_id, stock_price_series))
@@ -26,6 +26,11 @@ def _build_parser() -> argparse.ArgumentParser:
26
26
  parser = argparse.ArgumentParser(
27
27
  prog="investment", description="Fetch market quotes and fundamentals."
28
28
  )
29
+ parser.add_argument(
30
+ "--verbose",
31
+ action="store_true",
32
+ help="Print log messages (INFO and above) to stdout. Suppressed by default.",
33
+ )
29
34
  subparsers = parser.add_subparsers(dest="command", required=True)
30
35
  def _build_metrics_parser() -> None:
31
36
  metrics_parser = subparsers.add_parser(
@@ -93,13 +98,16 @@ def _build_parser() -> argparse.ArgumentParser:
93
98
 
94
99
 
95
100
  def main(argv: Sequence[str] | None = None) -> None:
96
- logging.basicConfig(
97
- level=logging.INFO,
98
- format="%(asctime)s %(levelname)s %(name)s.%(funcName)s: %(message)s",
99
- stream=sys.stdout,
100
- )
101
101
  parser = _build_parser()
102
102
  args = parser.parse_args(argv)
103
+ if args.verbose:
104
+ logging.basicConfig(
105
+ level=logging.INFO,
106
+ format="%(asctime)s %(levelname)s %(name)s.%(funcName)s: %(message)s",
107
+ stream=sys.stdout,
108
+ )
109
+ else:
110
+ logging.disable(logging.CRITICAL)
103
111
  if args.command == Command.METRICS:
104
112
  metrics, erratic_company_ids, metrics_records_out_of_range = _run_metrics(
105
113
  names=args.metric_names,
@@ -141,13 +149,13 @@ def main(argv: Sequence[str] | None = None) -> None:
141
149
  benchmark_index = chart_data.benchmark_index()
142
150
  stock_index = chart_data.stock_index()
143
151
  print(
144
- f"Coefficient ({stock_index.symbol} vs {benchmark_index.symbol}): "
152
+ f"Coefficient ({stock_index.label} vs {benchmark_index.label}): "
145
153
  f"{chart_data.coefficient():.4f}"
146
154
  )
147
155
  output_path = None
148
156
  if args.graph_directory:
149
157
  output_path = os.path.join(
150
- args.graph_directory, f"{stock_index.symbol}_vs_{benchmark_index.symbol}.png"
158
+ args.graph_directory, f"{stock_index.label}_vs_{benchmark_index.label}.png"
151
159
  )
152
160
  chart_path = _generate_benchmark_chart(chart_data, output_path=output_path)
153
161
  if chart_path is not None:
@@ -3,8 +3,10 @@
3
3
  import logging
4
4
  import time
5
5
  from datetime import date
6
+ from decimal import Decimal
6
7
 
7
8
  import matplotlib.pyplot as plt
9
+ import numpy
8
10
  import pandas as pd
9
11
 
10
12
  from investment.benchmark.chart_data import ChartData
@@ -31,8 +33,8 @@ def _run_metrics(
31
33
  for entry in price_ranges_str.split(","):
32
34
  company_id, start, end = entry.split(":")
33
35
  result[company_id] = Range(
34
- start=float(start) if start else None,
35
- end=float(end) if end else None,
36
+ start=Decimal(start) if start else None,
37
+ end=Decimal(end) if end else None,
36
38
  )
37
39
  return result
38
40
 
@@ -130,16 +132,18 @@ def _generate_benchmark_chart(
130
132
 
131
133
  fig, ax = plt.subplots()
132
134
  ax.plot(
133
- benchmark_index.index_series.index.to_numpy(), benchmark_index.index_series.to_numpy(),
134
- label=benchmark_index.symbol,
135
+ numpy.array(list(benchmark_index.dates())),
136
+ numpy.array(list(benchmark_index.index_values())),
137
+ label=benchmark_index.label,
135
138
  )
136
139
  ax.plot(
137
- stock_index.index_series.index.to_numpy(), stock_index.index_series.to_numpy(),
138
- label=stock_index.symbol,
140
+ numpy.array(list(stock_index.dates())),
141
+ numpy.array(list(stock_index.index_values())),
142
+ label=stock_index.label,
139
143
  )
140
144
  ax.axhline(chart_data.base, color="gray", linestyle="--", linewidth=0.8)
141
145
  ax.set_title(
142
- f"{stock_index.symbol} vs {benchmark_index.symbol} — indexed to {chart_data.base:.0f}"
146
+ f"{stock_index.label} vs {benchmark_index.label} — indexed to {chart_data.base:.0f}"
143
147
  )
144
148
  ax.set_ylabel("Index value")
145
149
  ax.legend()
@@ -42,7 +42,7 @@ def fetch_current_price(symbol: str) -> tuple[float, str, int]:
42
42
  return price, currency, regular_market_time
43
43
 
44
44
 
45
- def fetcher_close_price(
45
+ def fetch_close_price(
46
46
  symbol: str, target_date: date
47
47
  ) -> tuple[numpy.float64, str, pandas.Timestamp]:
48
48
  """Fetch the closing price for ``symbol`` on or before ``target_date``.
@@ -72,7 +72,7 @@ def fetcher_close_price(
72
72
 
73
73
  return last_close, currency, timestamp
74
74
 
75
- def fetcher_close_prices(
75
+ def fetch_close_prices(
76
76
  symbols: list[str], target_date: date
77
77
  ) -> dict[str, tuple[numpy.float64, str, pandas.Timestamp]]:
78
78
  """Fetch the closing price for each of ``symbols`` on or before ``target_date``.
@@ -1,13 +1,14 @@
1
1
  import math
2
+ from decimal import Decimal
2
3
  from typing import NamedTuple
3
4
 
4
5
  from investment.marketquote.metrics import Metric, MetricsRecord
5
6
 
6
7
 
7
8
  class Range(NamedTuple):
8
- start: float | None
9
- end: float | None
10
- def has(self, value:float) -> bool:
9
+ start: Decimal | None
10
+ end: Decimal | None
11
+ def has(self, value: Decimal) -> bool:
11
12
  start = self.start if self.start else 0
12
13
  end = self.end if self.end else math.inf
13
14
  return start <= value <= end
@@ -5,7 +5,7 @@ from decimal import ROUND_HALF_UP, Decimal
5
5
  from types import MappingProxyType
6
6
  from typing import Any, Final
7
7
 
8
- from investment.marketquote import yahoo_finance_fetcher
8
+ from investment.marketquote import _yahoo_finance_fetcher as yahoo_finance_fetcher
9
9
  from investment.marketquote._fx_rate_fetcher import fetch_fx_rate_from_euro
10
10
  from investment.marketquote._fx_rate_fetcher import (
11
11
  fetch_fx_rate_series_from_euro as _fetch_fx_rate_series_from_euro,
@@ -14,7 +14,7 @@ from investment.marketquote.metrics import Metric, MetricsRecord
14
14
  from investment.util.util import EUR, convert_to_euro_cent
15
15
  from investment.vo.value_objects import FxRateSeries, Percentage, Period, Price, PriceSeries
16
16
 
17
-
17
+ _fx_rate_series_from_euro_cache:dict[str,FxRateSeries] = dict()
18
18
  def fetch_price(symbol: str, target_date: date | None = None) -> Price:
19
19
  """Fetch the price for ``symbol``.
20
20
 
@@ -29,7 +29,7 @@ def fetch_price(symbol: str, target_date: date | None = None) -> Price:
29
29
  currency=currency,
30
30
  timestamp=datetime.fromtimestamp(epoch_seconds, tz=timezone.utc),
31
31
  )
32
- last_close, currency, timestamp = yahoo_finance_fetcher.fetcher_close_price(symbol, target_date)
32
+ last_close, currency, timestamp = yahoo_finance_fetcher.fetch_close_price(symbol, target_date)
33
33
  cent_value = int((Decimal(str(last_close)) * 100).to_integral_value(rounding=ROUND_HALF_UP))
34
34
  return Price(
35
35
  cent_value=cent_value,
@@ -116,11 +116,16 @@ def fetch_current_metrics_batch(
116
116
  records_with_errors = [record for record in records if record.has_errors()]
117
117
  return records_without_errors, records_with_errors
118
118
 
119
- def fetch_historical_prices(company_id:str, period:Period) -> PriceSeries:
119
+ def fetch_historical_prices(company_id:str, period:Period, currency:str=EUR) -> PriceSeries:
120
120
  """Fetch the daily closing price series for ``company_id`` over ``period``."""
121
- prices, currency = yahoo_finance_fetcher.fetch_price_history(
121
+ float_prices, original_currency = yahoo_finance_fetcher.fetch_price_history(
122
122
  company_id, period.from_date, period.to_date
123
123
  )
124
+ prices: dict[date, Decimal] = {d:Decimal(price) for d, price in float_prices.items()}
125
+ if original_currency != currency:
126
+ fx_rate_series = fetch_fx_rate_series(original_currency,currency, period)
127
+ prices = {date:fx_rate_series.get(date)*price for date, price in prices.items()}
128
+
124
129
  cent_prices = {
125
130
  trading_date: int((Decimal(str(price)) * 100).to_integral_value(rounding=ROUND_HALF_UP))
126
131
  for trading_date, price in prices.items()
@@ -136,3 +141,28 @@ def fetch_fx_rate_series_from_euro(currency:str, period:Period) -> FxRateSeries:
136
141
  quote_currency=currency,
137
142
  values=dict(rate_pairs),
138
143
  )
144
+
145
+ def fetch_fx_rate_series(base_currency:str, quote_currency:str, period:Period) -> FxRateSeries:
146
+ """Fetch the ``base_currency``-to-``quote_currency`` exchange rate for every
147
+ day the ECB published a rate for both currencies within ``period`` (both
148
+ dates inclusive).
149
+
150
+ The ECB only publishes EUR-denominated reference rates, so a non-EUR pair
151
+ is triangulated through EUR: rate = (EUR-to-``quote_currency``) /
152
+ (EUR-to-``base_currency``). This also covers ``base_currency`` or
153
+ ``quote_currency`` being EUR, and ``base_currency == quote_currency``,
154
+ without any special-casing, since ``fetch_fx_rate_series_from_euro``
155
+ already returns an identity (rate ``1``) series for EUR.
156
+ """
157
+ base_rates = fetch_fx_rate_series_from_euro(base_currency, period)
158
+ quote_rates = fetch_fx_rate_series_from_euro(quote_currency, period)
159
+ common_dates = base_rates.values.keys() & quote_rates.values.keys()
160
+ values = {
161
+ rate_date: quote_rates.values[rate_date] / base_rates.values[rate_date]
162
+ for rate_date in common_dates
163
+ }
164
+ return FxRateSeries(
165
+ base_currency=base_currency,
166
+ quote_currency=quote_currency,
167
+ values=values,
168
+ )
@@ -49,10 +49,29 @@ class Deposit(NamedTuple):
49
49
  def is_external_cashflow(self) -> bool:
50
50
  return True
51
51
 
52
- class Expense(NamedTuple):
52
+ class ExpenseType(Enum):
53
+ INVESTMENT = auto()
54
+ NON_INVESTMENT = auto()
55
+
56
+ class Expense(Transaction, Protocol):
57
+ def type(self) -> ExpenseType:...
58
+
59
+ class InvestmentExpense(NamedTuple):
60
+ date: date
61
+ money:Decimal
62
+ def cent_value(self) -> int:
63
+ return _money_to_cent_value(self.money)
64
+ def is_external_cashflow(self) -> bool:
65
+ return False
66
+ def type(self):
67
+ return ExpenseType.INVESTMENT
68
+
69
+ class NonInvestmentExpense(NamedTuple):
53
70
  date: date
54
71
  money:Decimal
55
72
  def cent_value(self) -> int:
56
73
  return _money_to_cent_value(self.money)
57
74
  def is_external_cashflow(self) -> bool:
58
75
  return True
76
+ def type(self):
77
+ return ExpenseType.NON_INVESTMENT
@@ -0,0 +1,19 @@
1
+ from datetime import date
2
+
3
+ from investment.marketquote.repository import fetch_historical_prices
4
+ from investment.util.util import EUR
5
+ from investment.vo.value_objects import FxRateSeries, Period, Price, PriceSeries
6
+
7
+ _fx_rate_series_cache:dict[str,FxRateSeries] = dict()
8
+
9
+ class MarketPriceRepository:
10
+ def __init__(self, end_date: date) -> None:
11
+ self.end_date = end_date
12
+ self.series_cache: dict[str, PriceSeries] = {}
13
+ def find_price(self, security_id:str, date:date, currency:str=EUR) -> Price:
14
+ price_series = self.series_cache.get(security_id)
15
+ if price_series is None:
16
+ period = Period(from_date=date, to_date=self.end_date)
17
+ price_series = fetch_historical_prices(security_id, period, currency)
18
+ self.series_cache[security_id] = price_series
19
+ return price_series.get_price(date)
@@ -5,6 +5,8 @@ from typing import Final, NamedTuple
5
5
  from investment.portfolio.transaction import Action, Deposit, Trade, Transaction
6
6
  from investment.portfolio.twr._market_price_repository import MarketPriceRepository
7
7
  from investment.portfolio.twr.portfolio import Holding, Holdings, PortfolioSnapshot
8
+ from investment.returns.returns import DailyReturnSeries
9
+ from investment.util.util import EUR
8
10
  from investment.vo.value_objects import Period
9
11
 
10
12
 
@@ -12,13 +14,16 @@ class DailyReturn(NamedTuple):
12
14
  date: date
13
15
  value: float
14
16
 
15
- class DailyReturnSeries(NamedTuple):
16
- series: list[DailyReturn]
17
-
18
17
  class _PortfolioSnapshotSeriesGenerator:
19
- def __init__(self, transactions:list[Transaction]) -> None:
18
+ def __init__(self, transactions:list[Transaction], reporting_currency:str=EUR) -> None:
20
19
  self.transactions = transactions
20
+ self.reporting_currency = reporting_currency
21
21
  self.market_price_repository = MarketPriceRepository(self._get_end_date())
22
+ self.reporting_currency:str = reporting_currency
23
+ def _get_end_date(self) -> date:
24
+ last_date = self.transactions[-1].date
25
+ last_day_of_month = calendar.monthrange(last_date.year, last_date.month)[1]
26
+ return last_date.replace(day=last_day_of_month)
22
27
  def generate(self) -> dict[date,PortfolioSnapshot]:
23
28
  # Assumes transactions is already sorted by date ascendingly: the last
24
29
  # element is taken as the end date, and snapshots are chained in the
@@ -39,18 +44,13 @@ class _PortfolioSnapshotSeriesGenerator:
39
44
  previous_portfolio_snapshot = portfolio_snapshots[_date] = snapshot
40
45
  return self._add_missing_snapshots(portfolio_snapshots)
41
46
 
42
- def _get_end_date(self) -> date:
43
- last_date = self.transactions[-1].date
44
- last_day_of_month = calendar.monthrange(last_date.year, last_date.month)[1]
45
- return last_date.replace(day=last_day_of_month)
46
-
47
47
  def _new_snapshot(
48
48
  self, daily_transactions:list[Transaction], previous_snapshot:PortfolioSnapshot
49
49
  ) -> PortfolioSnapshot:
50
50
  # date
51
51
  _date:Final[date] = daily_transactions[-1].date
52
52
  # calculate remaining cash in cent
53
- remaining_cash_in_cent:int = previous_snapshot.cash_in_cent
53
+ remaining_cash_in_cent:int = previous_snapshot.cash_balance_in_cent
54
54
  for transaction in daily_transactions:
55
55
  remaining_cash_in_cent += transaction.cent_value()
56
56
  # calculate holdings
@@ -92,7 +92,7 @@ class _PortfolioSnapshotSeriesGenerator:
92
92
  holding_by_security = previous_snapshot.holdings.holding_by_security
93
93
  holdings_with_price = self._reprice_holdings(holding_by_security, _date)
94
94
  return PortfolioSnapshot(
95
- _date, previous_snapshot.cash_in_cent, Holdings(holdings_with_price), []
95
+ _date, previous_snapshot.cash_balance_in_cent, Holdings(holdings_with_price), []
96
96
  )
97
97
 
98
98
  complete_snapshots: dict[date,PortfolioSnapshot] = {}
@@ -113,14 +113,16 @@ class _PortfolioSnapshotSeriesGenerator:
113
113
  return {
114
114
  security_id: Holding(
115
115
  holding.position,
116
- self.market_price_repository.find_euro_price(security_id, _date).cent_value,
116
+ self.market_price_repository.find_price(
117
+ security_id, _date, currency=self.reporting_currency
118
+ ).cent_value,
117
119
  )
118
120
  for security_id, holding in holding_by_security.items()
119
121
  }
120
122
 
121
123
  def calculate_twr(
122
- transactions: list[Transaction],
123
- ) -> tuple[list[PortfolioSnapshot], list[DailyReturn]]:
124
+ transactions: list[Transaction], reporting_currency:str=EUR
125
+ ) -> tuple[list[PortfolioSnapshot], DailyReturnSeries]:
124
126
  """Compute a daily-linked time-weighted return series from a portfolio's
125
127
  transaction history.
126
128
 
@@ -142,21 +144,21 @@ def calculate_twr(
142
144
  no prior snapshot to compare against.
143
145
  """
144
146
  # Step 1: form the map from date to portfolio snapshot for each day
145
- snapshots = _PortfolioSnapshotSeriesGenerator(transactions).generate()
147
+ snapshots = _PortfolioSnapshotSeriesGenerator(transactions, reporting_currency).generate()
146
148
  dates = list(snapshots)
147
149
 
148
150
  # Step 2: chain daily returns, each day's cashflow-adjusted change over the previous day
149
- daily_returns: list[DailyReturn] = []
151
+ daily_returns: dict[date,float] = dict()
150
152
  for previous_date, current_date in zip(dates, dates[1:]):
151
153
  previous_value = snapshots[previous_date].value_in_cent()
152
154
  current_snapshot = snapshots[current_date]
153
155
  current_value = current_snapshot.value_in_cent()
154
- cash_flow = current_snapshot.external_cash_flow_value_in_cent()
156
+ cash_flow = current_snapshot.total_external_cash_flow_in_cent()
155
157
  daily_return = (
156
158
  0.0 if previous_value == 0
157
159
  else (current_value - cash_flow) / previous_value - 1
158
160
  )
159
- daily_returns.append(DailyReturn(current_date, daily_return))
161
+ daily_returns[current_date] = daily_return
160
162
 
161
- return [snapshots[d] for d in dates], daily_returns
163
+ return [snapshots[d] for d in dates], DailyReturnSeries(daily_returns)
162
164
 
@@ -23,14 +23,23 @@ class Holdings:
23
23
 
24
24
  class PortfolioSnapshot(NamedTuple):
25
25
  date:date
26
- cash_in_cent:int
26
+ # Running cash balance as of `date` - carried forward and adjusted from
27
+ # the previous snapshot, not reset per day.
28
+ cash_balance_in_cent:int
27
29
  holdings:Holdings
30
+ # External cash-flow transaction amounts (deposits, non-investment
31
+ # expenses) that occurred on the `date` only - NOT cumulative; reset to []
32
+ # for every snapshot, including carried-forward no-transaction days.
28
33
  external_cash_flows:list[int]
29
34
  def value_in_cent(self) -> int:
30
35
  holdings = self.holdings.holding_by_security.values()
31
36
  holdings_value = sum(h.market_value_in_cent() for h in holdings)
32
- return self.cash_in_cent + holdings_value
33
- def external_cash_flow_value_in_cent(self) -> int:
37
+ return self.cash_balance_in_cent + holdings_value
38
+ def total_external_cash_flow_in_cent(self) -> int:
34
39
  return sum(self.external_cash_flows)
35
40
 
41
+ class PortfolioSnapshotSeries(NamedTuple):
42
+ currency:str
43
+ snapshots:list[PortfolioSnapshot]
44
+
36
45
 
@@ -0,0 +1,30 @@
1
+ from datetime import date
2
+ from typing import NamedTuple
3
+
4
+ from investment.vo.value_objects import IndexSeries
5
+
6
+
7
+ class DailyReturnSeries(NamedTuple):
8
+ value_by_date:dict[date,float]
9
+ def to_index_series(self, label:str, base:float=100) -> IndexSeries:
10
+ """Compound this series' daily returns into a rebased index series.
11
+
12
+ Starts from ``base`` (before the first date in ``value_by_date``)
13
+ and multiplies by ``(1 + daily_return)`` for each date in turn, so
14
+ the result is directly comparable to a price-based index series
15
+ (e.g. ``ChartData``'s benchmark/stock indices) rebased to the same
16
+ ``base``. ``label`` identifies this series (e.g. a portfolio name),
17
+ matching how ``ChartData.benchmark_index()``/``stock_index()`` label
18
+ theirs with a ticker.
19
+
20
+ ``value_by_date`` MUST already be ordered by date, ascending - this
21
+ is a precondition the caller is responsible for, not something this
22
+ method checks or sorts for you, since the running value is compounded
23
+ strictly in dict-iteration order.
24
+ """
25
+ value_by_date: dict[date, float] = {}
26
+ running_value = base
27
+ for return_date, daily_return in self.value_by_date.items():
28
+ running_value *= 1 + daily_return
29
+ value_by_date[return_date] = running_value
30
+ return IndexSeries(label=label, value_by_date=value_by_date)
@@ -7,8 +7,8 @@ class Price(NamedTuple):
7
7
  cent_value:int
8
8
  currency: str
9
9
  timestamp: datetime
10
- def amount(self) -> float:
11
- return self.cent_value / 100
10
+ def amount(self) -> Decimal:
11
+ return Decimal(self.cent_value) / 100
12
12
  def value_with_currency(self) -> str:
13
13
  return f"{self.amount()} {self.currency}"
14
14
  def date(self) -> date:
@@ -55,3 +55,11 @@ class PriceSeries(NamedTuple):
55
55
  currency=self.currency,
56
56
  timestamp=datetime.combine(search_date, datetime.min.time()),
57
57
  )
58
+
59
+ class IndexSeries(NamedTuple):
60
+ label: str
61
+ value_by_date: dict[date, float]
62
+ def dates(self):
63
+ return self.value_by_date.keys()
64
+ def index_values(self):
65
+ return self.value_by_date.values()
@@ -1,56 +0,0 @@
1
- from typing import NamedTuple
2
-
3
- import pandas as pd
4
-
5
- from investment.marketquote.repository import fetch_historical_prices
6
- from investment.vo.value_objects import Period, PriceSeries
7
-
8
-
9
- class LabeledIndexSeries(NamedTuple):
10
- symbol: str
11
- index_series: pd.Series
12
-
13
- class ChartData(NamedTuple):
14
- benchmark: tuple[str,PriceSeries]
15
- stock: tuple[str,PriceSeries]
16
- base:float=100
17
-
18
-
19
- def _to_index(self, price_series:PriceSeries) -> pd.Series:
20
- prices = pd.Series(price_series.cent_prices).sort_index()
21
- return prices / prices.iloc[0] * self.base
22
-
23
- def benchmark_index(self) -> LabeledIndexSeries:
24
- """Return the benchmark's price series rebased to ``base`` at its first date."""
25
- benchmark_id = self.benchmark[0]
26
- price_series = self.benchmark[1]
27
- return LabeledIndexSeries(benchmark_id, self._to_index(price_series))
28
-
29
- def stock_index(self) -> LabeledIndexSeries:
30
- """Return the stock's price series rebased to ``base`` at its first date."""
31
- company_id = self.stock[0]
32
- price_series = self.stock[1]
33
- return LabeledIndexSeries(company_id, self._to_index(price_series))
34
-
35
- def coefficient(self)->float:
36
- """Return the stock's beta relative to the benchmark over the period.
37
-
38
- Beta = Cov(stock returns, benchmark returns) / Var(benchmark returns),
39
- computed from daily returns of the raw price series.
40
- """
41
- benchmark_prices = pd.Series(self.benchmark[1].cent_prices).sort_index()
42
- stock_prices = pd.Series(self.stock[1].cent_prices).sort_index()
43
- benchmark_returns = benchmark_prices.pct_change().dropna()
44
- stock_returns = stock_prices.pct_change().dropna()
45
- aligned = pd.concat(
46
- [benchmark_returns, stock_returns], axis=1, join="inner", keys=["benchmark", "stock"]
47
- )
48
- covariance = aligned["stock"].cov(aligned["benchmark"])
49
- variance = aligned["benchmark"].var()
50
- return covariance / variance
51
-
52
- @staticmethod
53
- def generate(benchmark_id:str, company_id:str, period:Period) -> "ChartData":
54
- benchmark_price_series = fetch_historical_prices(benchmark_id, period)
55
- stock_price_series = fetch_historical_prices(company_id, period)
56
- return ChartData((benchmark_id, benchmark_price_series), (company_id, stock_price_series))
@@ -1,46 +0,0 @@
1
- from datetime import date, datetime
2
- from typing import Final
3
-
4
- from investment.marketquote.repository import (
5
- fetch_fx_rate_series_from_euro,
6
- fetch_historical_prices,
7
- )
8
- from investment.util.util import EUR, convert_to_euro_cent
9
- from investment.vo.value_objects import FxRateSeries, Period, Price, PriceSeries
10
-
11
- _fx_rate_series_cache:dict[str,FxRateSeries] = dict()
12
- def _find_historical_euro_price_series(security_id:str, period:Period) -> PriceSeries:
13
- price_series = fetch_historical_prices(security_id, period)
14
- def fetch_price_in_euro(existing_price: Price) -> Price:
15
- currency: Final = existing_price.currency
16
- if currency == EUR:
17
- return existing_price
18
- else:
19
- if currency not in _fx_rate_series_cache:
20
- _fx_rate_series_cache[currency] = fetch_fx_rate_series_from_euro(currency, period)
21
- fx_rate = _fx_rate_series_cache[currency].get(existing_price.date())
22
- price_value_in_euro = convert_to_euro_cent(existing_price, fx_rate)
23
- return Price(price_value_in_euro, EUR, existing_price.timestamp)
24
- euro_cent_prices = {
25
- trading_date: fetch_price_in_euro(
26
- Price(
27
- cent_value,
28
- price_series.currency,
29
- datetime.combine(trading_date, datetime.min.time()),
30
- )
31
- ).cent_value
32
- for trading_date, cent_value in price_series.cent_prices.items()
33
- }
34
- return PriceSeries(currency=EUR, cent_prices=euro_cent_prices)
35
-
36
- class MarketPriceRepository:
37
- def __init__(self, end_date: date) -> None:
38
- self.end_date = end_date
39
- self.series_cache: dict[str, PriceSeries] = {}
40
- def find_euro_price(self, security_id:str, date:date) -> Price:
41
- price_series = self.series_cache.get(security_id)
42
- if price_series is None:
43
- period = Period(from_date=date, to_date=self.end_date)
44
- price_series = _find_historical_euro_price_series(security_id, period)
45
- self.series_cache[security_id] = price_series
46
- return price_series.get_price(date)