investment-python 0.1.0b1__py3-none-any.whl
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/__init__.py +0 -0
- investment/benchmark/__init__.py +0 -0
- investment/benchmark/chart_data.py +56 -0
- investment/cli/__init__.py +0 -0
- investment/cli/__main__.py +7 -0
- investment/cli/main.py +161 -0
- investment/cli/program_runner.py +153 -0
- investment/marketquote/__init__.py +0 -0
- investment/marketquote/_fx_rate_fetcher.py +53 -0
- investment/marketquote/filter.py +34 -0
- investment/marketquote/metrics.py +74 -0
- investment/marketquote/repository.py +124 -0
- investment/marketquote/yahoo_finance_fetcher.py +166 -0
- investment/portfolio/__init__.py +0 -0
- investment/portfolio/transaction.py +58 -0
- investment/portfolio/twr/__init__.py +0 -0
- investment/portfolio/twr/_market_price_repository.py +33 -0
- investment/portfolio/twr/calculation.py +157 -0
- investment/portfolio/twr/portfolio.py +36 -0
- investment/util/__init__.py +0 -0
- investment/util/constants.py +3 -0
- investment/util/decorator.py +15 -0
- investment/vo/__init__.py +0 -0
- investment/vo/value_objects.py +43 -0
- investment_python-0.1.0b1.dist-info/METADATA +94 -0
- investment_python-0.1.0b1.dist-info/RECORD +29 -0
- investment_python-0.1.0b1.dist-info/WHEEL +4 -0
- investment_python-0.1.0b1.dist-info/entry_points.txt +3 -0
- investment_python-0.1.0b1.dist-info/licenses/LICENSE +21 -0
investment/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
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))
|
|
File without changes
|
investment/cli/main.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Command-line entry point for the investment toolkit.
|
|
2
|
+
|
|
3
|
+
Usage examples::
|
|
4
|
+
|
|
5
|
+
investment-python price AAPL
|
|
6
|
+
investment-python price AAPL --date 2026-08-01
|
|
7
|
+
investment-python metrics PRICE,TRAILING_PE --company-symbols AAPL
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
|
|
16
|
+
from investment.cli.program_runner import _generate_benchmark_chart, _run_benchmark, _run_metrics
|
|
17
|
+
from investment.marketquote import repository
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Command(StrEnum):
|
|
21
|
+
METRICS = "metrics"
|
|
22
|
+
BENCHMARK = "benchmark"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
prog="investment", description="Fetch market quotes and fundamentals."
|
|
28
|
+
)
|
|
29
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
def _build_metrics_parser() -> None:
|
|
31
|
+
metrics_parser = subparsers.add_parser(
|
|
32
|
+
Command.METRICS, help="Fetch metrics for symbols."
|
|
33
|
+
)
|
|
34
|
+
metrics_parser.add_argument(
|
|
35
|
+
"metric_names",
|
|
36
|
+
help="One or more metrics to fetch, delimited by comma, e.g. PRICE,TRAILING_PE. "
|
|
37
|
+
f"Choose from {', '.join(metric.name for metric in repository.Metric)}.",
|
|
38
|
+
)
|
|
39
|
+
company_source_group = metrics_parser.add_mutually_exclusive_group(required=True)
|
|
40
|
+
company_source_group.add_argument(
|
|
41
|
+
"--company-symbols",
|
|
42
|
+
help="Company ticker symbols delimited by comma, e.g. AAPL,ELISA.HE",
|
|
43
|
+
)
|
|
44
|
+
company_source_group.add_argument(
|
|
45
|
+
"--company-csv",
|
|
46
|
+
help="Path or URL to a company CSV file with a 'Yahoo Company Symbol' column, "
|
|
47
|
+
"e.g. https://gist.githubusercontent.com/rxue/7ec0914a8af1525d97e8dfd2ac5d61d7/raw/companies.csv",
|
|
48
|
+
)
|
|
49
|
+
metrics_parser.add_argument(
|
|
50
|
+
"--sort-by",
|
|
51
|
+
default=None,
|
|
52
|
+
help="Sort results by this metric, ascending. Must be one of the metrics in --names.",
|
|
53
|
+
)
|
|
54
|
+
metrics_parser.add_argument(
|
|
55
|
+
"--price-ranges",
|
|
56
|
+
default=None,
|
|
57
|
+
help="If given on the premises of price is also given, it should be in the format "
|
|
58
|
+
"like COMPANY_ID1:12:22,COMPANY_ID2:100:",
|
|
59
|
+
)
|
|
60
|
+
metrics_parser.add_argument(
|
|
61
|
+
"--output-csv-name",
|
|
62
|
+
default=None,
|
|
63
|
+
help="If given, also write the metrics result to this CSV file path.",
|
|
64
|
+
)
|
|
65
|
+
def _build_benchmark_parser() -> None:
|
|
66
|
+
benchmark_parser = subparsers.add_parser(
|
|
67
|
+
Command.BENCHMARK, help="Benchmark stocks against an index or another stock."
|
|
68
|
+
)
|
|
69
|
+
benchmark_parser.add_argument(
|
|
70
|
+
"benchmark_pair",
|
|
71
|
+
help="The benchmark and company ticker symbols, delimited by a colon, "
|
|
72
|
+
"e.g. VOO:T.",
|
|
73
|
+
)
|
|
74
|
+
benchmark_parser.add_argument(
|
|
75
|
+
"--start-date",
|
|
76
|
+
required=True,
|
|
77
|
+
help="Start date of the period, in ISO format, e.g. 2024-01-01.",
|
|
78
|
+
)
|
|
79
|
+
benchmark_parser.add_argument(
|
|
80
|
+
"--end-date",
|
|
81
|
+
required=True,
|
|
82
|
+
help="End date of the period, in ISO format, e.g. 2026-01-01.",
|
|
83
|
+
)
|
|
84
|
+
benchmark_parser.add_argument(
|
|
85
|
+
"--graph-directory",
|
|
86
|
+
default=None,
|
|
87
|
+
help="If given, save the chart as a PNG into this directory. "
|
|
88
|
+
"If omitted, the chart is not saved.",
|
|
89
|
+
)
|
|
90
|
+
_build_metrics_parser()
|
|
91
|
+
_build_benchmark_parser()
|
|
92
|
+
return parser
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
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
|
+
parser = _build_parser()
|
|
102
|
+
args = parser.parse_args(argv)
|
|
103
|
+
if args.command == Command.METRICS:
|
|
104
|
+
metrics, erratic_company_ids, metrics_records_out_of_range = _run_metrics(
|
|
105
|
+
names=args.metric_names,
|
|
106
|
+
company_symbols=args.company_symbols,
|
|
107
|
+
company_csv=args.company_csv,
|
|
108
|
+
sort_by=args.sort_by,
|
|
109
|
+
price_ranges_str=args.price_ranges,
|
|
110
|
+
)
|
|
111
|
+
print(metrics.to_string(index=False))
|
|
112
|
+
if not erratic_company_ids.empty:
|
|
113
|
+
print()
|
|
114
|
+
print("Companies fetched with error")
|
|
115
|
+
print(erratic_company_ids.to_string(index=False))
|
|
116
|
+
if metrics_records_out_of_range is not None:
|
|
117
|
+
print()
|
|
118
|
+
print("Stocks with price out of range")
|
|
119
|
+
print(metrics_records_out_of_range.to_string(index=False))
|
|
120
|
+
|
|
121
|
+
if args.output_csv_name:
|
|
122
|
+
metrics.to_csv(args.output_csv_name, index=False)
|
|
123
|
+
if not erratic_company_ids.empty:
|
|
124
|
+
erratic_company_ids.to_csv("companies_with_error.csv", index=False)
|
|
125
|
+
if not metrics_records_out_of_range.empty:
|
|
126
|
+
metrics_records_out_of_range.to_csv("alert_on_companies.csv", index=False)
|
|
127
|
+
elif args.command == Command.BENCHMARK:
|
|
128
|
+
try:
|
|
129
|
+
benchmark_id, company_id = args.benchmark_pair.split(":")
|
|
130
|
+
except ValueError:
|
|
131
|
+
parser.error(
|
|
132
|
+
f"argument benchmark_pair: invalid format: {args.benchmark_pair!r} "
|
|
133
|
+
"(expected BENCHMARK_ID:COMPANY_ID, e.g. VOO:T)"
|
|
134
|
+
)
|
|
135
|
+
chart_data = _run_benchmark(
|
|
136
|
+
benchmark_id=benchmark_id,
|
|
137
|
+
company_id=company_id,
|
|
138
|
+
start_date=args.start_date,
|
|
139
|
+
end_date=args.end_date,
|
|
140
|
+
)
|
|
141
|
+
benchmark_index = chart_data.benchmark_index()
|
|
142
|
+
stock_index = chart_data.stock_index()
|
|
143
|
+
print(
|
|
144
|
+
f"Coefficient ({stock_index.symbol} vs {benchmark_index.symbol}): "
|
|
145
|
+
f"{chart_data.coefficient():.4f}"
|
|
146
|
+
)
|
|
147
|
+
output_path = None
|
|
148
|
+
if args.graph_directory:
|
|
149
|
+
output_path = os.path.join(
|
|
150
|
+
args.graph_directory, f"{stock_index.symbol}_vs_{benchmark_index.symbol}.png"
|
|
151
|
+
)
|
|
152
|
+
chart_path = _generate_benchmark_chart(chart_data, output_path=output_path)
|
|
153
|
+
if chart_path is not None:
|
|
154
|
+
print(f"Chart saved to {chart_path}")
|
|
155
|
+
|
|
156
|
+
else: # pragma: no cover - guarded by argparse's `required=True`
|
|
157
|
+
parser.error(f"Unknown command: {args.command}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
main()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Orchestration for the CLI ``metrics`` command."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from datetime import date
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from investment.benchmark.chart_data import ChartData
|
|
11
|
+
from investment.marketquote import metrics, repository
|
|
12
|
+
from investment.marketquote.filter import Range, records_out_of_range
|
|
13
|
+
from investment.util.decorator import clock
|
|
14
|
+
from investment.vo.value_objects import Period
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@clock
|
|
20
|
+
def _run_metrics(
|
|
21
|
+
names: str,
|
|
22
|
+
company_symbols: str | None = None,
|
|
23
|
+
company_csv: str | None = None,
|
|
24
|
+
sort_by: str | None = None,
|
|
25
|
+
price_ranges_str: str | None = None,
|
|
26
|
+
) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
27
|
+
def extract_price_ranges() -> dict[str, Range]:
|
|
28
|
+
if not price_ranges_str:
|
|
29
|
+
return {}
|
|
30
|
+
result: dict[str, Range] = {}
|
|
31
|
+
for entry in price_ranges_str.split(","):
|
|
32
|
+
company_id, start, end = entry.split(":")
|
|
33
|
+
result[company_id] = Range(
|
|
34
|
+
start=float(start) if start else None,
|
|
35
|
+
end=float(end) if end else None,
|
|
36
|
+
)
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
def _load_company_symbols(csv_source: str) -> str:
|
|
40
|
+
"""Load comma-delimited Yahoo ticker symbols from a company CSV file.
|
|
41
|
+
|
|
42
|
+
``csv_source`` may be a local file path or an http(s) URL. The CSV must
|
|
43
|
+
contain a "Yahoo Company Symbol" column, e.g.
|
|
44
|
+
https://gist.githubusercontent.com/rxue/7ec0914a8af1525d97e8dfd2ac5d61d7/raw/companies.csv
|
|
45
|
+
"""
|
|
46
|
+
companies = pd.read_csv(csv_source)
|
|
47
|
+
return ",".join(companies["Yahoo Company Symbol"].astype(str))
|
|
48
|
+
|
|
49
|
+
metric_names = [name.strip() for name in names.split(",")]
|
|
50
|
+
try:
|
|
51
|
+
metric_list = [repository.Metric[name] for name in metric_names]
|
|
52
|
+
except KeyError as exc:
|
|
53
|
+
valid_names = ", ".join(metric.name for metric in repository.Metric)
|
|
54
|
+
raise SystemExit(
|
|
55
|
+
f"investment metrics: error: argument --names: invalid choice: {exc.args[0]!r} "
|
|
56
|
+
f"(choose from {valid_names})"
|
|
57
|
+
) from None
|
|
58
|
+
|
|
59
|
+
sort_by_metric = None
|
|
60
|
+
if sort_by is not None:
|
|
61
|
+
try:
|
|
62
|
+
sort_by_metric = repository.Metric[sort_by]
|
|
63
|
+
except KeyError:
|
|
64
|
+
valid_names = ", ".join(metric.name for metric in repository.Metric)
|
|
65
|
+
raise SystemExit(
|
|
66
|
+
f"investment metrics: error: argument --sort-by: invalid choice: {sort_by!r} "
|
|
67
|
+
f"(choose from {valid_names})"
|
|
68
|
+
) from None
|
|
69
|
+
if sort_by_metric not in metric_list:
|
|
70
|
+
raise SystemExit(
|
|
71
|
+
f"investment metrics: error: argument --sort-by: {sort_by!r} must be one of the "
|
|
72
|
+
f"metrics in --names ({', '.join(metric_names)})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if company_symbols:
|
|
76
|
+
company_ids = company_symbols
|
|
77
|
+
elif company_csv is not None:
|
|
78
|
+
company_ids = _load_company_symbols(company_csv)
|
|
79
|
+
else:
|
|
80
|
+
raise SystemExit(
|
|
81
|
+
"investment metrics: error: one of --company-symbols or --company-csv is required"
|
|
82
|
+
)
|
|
83
|
+
company_id_list = [symbol.strip() for symbol in company_ids.split(",")]
|
|
84
|
+
batch_size = 100
|
|
85
|
+
thread_amount = 10
|
|
86
|
+
if len(company_id_list) > batch_size:
|
|
87
|
+
rows = []
|
|
88
|
+
erratic_rows = []
|
|
89
|
+
for i in range(0, len(company_id_list), batch_size):
|
|
90
|
+
batch = company_id_list[i : i + batch_size]
|
|
91
|
+
metrics_records, erratic_metrics_records = repository.fetch_current_metrics_batch(
|
|
92
|
+
batch, metric_list, thread_amount
|
|
93
|
+
)
|
|
94
|
+
rows.extend(metrics_records)
|
|
95
|
+
erratic_metrics_records.extend(erratic_metrics_records)
|
|
96
|
+
logger.info("Executed one batch")
|
|
97
|
+
time.sleep(60)
|
|
98
|
+
else:
|
|
99
|
+
rows, erratic_rows = repository.fetch_current_metrics_batch(
|
|
100
|
+
company_id_list, metric_list, thread_amount
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if sort_by_metric is not None:
|
|
104
|
+
rows = metrics.sort_records(rows, sort_by_metric)
|
|
105
|
+
records_out_of_range_df = pd.DataFrame()
|
|
106
|
+
if price_ranges_str is not None:
|
|
107
|
+
price_ranges = extract_price_ranges()
|
|
108
|
+
records_outside = records_out_of_range(rows, price_ranges)
|
|
109
|
+
records_out_of_range_df = pd.DataFrame([r.to_readable() for r in records_outside])
|
|
110
|
+
return (
|
|
111
|
+
pd.DataFrame([r.to_readable() for r in rows]),
|
|
112
|
+
pd.DataFrame([r.company_id for r in erratic_rows], columns=["non-existing company"]),
|
|
113
|
+
records_out_of_range_df,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def _run_benchmark(benchmark_id:str,company_id:str,start_date:str,end_date:str) -> ChartData:
|
|
117
|
+
period = Period(from_date=date.fromisoformat(start_date), to_date=date.fromisoformat(end_date))
|
|
118
|
+
return ChartData.generate(benchmark_id, company_id, period)
|
|
119
|
+
|
|
120
|
+
def _generate_benchmark_chart(
|
|
121
|
+
chart_data:ChartData, output_path:str|None=None, show:bool=True
|
|
122
|
+
) -> str|None:
|
|
123
|
+
"""Plot the benchmark's and stock's rebased index series.
|
|
124
|
+
|
|
125
|
+
Displays the chart in a window by default (``show=True``). Saved to
|
|
126
|
+
``output_path`` only if given; returns that path, or ``None`` if not saved.
|
|
127
|
+
"""
|
|
128
|
+
benchmark_index = chart_data.benchmark_index()
|
|
129
|
+
stock_index = chart_data.stock_index()
|
|
130
|
+
|
|
131
|
+
fig, ax = plt.subplots()
|
|
132
|
+
ax.plot(
|
|
133
|
+
benchmark_index.index_series.index.to_numpy(), benchmark_index.index_series.to_numpy(),
|
|
134
|
+
label=benchmark_index.symbol,
|
|
135
|
+
)
|
|
136
|
+
ax.plot(
|
|
137
|
+
stock_index.index_series.index.to_numpy(), stock_index.index_series.to_numpy(),
|
|
138
|
+
label=stock_index.symbol,
|
|
139
|
+
)
|
|
140
|
+
ax.axhline(chart_data.base, color="gray", linestyle="--", linewidth=0.8)
|
|
141
|
+
ax.set_title(
|
|
142
|
+
f"{stock_index.symbol} vs {benchmark_index.symbol} — indexed to {chart_data.base:.0f}"
|
|
143
|
+
)
|
|
144
|
+
ax.set_ylabel("Index value")
|
|
145
|
+
ax.legend()
|
|
146
|
+
fig.autofmt_xdate()
|
|
147
|
+
|
|
148
|
+
if output_path is not None:
|
|
149
|
+
fig.savefig(output_path, dpi=150)
|
|
150
|
+
if show:
|
|
151
|
+
plt.show()
|
|
152
|
+
plt.close(fig)
|
|
153
|
+
return output_path
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import date
|
|
5
|
+
from functools import cache
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from investment.util.constants import EUR
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
@cache
|
|
14
|
+
def fetch_fx_rate_to_euro(base_currency: str, target_date: date) -> tuple[date, float]:
|
|
15
|
+
"""Fetch the ``base_currency``-to-EUR exchange rate for ``date`` from the ECB
|
|
16
|
+
(European Central Bank).
|
|
17
|
+
|
|
18
|
+
Returns ``1.0`` for ``date`` unchanged when ``base_currency`` is already ``'EUR'``.
|
|
19
|
+
For a past ``date``, requests that day's rate from the ECB data API. For today
|
|
20
|
+
or a future ``date``, requests the latest available observation instead, since
|
|
21
|
+
the ECB has no rate published yet for those.
|
|
22
|
+
|
|
23
|
+
Because the ECB only publishes rates for business days, the returned date may
|
|
24
|
+
differ from the requested ``date`` (e.g. a weekend or holiday falls back to the
|
|
25
|
+
most recent prior business day).
|
|
26
|
+
|
|
27
|
+
:param base_currency: ISO 4217 currency code to convert from (e.g. ``'USD'``).
|
|
28
|
+
:param target_date: the date to fetch the rate for.
|
|
29
|
+
:return: a tuple of the actual observation date and the exchange rate.
|
|
30
|
+
:raises requests.HTTPError: if the ECB API request fails.
|
|
31
|
+
:raises StopIteration: if the API response contains no observations.
|
|
32
|
+
"""
|
|
33
|
+
if base_currency == EUR:
|
|
34
|
+
return target_date, 1
|
|
35
|
+
url = f"https://data-api.ecb.europa.eu/service/data/EXR/D.{base_currency}.EUR.SP00.A"
|
|
36
|
+
today = date.today()
|
|
37
|
+
if target_date < today:
|
|
38
|
+
date_str = target_date.strftime("%Y-%m-%d")
|
|
39
|
+
response = requests.get(url, params={
|
|
40
|
+
"startPeriod": date_str,
|
|
41
|
+
"endPeriod": date_str,
|
|
42
|
+
"format": "csvdata",
|
|
43
|
+
})
|
|
44
|
+
else:
|
|
45
|
+
logger.info(f"Fetch FX rate for {target_date}")
|
|
46
|
+
response = requests.get(url, params={
|
|
47
|
+
"lastNObservations": 1,
|
|
48
|
+
"format": "csvdata",
|
|
49
|
+
})
|
|
50
|
+
response.raise_for_status()
|
|
51
|
+
reader = csv.DictReader(io.StringIO(response.text))
|
|
52
|
+
row = next(reader)
|
|
53
|
+
return target_date.fromisoformat(row["TIME_PERIOD"]), float(row["OBS_VALUE"])
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
from investment.marketquote.metrics import Metric, MetricsRecord
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Range(NamedTuple):
|
|
8
|
+
start: float | None
|
|
9
|
+
end: float | None
|
|
10
|
+
def has(self, value:float) -> bool:
|
|
11
|
+
start = self.start if self.start else 0
|
|
12
|
+
end = self.end if self.end else math.inf
|
|
13
|
+
return start <= value <= end
|
|
14
|
+
|
|
15
|
+
def records_out_of_range(
|
|
16
|
+
all_metric_records: list[MetricsRecord], ranges: dict[str, Range]
|
|
17
|
+
) -> list[MetricsRecord]:
|
|
18
|
+
"""Return the records whose ``Metric.PRICE`` falls outside its configured range.
|
|
19
|
+
|
|
20
|
+
A record is skipped (not returned) when its company has no configured
|
|
21
|
+
range, no ``Metric.PRICE`` value, or that value is an error rather than a
|
|
22
|
+
``Price``.
|
|
23
|
+
"""
|
|
24
|
+
result = []
|
|
25
|
+
for record in all_metric_records:
|
|
26
|
+
price_range = ranges.get(record.company_id)
|
|
27
|
+
if price_range is None:
|
|
28
|
+
continue
|
|
29
|
+
price = record.metrics.get(Metric.PRICE)
|
|
30
|
+
if price is None or isinstance(price, Exception):
|
|
31
|
+
continue
|
|
32
|
+
if not price_range.has(price.amount()):
|
|
33
|
+
result.append(record)
|
|
34
|
+
return result
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, NamedTuple
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class Metric(Enum):
|
|
10
|
+
COMPANY_NAME = ("shortName", "Company Name")
|
|
11
|
+
PRICE = (None, "Price")
|
|
12
|
+
PRICE_IN_EURO = (None, "Price in EURO")
|
|
13
|
+
MARKET_STATE = ("marketState", "Market State")
|
|
14
|
+
TRAILING_PE = ("trailingPE", "Trailing P/E")
|
|
15
|
+
DIVIDEND_YIELD = ("dividendYield", "Dividend Yield %")
|
|
16
|
+
DIVIDEND_PAYOUT_RATIO = ("payoutRatio", "Dividend Payout Ratio %")
|
|
17
|
+
RETURN_ON_EQUITY = ("returnOnEquity", "Return on Equity %")
|
|
18
|
+
REGULAR_MARKET_CHANGE_PERCENT = ("regularMarketChangePercent", "Regular Market Change %")
|
|
19
|
+
PRICE_TO_BOOK = ("priceToBook", "Price to Book")
|
|
20
|
+
|
|
21
|
+
def __init__(self, yahoo_metric_name: str | None, label: str) -> None:
|
|
22
|
+
self.yahoo_metric_name = yahoo_metric_name
|
|
23
|
+
self.label = label
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MetricsRecord(NamedTuple):
|
|
27
|
+
company_id: str
|
|
28
|
+
metrics: Mapping[Metric, Any]
|
|
29
|
+
def has_errors(self) -> bool:
|
|
30
|
+
return any(isinstance(value, Exception) for value in self.metrics.values())
|
|
31
|
+
def to_readable(self) -> dict[str,Any]:
|
|
32
|
+
if self.has_errors():
|
|
33
|
+
errors = {
|
|
34
|
+
metric.label: str(value)
|
|
35
|
+
for metric, value in self.metrics.items()
|
|
36
|
+
if isinstance(value, Exception)
|
|
37
|
+
}
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Cannot make metrics for {self.company_id} readable due to error(s): {errors}"
|
|
40
|
+
)
|
|
41
|
+
result:dict[str,Any] = {"company":self.company_id}
|
|
42
|
+
logger.info(f"Company: {self.company_id}")
|
|
43
|
+
for metric,value in self.metrics.items():
|
|
44
|
+
if metric == Metric.PRICE:
|
|
45
|
+
result[metric.label] = value.value_with_currency()
|
|
46
|
+
elif metric == Metric.PRICE_IN_EURO:
|
|
47
|
+
result[metric.label] = value.amount()
|
|
48
|
+
elif metric.label.endswith("%") and value is not None:
|
|
49
|
+
logger.info(f"Metric, {metric}, with percent or fraction value: {value}")
|
|
50
|
+
result[metric.label] = value.percent_value()
|
|
51
|
+
elif metric == Metric.TRAILING_PE:
|
|
52
|
+
if value is not None:
|
|
53
|
+
result[metric.label] = int(value*10)/10
|
|
54
|
+
elif metric == Metric.PRICE_TO_BOOK:
|
|
55
|
+
if value is not None:
|
|
56
|
+
result[metric.label] = int(value*100)/100
|
|
57
|
+
else:
|
|
58
|
+
result[metric.label] = value
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
def sort_records(records: list[MetricsRecord], sort_by: Metric) -> list[MetricsRecord]:
|
|
62
|
+
"""Sort ``records`` by their ``sort_by`` metric value, ascending.
|
|
63
|
+
|
|
64
|
+
Records missing ``sort_by`` (absent key, ``None``, or ``NaN``) sort last,
|
|
65
|
+
regardless of the metric's type.
|
|
66
|
+
"""
|
|
67
|
+
def sort_key(record: MetricsRecord) -> tuple[bool, Any]:
|
|
68
|
+
value = record.metrics.get(sort_by)
|
|
69
|
+
if sort_by in (Metric.PRICE, Metric.PRICE_IN_EURO) and value is not None:
|
|
70
|
+
value = value.amount()
|
|
71
|
+
is_missing = value is None or (isinstance(value, float) and math.isnan(value))
|
|
72
|
+
return (is_missing, 0.0 if is_missing else value)
|
|
73
|
+
|
|
74
|
+
return sorted(records, key=sort_key)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from collections.abc import Collection
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from datetime import date, datetime, timezone
|
|
4
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any, Final
|
|
7
|
+
|
|
8
|
+
from investment.marketquote import yahoo_finance_fetcher
|
|
9
|
+
from investment.marketquote._fx_rate_fetcher import fetch_fx_rate_to_euro
|
|
10
|
+
from investment.marketquote.metrics import Metric, MetricsRecord
|
|
11
|
+
from investment.util.constants import EUR
|
|
12
|
+
from investment.vo.value_objects import Percentage, Period, Price, PriceSeries
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def fetch_price(symbol: str, target_date: date | None = None) -> Price:
|
|
16
|
+
"""Fetch the price for ``symbol``.
|
|
17
|
+
|
|
18
|
+
Returns the current quoted price when ``target_date`` is ``None``,
|
|
19
|
+
otherwise the closing price on or before ``target_date``.
|
|
20
|
+
"""
|
|
21
|
+
if target_date is None:
|
|
22
|
+
price, currency, epoch_seconds = yahoo_finance_fetcher.fetch_current_price(symbol)
|
|
23
|
+
cent_value = int((Decimal(str(price)) * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
|
24
|
+
return Price(
|
|
25
|
+
cent_value=cent_value,
|
|
26
|
+
currency=currency,
|
|
27
|
+
timestamp=datetime.fromtimestamp(epoch_seconds, tz=timezone.utc),
|
|
28
|
+
)
|
|
29
|
+
last_close, currency, timestamp = yahoo_finance_fetcher.fetcher_close_price(symbol, target_date)
|
|
30
|
+
cent_value = int((Decimal(str(last_close)) * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
|
31
|
+
return Price(
|
|
32
|
+
cent_value=cent_value,
|
|
33
|
+
currency=currency,
|
|
34
|
+
timestamp=timestamp,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def fetch_price_in_euro(existing_price: Price) -> Price:
|
|
38
|
+
currency:Final = existing_price.currency_value()
|
|
39
|
+
if currency == EUR:
|
|
40
|
+
return existing_price
|
|
41
|
+
else:
|
|
42
|
+
_, fx_rate = fetch_fx_rate_to_euro(currency, existing_price.date())
|
|
43
|
+
price_value = round(existing_price.cent_value / fx_rate)
|
|
44
|
+
return Price(price_value, EUR, existing_price.timestamp)
|
|
45
|
+
|
|
46
|
+
def fetch_current_metrics(
|
|
47
|
+
company_id: str, metrics: Collection[Metric]
|
|
48
|
+
) -> MetricsRecord:
|
|
49
|
+
def fetch_fundamental_metrics() -> dict[Metric,Any]:
|
|
50
|
+
fundamental_metrics_by_yahoo_name = {
|
|
51
|
+
metric.yahoo_metric_name: metric
|
|
52
|
+
for metric in metrics
|
|
53
|
+
if metric not in [Metric.PRICE, Metric.PRICE_IN_EURO]
|
|
54
|
+
and metric.yahoo_metric_name is not None
|
|
55
|
+
}
|
|
56
|
+
fundamental_metrics_values = yahoo_finance_fetcher.fetch_fundamental_metrics(
|
|
57
|
+
company_id, fundamental_metrics_by_yahoo_name.keys()
|
|
58
|
+
)
|
|
59
|
+
fundamenal_metrics: dict[Metric, Any] = {
|
|
60
|
+
fundamental_metrics_by_yahoo_name[yahoo_metric_name]: value
|
|
61
|
+
for yahoo_metric_name, value in fundamental_metrics_values.items()
|
|
62
|
+
}
|
|
63
|
+
for metric in metrics:
|
|
64
|
+
if metric in (Metric.RETURN_ON_EQUITY, Metric.DIVIDEND_PAYOUT_RATIO):
|
|
65
|
+
fraction_value = fundamenal_metrics[metric]
|
|
66
|
+
if fraction_value is not None:
|
|
67
|
+
fundamenal_metrics[metric] = Percentage(fraction_value)
|
|
68
|
+
elif metric.label.endswith("%"):
|
|
69
|
+
percent_value = fundamenal_metrics[metric]
|
|
70
|
+
if percent_value is not None:
|
|
71
|
+
fundamenal_metrics[metric] = Percentage(percent_value / 100)
|
|
72
|
+
return fundamenal_metrics
|
|
73
|
+
combined_metrics: dict[Metric,Any] = fetch_fundamental_metrics()
|
|
74
|
+
if Metric.PRICE in metrics:
|
|
75
|
+
try:
|
|
76
|
+
combined_metrics[Metric.PRICE] = fetch_price(company_id)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
combined_metrics[Metric.PRICE] = e
|
|
79
|
+
if Metric.PRICE_IN_EURO in metrics:
|
|
80
|
+
existing_price = combined_metrics.get(Metric.PRICE)
|
|
81
|
+
if not isinstance(existing_price, Price):
|
|
82
|
+
existing_price = None
|
|
83
|
+
try:
|
|
84
|
+
price = existing_price if existing_price is not None else fetch_price(company_id)
|
|
85
|
+
combined_metrics[Metric.PRICE_IN_EURO] = fetch_price_in_euro(price)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
combined_metrics[Metric.PRICE_IN_EURO] = e
|
|
88
|
+
return MetricsRecord(company_id=company_id, metrics=MappingProxyType(combined_metrics))
|
|
89
|
+
|
|
90
|
+
def fetch_current_metrics_batch(
|
|
91
|
+
company_ids: Collection[str], metrics: Collection[Metric], thread_amount: int | None
|
|
92
|
+
) -> tuple[list[MetricsRecord], list[MetricsRecord]]:
|
|
93
|
+
"""Fetch metrics for ``company_ids``, split into records without errors and
|
|
94
|
+
records with errors.
|
|
95
|
+
|
|
96
|
+
Returns a ``(records_without_errors, records_with_errors)`` tuple.
|
|
97
|
+
"""
|
|
98
|
+
if thread_amount is not None:
|
|
99
|
+
company_ids = list(company_ids)
|
|
100
|
+
if not company_ids:
|
|
101
|
+
return [], []
|
|
102
|
+
with ThreadPoolExecutor(max_workers=min(len(company_ids), thread_amount)) as executor:
|
|
103
|
+
records = list(
|
|
104
|
+
executor.map(
|
|
105
|
+
lambda company_id: fetch_current_metrics(company_id, metrics), company_ids
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
records = [fetch_current_metrics(company_id, metrics) for company_id in company_ids]
|
|
110
|
+
|
|
111
|
+
records_without_errors = [record for record in records if not record.has_errors()]
|
|
112
|
+
records_with_errors = [record for record in records if record.has_errors()]
|
|
113
|
+
return records_without_errors, records_with_errors
|
|
114
|
+
|
|
115
|
+
def fetch_historical_prices(company_id: str, period: Period) -> PriceSeries:
|
|
116
|
+
"""Fetch the daily closing price series for ``company_id`` over ``period``."""
|
|
117
|
+
prices, currency = yahoo_finance_fetcher.fetch_price_history(
|
|
118
|
+
company_id, period.from_date, period.to_date
|
|
119
|
+
)
|
|
120
|
+
cent_prices = {
|
|
121
|
+
trading_date: int((Decimal(str(price)) * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
|
122
|
+
for trading_date, price in prices.items()
|
|
123
|
+
}
|
|
124
|
+
return PriceSeries(currency=currency, cent_prices=cent_prices)
|