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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Fetch market quote data from Yahoo Finance via the ``yfinance`` package.
|
|
2
|
+
|
|
3
|
+
This mirrors the Java `YahooFinanceFetcher`, but delegates all the HTTP
|
|
4
|
+
plumbing (cookies, crumbs, endpoint URLs) to ``yfinance`` instead of talking
|
|
5
|
+
to the Yahoo Finance REST API directly. Price lookups return raw
|
|
6
|
+
``(price, currency, timestamp)`` tuples, leaving ``Price`` value-object
|
|
7
|
+
construction (e.g. cent-value conversion) to the caller;
|
|
8
|
+
``fetch_fundamental_metrics`` returns a plain ``dict`` keyed by metric name,
|
|
9
|
+
since those metric values can be of any type.
|
|
10
|
+
"""
|
|
11
|
+
from collections.abc import Collection
|
|
12
|
+
from datetime import date, timedelta
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy
|
|
16
|
+
import pandas
|
|
17
|
+
import yfinance as yf
|
|
18
|
+
|
|
19
|
+
REGULAR_MARKET_CHANGE_PERCENT = "regularMarketChangePercent"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch_current_price(symbol: str) -> tuple[float, str, int]:
|
|
23
|
+
"""Fetch the latest quoted price for ``symbol``.
|
|
24
|
+
|
|
25
|
+
Returns a ``(price, currency, timestamp)`` tuple: ``price`` is the
|
|
26
|
+
fetched price as returned by ``yfinance`` (a ``float``), ``currency``
|
|
27
|
+
is the quote currency, and ``timestamp`` is the quote time as Unix
|
|
28
|
+
epoch seconds.
|
|
29
|
+
"""
|
|
30
|
+
info = yf.Ticker(symbol).info
|
|
31
|
+
price = info.get("regularMarketPrice")
|
|
32
|
+
if price is None:
|
|
33
|
+
raise ValueError(f"Cannot fetch any price with the given company symbol {symbol}")
|
|
34
|
+
|
|
35
|
+
currency:str|None = info.get("currency")
|
|
36
|
+
regular_market_time = info.get("regularMarketTime")
|
|
37
|
+
if currency is None or regular_market_time is None:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Cannot determine the price currency or the timestamp for company symbol {symbol}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return price, currency, regular_market_time
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fetcher_close_price(
|
|
46
|
+
symbol: str, target_date: date
|
|
47
|
+
) -> tuple[numpy.float64, str, pandas.Timestamp]:
|
|
48
|
+
"""Fetch the closing price for ``symbol`` on or before ``target_date``.
|
|
49
|
+
|
|
50
|
+
Returns a ``(price, currency, timestamp)`` tuple: ``price`` is the
|
|
51
|
+
closing price as returned by ``yfinance``, ``currency`` is the quote
|
|
52
|
+
currency, and ``timestamp`` is the close date/time.
|
|
53
|
+
"""
|
|
54
|
+
ticker = yf.Ticker(symbol)
|
|
55
|
+
history = ticker.history(
|
|
56
|
+
start=target_date - timedelta(days=7),
|
|
57
|
+
end=target_date + timedelta(days=1),
|
|
58
|
+
interval="1d",
|
|
59
|
+
)
|
|
60
|
+
history = history[pandas.DatetimeIndex(history.index).date <= target_date]
|
|
61
|
+
if history.empty:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"No historical price found for company symbol {symbol} on or before {target_date}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
last_close = history["Close"].iloc[-1]
|
|
67
|
+
timestamp = history.index[-1]
|
|
68
|
+
|
|
69
|
+
currency = ticker.info.get("currency")
|
|
70
|
+
if currency is None:
|
|
71
|
+
raise ValueError(f"Cannot determine the price currency for company symbol {symbol}")
|
|
72
|
+
|
|
73
|
+
return last_close, currency, timestamp
|
|
74
|
+
|
|
75
|
+
def fetcher_close_prices(
|
|
76
|
+
symbols: list[str], target_date: date
|
|
77
|
+
) -> dict[str, tuple[numpy.float64, str, pandas.Timestamp]]:
|
|
78
|
+
"""Fetch the closing price for each of ``symbols`` on or before ``target_date``.
|
|
79
|
+
|
|
80
|
+
Unlike :func:`fetcher_close_price`, this batches the price history fetch
|
|
81
|
+
for all ``symbols`` into a single ``yfinance.download`` call. Returns a
|
|
82
|
+
dict mapping each symbol to a ``(price, currency, timestamp)`` tuple, the
|
|
83
|
+
same shape returned by :func:`fetcher_close_price`.
|
|
84
|
+
"""
|
|
85
|
+
if not symbols:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
data = yf.download(
|
|
89
|
+
symbols,
|
|
90
|
+
start=target_date - timedelta(days=7),
|
|
91
|
+
end=target_date + timedelta(days=1),
|
|
92
|
+
interval="1d",
|
|
93
|
+
group_by="ticker",
|
|
94
|
+
progress=False,
|
|
95
|
+
)
|
|
96
|
+
if data is None:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f"No historical price found for company symbols {symbols} on or before {target_date}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
result: dict[str, tuple[numpy.float64, str, pandas.Timestamp]] = {}
|
|
102
|
+
for symbol in symbols:
|
|
103
|
+
history = data[symbol]
|
|
104
|
+
history = history[pandas.DatetimeIndex(history.index).date <= target_date]
|
|
105
|
+
history = history[history["Close"].notna()]
|
|
106
|
+
if history.empty:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"No historical price found for company symbol {symbol} on or before {target_date}"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
last_close = history["Close"].iloc[-1]
|
|
112
|
+
timestamp = history.index[-1]
|
|
113
|
+
|
|
114
|
+
currency = yf.Ticker(symbol).info.get("currency")
|
|
115
|
+
if currency is None:
|
|
116
|
+
raise ValueError(f"Cannot determine the price currency for company symbol {symbol}")
|
|
117
|
+
|
|
118
|
+
result[symbol] = (last_close, currency, timestamp)
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fetch_price_history(symbol: str, start: date, end: date) -> tuple[dict[date, float], str]:
|
|
124
|
+
"""Fetch daily closing prices for ``symbol`` between ``start`` and ``end`` (inclusive).
|
|
125
|
+
|
|
126
|
+
Returns a ``(prices, currency)`` tuple: ``prices`` maps each trading date
|
|
127
|
+
in the range to its closing price, and ``currency`` is the quote currency.
|
|
128
|
+
"""
|
|
129
|
+
ticker = yf.Ticker(symbol)
|
|
130
|
+
history = ticker.history(
|
|
131
|
+
start=start,
|
|
132
|
+
end=end + timedelta(days=1),
|
|
133
|
+
interval="1d",
|
|
134
|
+
)
|
|
135
|
+
if history.empty:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"No historical price found for company symbol {symbol} between {start} and {end}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
dates = pandas.DatetimeIndex(history.index).date
|
|
141
|
+
prices = {
|
|
142
|
+
trading_date: float(close)
|
|
143
|
+
for trading_date, close in zip(dates, history["Close"], strict=True)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
currency = ticker.info.get("currency")
|
|
147
|
+
if currency is None:
|
|
148
|
+
raise ValueError(f"Cannot determine the price currency for company symbol {symbol}")
|
|
149
|
+
|
|
150
|
+
return prices, currency
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fetch_fundamental_metrics(symbol: str, metrics: Collection[str]) -> dict[str, Any]:
|
|
154
|
+
"""Fetch fundamental metrics (e.g. ``trailingPE``, ``dividendYield``,
|
|
155
|
+
``returnOnEquity``, ``regularMarketChangePercent``) for ``symbol``.
|
|
156
|
+
|
|
157
|
+
Unlike the Yahoo Finance REST API, ``yfinance``'s ``Ticker.info`` already
|
|
158
|
+
flattens the various quote-summary modules (summaryDetail, financialData,
|
|
159
|
+
price, ...) into a single dict, so metric names can be looked up directly
|
|
160
|
+
without mapping each one to its containing section.
|
|
161
|
+
"""
|
|
162
|
+
if not metrics:
|
|
163
|
+
return {}
|
|
164
|
+
|
|
165
|
+
info = yf.Ticker(symbol).info
|
|
166
|
+
return {metric_name: info.get(metric_name) for metric_name in metrics}
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
3
|
+
from enum import Enum, auto
|
|
4
|
+
from typing import NamedTuple, Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _money_to_cent_value(money: Decimal) -> int:
|
|
8
|
+
return int((money * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Transaction(Protocol):
|
|
12
|
+
date:date
|
|
13
|
+
money:Decimal
|
|
14
|
+
def cent_value(self) -> int: ...
|
|
15
|
+
def is_external_cashflow(self) -> bool: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Action(Enum):
|
|
19
|
+
BUY = auto()
|
|
20
|
+
SELL = auto()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Trade(NamedTuple):
|
|
24
|
+
security_id: str
|
|
25
|
+
action:Action
|
|
26
|
+
share_amount:int
|
|
27
|
+
date: date
|
|
28
|
+
money:Decimal
|
|
29
|
+
def cent_value(self) -> int:
|
|
30
|
+
return _money_to_cent_value(self.money)
|
|
31
|
+
def is_external_cashflow(self) -> bool:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
class Dividend(NamedTuple):
|
|
35
|
+
security_id: str
|
|
36
|
+
share_amount:int
|
|
37
|
+
date: date
|
|
38
|
+
money:Decimal
|
|
39
|
+
def cent_value(self) -> int:
|
|
40
|
+
return _money_to_cent_value(self.money)
|
|
41
|
+
def is_external_cashflow(self) -> bool:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
class Deposit(NamedTuple):
|
|
45
|
+
date: date
|
|
46
|
+
money:Decimal
|
|
47
|
+
def cent_value(self) -> int:
|
|
48
|
+
return _money_to_cent_value(self.money)
|
|
49
|
+
def is_external_cashflow(self) -> bool:
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
class Expense(NamedTuple):
|
|
53
|
+
date: date
|
|
54
|
+
money:Decimal
|
|
55
|
+
def cent_value(self) -> int:
|
|
56
|
+
return _money_to_cent_value(self.money)
|
|
57
|
+
def is_external_cashflow(self) -> bool:
|
|
58
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from datetime import date, datetime
|
|
2
|
+
|
|
3
|
+
from investment.marketquote.repository import fetch_historical_prices, fetch_price_in_euro
|
|
4
|
+
from investment.util.constants import EUR
|
|
5
|
+
from investment.vo.value_objects import Period, Price, PriceSeries
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _find_historical_euro_price_series(security_id:str, period:Period) -> PriceSeries:
|
|
9
|
+
price_series = fetch_historical_prices(security_id, period)
|
|
10
|
+
|
|
11
|
+
euro_cent_prices = {
|
|
12
|
+
trading_date: fetch_price_in_euro(
|
|
13
|
+
Price(
|
|
14
|
+
cent_value,
|
|
15
|
+
price_series.currency,
|
|
16
|
+
datetime.combine(trading_date, datetime.min.time()),
|
|
17
|
+
)
|
|
18
|
+
).cent_value
|
|
19
|
+
for trading_date, cent_value in price_series.cent_prices.items()
|
|
20
|
+
}
|
|
21
|
+
return PriceSeries(currency=EUR, cent_prices=euro_cent_prices)
|
|
22
|
+
|
|
23
|
+
class MarketPriceRepository:
|
|
24
|
+
def __init__(self, end_date: date) -> None:
|
|
25
|
+
self.end_date = end_date
|
|
26
|
+
self.series_cache: dict[str, PriceSeries] = {}
|
|
27
|
+
def find_euro_price(self, security_id:str, date:date) -> Price:
|
|
28
|
+
price_series = self.series_cache.get(security_id)
|
|
29
|
+
if price_series is None:
|
|
30
|
+
period = Period(from_date=date, to_date=self.end_date)
|
|
31
|
+
price_series = _find_historical_euro_price_series(security_id, period)
|
|
32
|
+
self.series_cache[security_id] = price_series
|
|
33
|
+
return price_series.get_price(date)
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from datetime import date, timedelta
|
|
2
|
+
from typing import Final, NamedTuple
|
|
3
|
+
|
|
4
|
+
from investment.portfolio.transaction import Action, Deposit, Trade, Transaction
|
|
5
|
+
from investment.portfolio.twr._market_price_repository import MarketPriceRepository
|
|
6
|
+
from investment.portfolio.twr.portfolio import Holding, Holdings, PortfolioSnapshot
|
|
7
|
+
from investment.vo.value_objects import Period
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DailyReturn(NamedTuple):
|
|
11
|
+
date: date
|
|
12
|
+
value: float
|
|
13
|
+
|
|
14
|
+
class DailyReturnSeries(NamedTuple):
|
|
15
|
+
series: list[DailyReturn]
|
|
16
|
+
|
|
17
|
+
class _PortfolioSnapshotSeriesGenerator:
|
|
18
|
+
def __init__(self, transactions:list[Transaction]) -> None:
|
|
19
|
+
self.transactions = transactions
|
|
20
|
+
self.market_price_repository = MarketPriceRepository(transactions[-1].date)
|
|
21
|
+
|
|
22
|
+
def generate(self) -> dict[date,PortfolioSnapshot]:
|
|
23
|
+
# Assumes transactions is already sorted by date ascendingly: the last
|
|
24
|
+
# element is taken as the end date, and snapshots are chained in the
|
|
25
|
+
# order dates are first seen below.
|
|
26
|
+
def group_transactions_by_date() -> dict[date, list[Transaction]]:
|
|
27
|
+
transactions_by_date: dict[date, list[Transaction]] = {}
|
|
28
|
+
for transaction in self.transactions:
|
|
29
|
+
transactions_by_date.setdefault(transaction.date, []).append(transaction)
|
|
30
|
+
return transactions_by_date
|
|
31
|
+
|
|
32
|
+
transactions_by_date = group_transactions_by_date()
|
|
33
|
+
|
|
34
|
+
first_date = self.transactions[0].date
|
|
35
|
+
previous_portfolio_snapshot = PortfolioSnapshot(first_date, 0, Holdings({}), [])
|
|
36
|
+
portfolio_snapshots:dict[date,PortfolioSnapshot] = {}
|
|
37
|
+
for _date, daily_transactions in transactions_by_date.items():
|
|
38
|
+
snapshot = self._new_snapshot(daily_transactions, previous_portfolio_snapshot)
|
|
39
|
+
previous_portfolio_snapshot = portfolio_snapshots[_date] = snapshot
|
|
40
|
+
return self._add_missing_snapshots(portfolio_snapshots)
|
|
41
|
+
|
|
42
|
+
def _new_snapshot(
|
|
43
|
+
self, daily_transactions:list[Transaction], previous_snapshot:PortfolioSnapshot
|
|
44
|
+
) -> PortfolioSnapshot:
|
|
45
|
+
# date
|
|
46
|
+
_date:Final[date] = daily_transactions[-1].date
|
|
47
|
+
# calculate remaining cash in cent
|
|
48
|
+
remaining_cash_in_cent:int = previous_snapshot.cash_in_cent
|
|
49
|
+
for transaction in daily_transactions:
|
|
50
|
+
remaining_cash_in_cent += transaction.cent_value()
|
|
51
|
+
# calculate holdings
|
|
52
|
+
holdings = Holdings(previous_snapshot.holdings.holding_by_security.copy())
|
|
53
|
+
for transaction in daily_transactions:
|
|
54
|
+
if isinstance(transaction, Trade):
|
|
55
|
+
trade = transaction
|
|
56
|
+
if trade.action == Action.BUY:
|
|
57
|
+
holdings.add(trade.security_id, trade.share_amount)
|
|
58
|
+
elif trade.action == Action.SELL:
|
|
59
|
+
holdings.remove(trade.security_id, trade.share_amount)
|
|
60
|
+
## add price to holdings
|
|
61
|
+
holdings_with_price = self._reprice_holdings(holdings.holding_by_security, _date)
|
|
62
|
+
# calculate external cash flow
|
|
63
|
+
external_cash_flows = [
|
|
64
|
+
transaction.cent_value()
|
|
65
|
+
for transaction in daily_transactions
|
|
66
|
+
if isinstance(transaction, Deposit)
|
|
67
|
+
]
|
|
68
|
+
return PortfolioSnapshot(
|
|
69
|
+
_date, remaining_cash_in_cent, Holdings(holdings_with_price), external_cash_flows
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def _add_missing_snapshots(
|
|
73
|
+
self, existing_snapshots:dict[date,PortfolioSnapshot]
|
|
74
|
+
) -> dict[date,PortfolioSnapshot]:
|
|
75
|
+
# Daily TWR needs a valuation for every day in the period, not just
|
|
76
|
+
# transaction days - a day with no transactions still has to reflect
|
|
77
|
+
# that day's market move. existing_snapshots[period.from_date] is
|
|
78
|
+
# guaranteed present since transactions[0].date is a transaction date.
|
|
79
|
+
period:Period = Period(self.transactions[0].date, self.transactions[-1].date)
|
|
80
|
+
|
|
81
|
+
def carry_forward_snapshot(
|
|
82
|
+
previous_snapshot:PortfolioSnapshot, _date:date
|
|
83
|
+
) -> PortfolioSnapshot:
|
|
84
|
+
"""A day with no transactions still needs a snapshot: carry the
|
|
85
|
+
previous day's cash and holdings forward, re-pricing the holdings
|
|
86
|
+
for ``_date`` (positions are unchanged, but market value isn't)."""
|
|
87
|
+
holding_by_security = previous_snapshot.holdings.holding_by_security
|
|
88
|
+
holdings_with_price = self._reprice_holdings(holding_by_security, _date)
|
|
89
|
+
return PortfolioSnapshot(
|
|
90
|
+
_date, previous_snapshot.cash_in_cent, Holdings(holdings_with_price), []
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
complete_snapshots: dict[date,PortfolioSnapshot] = {}
|
|
94
|
+
previous_snapshot = existing_snapshots[period.from_date]
|
|
95
|
+
current_date = period.from_date
|
|
96
|
+
while current_date <= period.to_date:
|
|
97
|
+
if current_date in existing_snapshots:
|
|
98
|
+
previous_snapshot = existing_snapshots[current_date]
|
|
99
|
+
else:
|
|
100
|
+
previous_snapshot = carry_forward_snapshot(previous_snapshot, current_date)
|
|
101
|
+
complete_snapshots[current_date] = previous_snapshot
|
|
102
|
+
current_date += timedelta(days=1)
|
|
103
|
+
return complete_snapshots
|
|
104
|
+
|
|
105
|
+
def _reprice_holdings(
|
|
106
|
+
self, holding_by_security:dict[str,Holding], _date:date
|
|
107
|
+
) -> dict[str,Holding]:
|
|
108
|
+
return {
|
|
109
|
+
security_id: Holding(
|
|
110
|
+
holding.position,
|
|
111
|
+
self.market_price_repository.find_euro_price(security_id, _date).cent_value,
|
|
112
|
+
)
|
|
113
|
+
for security_id, holding in holding_by_security.items()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
def calculate_twr(
|
|
117
|
+
transactions: list[Transaction],
|
|
118
|
+
) -> tuple[list[PortfolioSnapshot], list[DailyReturn]]:
|
|
119
|
+
"""Compute a daily-linked time-weighted return series from a portfolio's
|
|
120
|
+
transaction history.
|
|
121
|
+
|
|
122
|
+
``transactions`` MUST already be sorted by date, ascending. This is a
|
|
123
|
+
precondition the caller is responsible for, not something this function
|
|
124
|
+
(or ``_PortfolioSnapshotsGenerator``) checks or sorts for you - passing
|
|
125
|
+
unsorted transactions produces silently wrong snapshots and returns
|
|
126
|
+
rather than raising, since end date, first date, and the day-by-day
|
|
127
|
+
snapshot chain are all derived from list/insertion order rather than by
|
|
128
|
+
re-sorting internally.
|
|
129
|
+
|
|
130
|
+
Returns a ``(portfolio_snapshots, daily_return_series)`` tuple:
|
|
131
|
+
- ``portfolio_snapshots``: one ``PortfolioSnapshot`` per distinct
|
|
132
|
+
transaction date, in the same (assumed ascending) order.
|
|
133
|
+
- ``daily_return_series``: for each date after the first, the return
|
|
134
|
+
``(value_today - external_cashflow_today) / value_yesterday - 1``,
|
|
135
|
+
so that deposits/withdrawals don't get counted as investment
|
|
136
|
+
performance. No return is produced for the first date, since there is
|
|
137
|
+
no prior snapshot to compare against.
|
|
138
|
+
"""
|
|
139
|
+
# Step 1: form the map from date to portfolio snapshot for each day
|
|
140
|
+
snapshots = _PortfolioSnapshotSeriesGenerator(transactions).generate()
|
|
141
|
+
dates = list(snapshots)
|
|
142
|
+
|
|
143
|
+
# Step 2: chain daily returns, each day's cashflow-adjusted change over the previous day
|
|
144
|
+
daily_returns: list[DailyReturn] = []
|
|
145
|
+
for previous_date, current_date in zip(dates, dates[1:]):
|
|
146
|
+
previous_value = snapshots[previous_date].value_in_cent()
|
|
147
|
+
current_snapshot = snapshots[current_date]
|
|
148
|
+
current_value = current_snapshot.value_in_cent()
|
|
149
|
+
cash_flow = current_snapshot.external_cash_flow_value_in_cent()
|
|
150
|
+
daily_return = (
|
|
151
|
+
0.0 if previous_value == 0
|
|
152
|
+
else (current_value - cash_flow) / previous_value - 1
|
|
153
|
+
)
|
|
154
|
+
daily_returns.append(DailyReturn(current_date, daily_return))
|
|
155
|
+
|
|
156
|
+
return [snapshots[d] for d in dates], daily_returns
|
|
157
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import date
|
|
3
|
+
from typing import NamedTuple
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Holding(NamedTuple):
|
|
7
|
+
position:int
|
|
8
|
+
price_in_cent:int | None = None
|
|
9
|
+
def market_value_in_cent(self) -> int:
|
|
10
|
+
return self.position * (self.price_in_cent or 0)
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Holdings:
|
|
14
|
+
holding_by_security: dict[str,Holding]
|
|
15
|
+
def add(self, security_id:str, position:int):
|
|
16
|
+
existing = self.holding_by_security.get(security_id, Holding(0))
|
|
17
|
+
updated_position = existing.position + position
|
|
18
|
+
self.holding_by_security[security_id] = Holding(updated_position, existing.price_in_cent)
|
|
19
|
+
def remove(self, security_id:str, position:int):
|
|
20
|
+
existing = self.holding_by_security.get(security_id, Holding(0))
|
|
21
|
+
updated_position = existing.position - position
|
|
22
|
+
self.holding_by_security[security_id] = Holding(updated_position, existing.price_in_cent)
|
|
23
|
+
|
|
24
|
+
class PortfolioSnapshot(NamedTuple):
|
|
25
|
+
date:date
|
|
26
|
+
cash_in_cent:int
|
|
27
|
+
holdings:Holdings
|
|
28
|
+
external_cash_flows:list[int]
|
|
29
|
+
def value_in_cent(self) -> int:
|
|
30
|
+
holdings = self.holdings.holding_by_security.values()
|
|
31
|
+
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:
|
|
34
|
+
return sum(self.external_cash_flows)
|
|
35
|
+
|
|
36
|
+
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def clock(func):
|
|
5
|
+
def clocked(*args, **kwargs):
|
|
6
|
+
t0 = time.perf_counter()
|
|
7
|
+
result = func(*args, **kwargs)
|
|
8
|
+
elapsed = time.perf_counter() - t0
|
|
9
|
+
name = func.__name__
|
|
10
|
+
arg_str = ', '.join(repr(arg) for arg in args)
|
|
11
|
+
kwarg_str = ', '.join(f'{k}={v!r}' for k, v in kwargs.items())
|
|
12
|
+
all_args = ', '.join(filter(None, [arg_str, kwarg_str]))
|
|
13
|
+
print(f'[{elapsed:0.1f}s] {name}({all_args})')
|
|
14
|
+
return result
|
|
15
|
+
return clocked
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from datetime import date, datetime, timedelta
|
|
2
|
+
from typing import NamedTuple
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Price(NamedTuple):
|
|
6
|
+
cent_value:int
|
|
7
|
+
currency: str
|
|
8
|
+
timestamp: datetime
|
|
9
|
+
def amount(self) -> float:
|
|
10
|
+
return self.cent_value / 100
|
|
11
|
+
def value_with_currency(self) -> str:
|
|
12
|
+
return f"{self.amount()} {self.currency}"
|
|
13
|
+
def currency_value(self) -> str:
|
|
14
|
+
return self.currency.upper()
|
|
15
|
+
def date(self) -> date:
|
|
16
|
+
return self.timestamp.date()
|
|
17
|
+
|
|
18
|
+
class Percentage(NamedTuple):
|
|
19
|
+
fraction_value:float
|
|
20
|
+
def percent_value(self) -> float:
|
|
21
|
+
return round(self.fraction_value * 100, 2)
|
|
22
|
+
|
|
23
|
+
class Period(NamedTuple):
|
|
24
|
+
from_date:date
|
|
25
|
+
to_date:date
|
|
26
|
+
|
|
27
|
+
class PriceSeries(NamedTuple):
|
|
28
|
+
currency:str
|
|
29
|
+
cent_prices:dict[date,int]
|
|
30
|
+
def get_price(self, date:date) -> Price:
|
|
31
|
+
"""Return the price on ``date``, falling back to the most recent
|
|
32
|
+
earlier date in the series (e.g. a weekend or holiday has no price
|
|
33
|
+
of its own, so this walks backwards to the last trading day)."""
|
|
34
|
+
search_date = date
|
|
35
|
+
while search_date not in self.cent_prices:
|
|
36
|
+
search_date -= timedelta(days=1)
|
|
37
|
+
if search_date < min(self.cent_prices):
|
|
38
|
+
raise KeyError(f"no price on or before {date} in the series")
|
|
39
|
+
return Price(
|
|
40
|
+
cent_value=self.cent_prices[search_date],
|
|
41
|
+
currency=self.currency,
|
|
42
|
+
timestamp=datetime.combine(search_date, datetime.min.time()),
|
|
43
|
+
)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: investment-python
|
|
3
|
+
Version: 0.1.0b1
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author: Rui Xue
|
|
6
|
+
Author-email: Rui Xue <ruixue.fi@gmail.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: matplotlib>=3.11.1
|
|
10
|
+
Requires-Dist: pandas>=3.0.5
|
|
11
|
+
Requires-Dist: requests>=2.34.2
|
|
12
|
+
Requires-Dist: yfinance>=1.6.0
|
|
13
|
+
Requires-Python: >=3.13
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# investment-python
|
|
17
|
+
|
|
18
|
+
A command-line tool for pulling stock market quotes and fundamentals (price,
|
|
19
|
+
P/E, ROE, P/B, dividend yield, and more) for a watch list of companies given
|
|
20
|
+
as ticker symbols or a CSV file. It can sort the results, flag stocks trading
|
|
21
|
+
outside a given price range, and export everything to CSV.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Python 3.13+
|
|
26
|
+
- [`uv`](https://docs.astral.sh/uv/) for dependency management and running the tool
|
|
27
|
+
|
|
28
|
+
Install dependencies with:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
uv sync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Then run the tool with `uv run investment ...`.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
### Fetch Metrics
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
uv run investment metrics METRIC_NAMES (--company-symbols SYMBOLS | --company-csv PATH_OR_URL) [options]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `METRIC_NAMES` — required, positional. One or more metrics delimited by
|
|
44
|
+
comma, e.g. `PRICE,TRAILING_PE`. Choose from: `COMPANY_NAME`, `PRICE`,
|
|
45
|
+
`PRICE_IN_EURO`, `MARKET_STATE`, `TRAILING_PE`, `DIVIDEND_YIELD`,
|
|
46
|
+
`DIVIDEND_PAYOUT_RATIO`, `RETURN_ON_EQUITY`, `REGULAR_MARKET_CHANGE_PERCENT`,
|
|
47
|
+
`PRICE_TO_BOOK`.
|
|
48
|
+
- `--company-symbols` — company ticker symbols as used on *Yahoo Finance*,
|
|
49
|
+
delimited by comma, e.g. `AAPL,ELISA.HE`. Mutually exclusive with
|
|
50
|
+
`--company-csv`; one of the two is required.
|
|
51
|
+
- `--company-csv` — path or URL to a CSV file with a `Yahoo Company Symbol`
|
|
52
|
+
column.
|
|
53
|
+
- `--sort-by` — optional. Sort results ascending by this metric; must be one
|
|
54
|
+
of the metrics given in `METRIC_NAMES`.
|
|
55
|
+
- `--price-ranges` — optional. Flag companies whose price falls outside a
|
|
56
|
+
range, formatted as `COMPANY_ID1:MIN:MAX,COMPANY_ID2:MIN:`, e.g.
|
|
57
|
+
`AAPL:150:200,ELISA.HE:30:`. Leave `MIN` or `MAX` empty for no lower/upper
|
|
58
|
+
bound.
|
|
59
|
+
- `--output-csv-name` — optional. Also write the metrics result to this CSV
|
|
60
|
+
file. When given, any companies that failed to fetch are additionally
|
|
61
|
+
written to `companies_with_error.csv`, and any companies outside their
|
|
62
|
+
price range (per `--price-ranges`) are written to `alert_on_companies.csv`.
|
|
63
|
+
|
|
64
|
+
#### Example Command
|
|
65
|
+
|
|
66
|
+
`uv run investment metrics COMPANY_NAME,PRICE,REGULAR_MARKET_CHANGE_PERCENT,PRICE_IN_EURO,TRAILING_PE,RETURN_ON_EQUITY,PRICE_TO_BOOK,DIVIDEND_YIELD --sort-by REGULAR_MARKET_CHANGE_PERCENT --company-symbols ELISA.HE,FIA1S.HE,NOVO-B.CO`
|
|
67
|
+
|
|
68
|
+
### Benchmarking
|
|
69
|
+
|
|
70
|
+
Compare a stock's price performance against a benchmark (an index or another
|
|
71
|
+
stock) over a given period: both series are rebased to an index of 100 at the
|
|
72
|
+
start date, and a beta coefficient (`Cov(stock, benchmark) / Var(benchmark)`,
|
|
73
|
+
from daily returns) is printed.
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
uv run investment benchmark BENCHMARK_ID:COMPANY_ID --start-date START_DATE --end-date END_DATE [--graph-directory DIRECTORY]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- `BENCHMARK_ID:COMPANY_ID` — required, positional. The benchmark and company
|
|
80
|
+
ticker symbols, delimited by a colon, e.g. `VOO:T`.
|
|
81
|
+
- `--start-date` — required. Start date of the period, in ISO format, e.g.
|
|
82
|
+
`2021-08-30`.
|
|
83
|
+
- `--end-date` — required. End date of the period, in ISO format, e.g.
|
|
84
|
+
`2026-08-30`.
|
|
85
|
+
- `--graph-directory` — optional. Save the chart as a PNG named
|
|
86
|
+
`COMPANY_ID_vs_BENCHMARK_ID.png` in this directory. If omitted, the chart is
|
|
87
|
+
not saved.
|
|
88
|
+
|
|
89
|
+
The chart is always displayed in a window (this blocks until the window is
|
|
90
|
+
closed).
|
|
91
|
+
|
|
92
|
+
#### Example Command
|
|
93
|
+
|
|
94
|
+
`uv run investment benchmark VOO:T --start-date 2021-08-30 --end-date 2026-08-30 --graph-directory ./charts`
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
investment/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
investment/benchmark/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
investment/benchmark/chart_data.py,sha256=c0rSu2v90NKDQHhk-Yv4H65QlYELfJ8xpP5xIFJ4vuM,2338
|
|
4
|
+
investment/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
investment/cli/__main__.py,sha256=KyBNSWmYIKBeNl17W6vHRNFOh6aOmDrT5_apkNnKJg4,183
|
|
6
|
+
investment/cli/main.py,sha256=LOpXBNEHBGBXNlkQ3n0gzIJN6eQ6jOv6Ln_ThIMgbX0,6213
|
|
7
|
+
investment/cli/program_runner.py,sha256=uCWd2RuqdgPly8G4xjnHIuJ9wypCXMt7MPcBKFn_Ol4,5872
|
|
8
|
+
investment/marketquote/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
investment/marketquote/_fx_rate_fetcher.py,sha256=ilhsCyQ5bLLuE_BWYfdjtUuzje6OY88qwLb4BokbYi0,2062
|
|
10
|
+
investment/marketquote/filter.py,sha256=2v3Pwp1nGvIn9SMTCfzd29258zf27svBRGTlVdVtz3M,1126
|
|
11
|
+
investment/marketquote/metrics.py,sha256=MkErauplj-94xq3-7YTshLtA7CyUAG4N1ZMfj5tkqZo,3158
|
|
12
|
+
investment/marketquote/repository.py,sha256=D_vxl1AsHv3v1_eg-UzWuKstPhI0mylgPf_XtkhDKn8,5678
|
|
13
|
+
investment/marketquote/yahoo_finance_fetcher.py,sha256=B4_HEvxISB-D_8T2k7PEb8LnAhxoVrWgjZc0wB_Th90,6167
|
|
14
|
+
investment/portfolio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
investment/portfolio/transaction.py,sha256=f7FBIGAf07ovSAFe5B0i2HIKNBCYsmafz1HdR-9Uhg0,1393
|
|
16
|
+
investment/portfolio/twr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
investment/portfolio/twr/_market_price_repository.py,sha256=3B1RCZCQuZA2JmNjYKW5obs3t1cjWwaHOPTFR6VOREI,1372
|
|
18
|
+
investment/portfolio/twr/calculation.py,sha256=fzw_faFJ_t6E_2HpcSto1Ql1lSxWT4_04H75NnpYBxU,7571
|
|
19
|
+
investment/portfolio/twr/portfolio.py,sha256=UXO8BmJDtgTDALw2fEXq85wQy0RGn-MIE4Fi48yisG4,1352
|
|
20
|
+
investment/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
investment/util/constants.py,sha256=XV8I2JVuIw7jGoKoqfgHp-O8zTPLUbe-SkcHQ0lZB7U,49
|
|
22
|
+
investment/util/decorator.py,sha256=VnfsUBSJKwdU_KKYN4YkstVomzvJ6B9-CgqeIaru_eo,496
|
|
23
|
+
investment/vo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
24
|
+
investment/vo/value_objects.py,sha256=t7wTTIZnrqnix-Hn1EZmhUWiB00hNBD56SI-crTiLiE,1467
|
|
25
|
+
investment_python-0.1.0b1.dist-info/licenses/LICENSE,sha256=UPg_bWlyzbvWouUMDRsNRU2Zh5ztICAPqr2dc8Ri0KA,1064
|
|
26
|
+
investment_python-0.1.0b1.dist-info/WHEEL,sha256=s_zqWxHFEH8b58BCtf46hFCqPaISurdB9R1XJ8za6XI,80
|
|
27
|
+
investment_python-0.1.0b1.dist-info/entry_points.txt,sha256=jZ65dwNXZ429z9NdQRP-AlSSj5C_EV2WrNbOnpT-NoE,57
|
|
28
|
+
investment_python-0.1.0b1.dist-info/METADATA,sha256=s_yT_GhxGcDT2CQUOqM0LpiVRDV2Pl7Fl5CJoW8LEh0,3677
|
|
29
|
+
investment_python-0.1.0b1.dist-info/RECORD,,
|