investment-python 0.1.0b1__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.0b1/LICENSE +21 -0
- investment_python-0.1.0b1/PKG-INFO +94 -0
- investment_python-0.1.0b1/README.md +79 -0
- investment_python-0.1.0b1/pyproject.toml +53 -0
- investment_python-0.1.0b1/src/investment/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/benchmark/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/benchmark/chart_data.py +56 -0
- investment_python-0.1.0b1/src/investment/cli/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/cli/__main__.py +7 -0
- investment_python-0.1.0b1/src/investment/cli/main.py +161 -0
- investment_python-0.1.0b1/src/investment/cli/program_runner.py +153 -0
- investment_python-0.1.0b1/src/investment/marketquote/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/marketquote/_fx_rate_fetcher.py +53 -0
- investment_python-0.1.0b1/src/investment/marketquote/filter.py +34 -0
- investment_python-0.1.0b1/src/investment/marketquote/metrics.py +74 -0
- investment_python-0.1.0b1/src/investment/marketquote/repository.py +124 -0
- investment_python-0.1.0b1/src/investment/marketquote/yahoo_finance_fetcher.py +166 -0
- investment_python-0.1.0b1/src/investment/portfolio/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/portfolio/transaction.py +58 -0
- investment_python-0.1.0b1/src/investment/portfolio/twr/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/portfolio/twr/_market_price_repository.py +33 -0
- investment_python-0.1.0b1/src/investment/portfolio/twr/calculation.py +157 -0
- investment_python-0.1.0b1/src/investment/portfolio/twr/portfolio.py +36 -0
- investment_python-0.1.0b1/src/investment/util/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/util/constants.py +3 -0
- investment_python-0.1.0b1/src/investment/util/decorator.py +15 -0
- investment_python-0.1.0b1/src/investment/vo/__init__.py +0 -0
- investment_python-0.1.0b1/src/investment/vo/value_objects.py +43 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rui Xue
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -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,79 @@
|
|
|
1
|
+
# investment-python
|
|
2
|
+
|
|
3
|
+
A command-line tool for pulling stock market quotes and fundamentals (price,
|
|
4
|
+
P/E, ROE, P/B, dividend yield, and more) for a watch list of companies given
|
|
5
|
+
as ticker symbols or a CSV file. It can sort the results, flag stocks trading
|
|
6
|
+
outside a given price range, and export everything to CSV.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
- Python 3.13+
|
|
11
|
+
- [`uv`](https://docs.astral.sh/uv/) for dependency management and running the tool
|
|
12
|
+
|
|
13
|
+
Install dependencies with:
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
uv sync
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run the tool with `uv run investment ...`.
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
### Fetch Metrics
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
uv run investment metrics METRIC_NAMES (--company-symbols SYMBOLS | --company-csv PATH_OR_URL) [options]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
- `METRIC_NAMES` — required, positional. One or more metrics delimited by
|
|
29
|
+
comma, e.g. `PRICE,TRAILING_PE`. Choose from: `COMPANY_NAME`, `PRICE`,
|
|
30
|
+
`PRICE_IN_EURO`, `MARKET_STATE`, `TRAILING_PE`, `DIVIDEND_YIELD`,
|
|
31
|
+
`DIVIDEND_PAYOUT_RATIO`, `RETURN_ON_EQUITY`, `REGULAR_MARKET_CHANGE_PERCENT`,
|
|
32
|
+
`PRICE_TO_BOOK`.
|
|
33
|
+
- `--company-symbols` — company ticker symbols as used on *Yahoo Finance*,
|
|
34
|
+
delimited by comma, e.g. `AAPL,ELISA.HE`. Mutually exclusive with
|
|
35
|
+
`--company-csv`; one of the two is required.
|
|
36
|
+
- `--company-csv` — path or URL to a CSV file with a `Yahoo Company Symbol`
|
|
37
|
+
column.
|
|
38
|
+
- `--sort-by` — optional. Sort results ascending by this metric; must be one
|
|
39
|
+
of the metrics given in `METRIC_NAMES`.
|
|
40
|
+
- `--price-ranges` — optional. Flag companies whose price falls outside a
|
|
41
|
+
range, formatted as `COMPANY_ID1:MIN:MAX,COMPANY_ID2:MIN:`, e.g.
|
|
42
|
+
`AAPL:150:200,ELISA.HE:30:`. Leave `MIN` or `MAX` empty for no lower/upper
|
|
43
|
+
bound.
|
|
44
|
+
- `--output-csv-name` — optional. Also write the metrics result to this CSV
|
|
45
|
+
file. When given, any companies that failed to fetch are additionally
|
|
46
|
+
written to `companies_with_error.csv`, and any companies outside their
|
|
47
|
+
price range (per `--price-ranges`) are written to `alert_on_companies.csv`.
|
|
48
|
+
|
|
49
|
+
#### Example Command
|
|
50
|
+
|
|
51
|
+
`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`
|
|
52
|
+
|
|
53
|
+
### Benchmarking
|
|
54
|
+
|
|
55
|
+
Compare a stock's price performance against a benchmark (an index or another
|
|
56
|
+
stock) over a given period: both series are rebased to an index of 100 at the
|
|
57
|
+
start date, and a beta coefficient (`Cov(stock, benchmark) / Var(benchmark)`,
|
|
58
|
+
from daily returns) is printed.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
uv run investment benchmark BENCHMARK_ID:COMPANY_ID --start-date START_DATE --end-date END_DATE [--graph-directory DIRECTORY]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `BENCHMARK_ID:COMPANY_ID` — required, positional. The benchmark and company
|
|
65
|
+
ticker symbols, delimited by a colon, e.g. `VOO:T`.
|
|
66
|
+
- `--start-date` — required. Start date of the period, in ISO format, e.g.
|
|
67
|
+
`2021-08-30`.
|
|
68
|
+
- `--end-date` — required. End date of the period, in ISO format, e.g.
|
|
69
|
+
`2026-08-30`.
|
|
70
|
+
- `--graph-directory` — optional. Save the chart as a PNG named
|
|
71
|
+
`COMPANY_ID_vs_BENCHMARK_ID.png` in this directory. If omitted, the chart is
|
|
72
|
+
not saved.
|
|
73
|
+
|
|
74
|
+
The chart is always displayed in a window (this blocks until the window is
|
|
75
|
+
closed).
|
|
76
|
+
|
|
77
|
+
#### Example Command
|
|
78
|
+
|
|
79
|
+
`uv run investment benchmark VOO:T --start-date 2021-08-30 --end-date 2026-08-30 --graph-directory ./charts`
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "investment-python"
|
|
3
|
+
version = "0.1.0b1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Rui Xue", email = "ruixue.fi@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"matplotlib>=3.11.1",
|
|
14
|
+
"pandas>=3.0.5",
|
|
15
|
+
"requests>=2.34.2",
|
|
16
|
+
"yfinance>=1.6.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
investment = "investment.cli.main:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["uv_build>=0.11.6,<0.12.0"]
|
|
24
|
+
build-backend = "uv_build"
|
|
25
|
+
|
|
26
|
+
[tool.uv.build-backend]
|
|
27
|
+
module-name = "investment"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pandas-stubs>=3.0.5.260730",
|
|
32
|
+
"pyright>=1.1.411",
|
|
33
|
+
"pytest>=9.1.1",
|
|
34
|
+
"ruff>=0.16.4",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
markers = [
|
|
39
|
+
"integration: hits live external APIs (Yahoo Finance, ECB); slower and network-dependent.",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 100
|
|
44
|
+
target-version = "py313"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
select = ["E", "F", "W", "I"]
|
|
48
|
+
|
|
49
|
+
[tool.pyright]
|
|
50
|
+
pythonVersion = "3.13"
|
|
51
|
+
include = ["src"]
|
|
52
|
+
reportUnnecessaryTypeIgnoreComment = true
|
|
53
|
+
reportUnnecessaryCast = true
|
|
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
|
|
@@ -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
|