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.
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/PKG-INFO +1 -1
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/pyproject.toml +1 -1
- investment_python-0.1.0b4/src/investment/benchmark/chart_data.py +66 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/main.py +15 -7
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/program_runner.py +11 -7
- 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
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/filter.py +4 -3
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/repository.py +35 -5
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/transaction.py +20 -1
- investment_python-0.1.0b4/src/investment/portfolio/twr/_market_price_repository.py +19 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/calculation.py +21 -19
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/portfolio.py +12 -3
- investment_python-0.1.0b4/src/investment/returns/returns.py +30 -0
- investment_python-0.1.0b4/src/investment/vo/__init__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/vo/value_objects.py +10 -2
- investment_python-0.1.0b2/src/investment/benchmark/chart_data.py +0 -56
- investment_python-0.1.0b2/src/investment/portfolio/twr/_market_price_repository.py +0 -46
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/LICENSE +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/README.md +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/__init__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/benchmark/__init__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/__init__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/__main__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/__init__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/_fx_rate_fetcher.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/metrics.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/__init__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/__init__.py +0 -0
- {investment_python-0.1.0b2/src/investment/util → investment_python-0.1.0b4/src/investment/returns}/__init__.py +0 -0
- {investment_python-0.1.0b2/src/investment/vo → investment_python-0.1.0b4/src/investment/util}/__init__.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/util/decorator.py +0 -0
- {investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/util/util.py +0 -0
|
@@ -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.
|
|
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.
|
|
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:
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/cli/program_runner.py
RENAMED
|
@@ -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=
|
|
35
|
-
end=
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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.
|
|
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
|
|
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
|
|
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``.
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/filter.py
RENAMED
|
@@ -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:
|
|
9
|
-
end:
|
|
10
|
-
def has(self, value:
|
|
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
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/repository.py
RENAMED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
)
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/transaction.py
RENAMED
|
@@ -49,10 +49,29 @@ class Deposit(NamedTuple):
|
|
|
49
49
|
def is_external_cashflow(self) -> bool:
|
|
50
50
|
return True
|
|
51
51
|
|
|
52
|
-
class
|
|
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)
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/calculation.py
RENAMED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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],
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/portfolio.py
RENAMED
|
@@ -23,14 +23,23 @@ class Holdings:
|
|
|
23
23
|
|
|
24
24
|
class PortfolioSnapshot(NamedTuple):
|
|
25
25
|
date:date
|
|
26
|
-
|
|
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.
|
|
33
|
-
def
|
|
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)
|
|
File without changes
|
|
@@ -7,8 +7,8 @@ class Price(NamedTuple):
|
|
|
7
7
|
cent_value:int
|
|
8
8
|
currency: str
|
|
9
9
|
timestamp: datetime
|
|
10
|
-
def amount(self) ->
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/benchmark/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/marketquote/metrics.py
RENAMED
|
File without changes
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/__init__.py
RENAMED
|
File without changes
|
{investment_python-0.1.0b2 → investment_python-0.1.0b4}/src/investment/portfolio/twr/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|