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.
@@ -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,3 @@
1
+ from typing import Final
2
+
3
+ EUR:Final[str] = "EUR"
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ investment = investment.cli.main:main
3
+