yfin-cli 0.1.1__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.
src/commands/screen.py ADDED
@@ -0,0 +1,260 @@
1
+ import yfinance as yf
2
+ import typer
3
+ import json
4
+ from ..decorators import command
5
+ from ..typer import (
6
+ ScreenFilterTypeOptional,
7
+ ScreenPredefinedQueryTypeOptional,
8
+ ScreenJsonQueryTypeOptional,
9
+ OffsetType,
10
+ default_offset,
11
+ LimitType,
12
+ default_limit,
13
+ ScreenQueryFieldType,
14
+ ScreenSortOrderType,
15
+ default_sort_order,
16
+ )
17
+ from ..utils import is_number, count_specified
18
+
19
+ VALID_FIELDS_DICT = yf.const.EQUITY_SCREENER_FIELDS
20
+ VALID_VALUES_DICT = yf.const.EQUITY_SCREENER_EQ_MAP
21
+ VALID_FIELDS = set().union(*VALID_FIELDS_DICT.values())
22
+ VALID_REGIONS = set(VALID_VALUES_DICT.get("region"))
23
+ VALID_EXCHANGES = set().union(*VALID_VALUES_DICT.get("exchange").values())
24
+ VALID_SECTORS = set(VALID_VALUES_DICT.get("sector"))
25
+ VALID_INDUSTRIES = set().union(*VALID_VALUES_DICT.get("industry").values())
26
+ VALID_PEER_GROUPS = set(VALID_VALUES_DICT.get("peer_group"))
27
+ PREDEFINED_QUERIES = set(yf.PREDEFINED_SCREENER_QUERIES.keys())
28
+ VALID_OPERATORS = {
29
+ "eq",
30
+ "gt",
31
+ "gte",
32
+ "lt",
33
+ "lte",
34
+ "btwn",
35
+ "is-in",
36
+ }
37
+
38
+
39
+ @command
40
+ def screen_query_fields():
41
+ """Get a list of all valid fields for screening."""
42
+ return [{"field": f} for f in sorted(list(VALID_FIELDS))]
43
+
44
+
45
+ @command
46
+ def screen_query_values(field: ScreenQueryFieldType):
47
+ """Get a list of all valid values for a field."""
48
+ if field == "region":
49
+ values = list(VALID_REGIONS)
50
+ elif field == "exchange":
51
+ values = list(VALID_EXCHANGES)
52
+ elif field == "sector":
53
+ values = list(VALID_SECTORS)
54
+ elif field == "industry":
55
+ values = list(VALID_INDUSTRIES)
56
+ elif field == "peer_group":
57
+ values = list(VALID_PEER_GROUPS)
58
+ else:
59
+ validate_field(field)
60
+ values = None
61
+
62
+ if values is None:
63
+ return None
64
+
65
+ return [{"value": v} for v in sorted(values)]
66
+
67
+
68
+ @command
69
+ def screen_predefined_queries():
70
+ """Get a list of all valid predefined queries."""
71
+ return [{"query": q} for q in sorted(list(PREDEFINED_QUERIES))]
72
+
73
+
74
+ def validate_field(field: str):
75
+ """
76
+ Validate the field.
77
+ """
78
+ if field not in VALID_FIELDS:
79
+ raise typer.BadParameter(
80
+ f"Invalid field: '{field}'. Valid fields can be found using `yfin screen-query-fields`."
81
+ )
82
+
83
+
84
+ def parse_value(field: str, value: str) -> str | float:
85
+ """
86
+ Validate the value against the field, then return it into the correct type.
87
+ """
88
+ if field in VALID_FIELDS_DICT.get("eq_fields"):
89
+ # fields in `eq_fields` should operate with fixed values
90
+ if field not in ["region", "exchange", "sector", "industry", "peer_group"]:
91
+ # This should never happen
92
+ raise ValueError(f"Invalid field '{field}' for eq_fields")
93
+
94
+ if field == "region" and value not in VALID_REGIONS:
95
+ raise typer.BadParameter(
96
+ f"Invalid value: '{value}' for 'region' field. Valid values can be found using `yfin screen-query-values --field region`."
97
+ )
98
+ elif field == "exchange" and value not in VALID_EXCHANGES:
99
+ raise typer.BadParameter(
100
+ f"Invalid value: '{value}' for 'exchange' field. Valid values can be found using `yfin screen-query-values --field exchange`."
101
+ )
102
+ elif field == "sector" and value not in VALID_SECTORS:
103
+ raise typer.BadParameter(
104
+ f"Invalid value: '{value}' for 'sector' field. Valid values can be found using `yfin screen-query-values --field sector`."
105
+ )
106
+ elif field == "industry" and value not in VALID_INDUSTRIES:
107
+ raise typer.BadParameter(
108
+ f"Invalid value: '{value}' for 'industry' field. Valid values can be found using `yfin screen-query-values --field industry`."
109
+ )
110
+ elif field == "peer_group" and value not in VALID_PEER_GROUPS:
111
+ raise typer.BadParameter(
112
+ f"Invalid value: '{value}' for 'peer_group' field. Valid values can be found using `yfin screen-query-values --field peer_group`."
113
+ )
114
+ return value
115
+
116
+ # Remaining fields are numeric
117
+ if not is_number(value):
118
+ raise typer.BadParameter(
119
+ f"Invalid value: '{value}' for '{field}' field. Expected a number."
120
+ )
121
+ return float(value)
122
+
123
+
124
+ def parse_filter(filter_str: str) -> yf.EquityQuery:
125
+ """
126
+ Parse a filter string like '<field> <operator> <value>' into an EquityQuery.
127
+ Example: 'sector eq Technology' -> EquityQuery('eq', ['sector', 'Technology'])
128
+ """
129
+ parts = filter_str.strip().split(maxsplit=2)
130
+ if len(parts) != 3:
131
+ raise typer.BadParameter(
132
+ f"Invalid filter format: '{filter_str}'. Expected '<field> <operator> <value>'."
133
+ )
134
+
135
+ field, operator, value = (parts[0].lower(), parts[1].lower(), parts[2])
136
+
137
+ validate_field(field)
138
+
139
+ if operator not in VALID_OPERATORS:
140
+ raise typer.BadParameter(
141
+ f"Invalid operator: '{operator}'. Use one of {', '.join(sorted(list(VALID_OPERATORS)))}"
142
+ )
143
+
144
+ if operator in ["is-in", "btwn"]:
145
+ # comma-separated values
146
+ values = [parse_value(field, v.strip()) for v in value.split(",")]
147
+ if operator == "btwn" and len(values) != 2:
148
+ raise typer.BadParameter(
149
+ f"Invalid value: '{value}' for '{field}' field. Expected exactly two values."
150
+ )
151
+ else:
152
+ values = [parse_value(field, value)]
153
+
154
+ return yf.EquityQuery(operator, [field] + values)
155
+
156
+
157
+ def parse_json_query(json_query: str) -> yf.EquityQuery:
158
+ """
159
+ Parse a JSON query into an EquityQuery.
160
+ """
161
+ try:
162
+ query_node = json.loads(json_query)
163
+ except json.JSONDecodeError:
164
+ raise typer.BadParameter(f"Invalid JSON query: '{json_query}'.")
165
+
166
+ def _build_query(node):
167
+ if isinstance(node, str):
168
+ return parse_filter(node)
169
+
170
+ if isinstance(node, dict):
171
+ operator = node.get("operator")
172
+ queries = node.get("queries")
173
+ if operator is None:
174
+ raise typer.BadParameter("Invalid query, 'operator' is required.")
175
+
176
+ if queries is None:
177
+ raise typer.BadParameter("Invalid query, 'queries' is required.")
178
+
179
+ if operator not in ["and", "or"]:
180
+ raise typer.BadParameter(
181
+ "Invalid query, 'operator' must be either 'and' or 'or'."
182
+ )
183
+
184
+ if not isinstance(queries, list) or len(queries) == 0:
185
+ raise typer.BadParameter(
186
+ "Invalid query, 'queries' must be a non-empty list."
187
+ )
188
+ operands = [_build_query(q) for q in queries]
189
+
190
+ # yfinance does not support single operand queries
191
+ if len(operands) == 1:
192
+ return operands[0]
193
+
194
+ return yf.EquityQuery(operator, operands)
195
+
196
+ raise typer.BadParameter(
197
+ f"Invalid query, unexpected type: {type(node).__name__}"
198
+ )
199
+
200
+ return _build_query(query_node)
201
+
202
+
203
+ @command
204
+ def screen(
205
+ filters: ScreenFilterTypeOptional = None,
206
+ predefined: ScreenPredefinedQueryTypeOptional = None,
207
+ json_query: ScreenJsonQueryTypeOptional = None,
208
+ offset: OffsetType = default_offset,
209
+ limit: LimitType = default_limit,
210
+ sort_field: ScreenQueryFieldType = None,
211
+ sort_order: ScreenSortOrderType = default_sort_order,
212
+ ):
213
+ """
214
+ Run a stock screener.
215
+
216
+ Supports predefined queries, simple filters (implicitly ANDed), or complex JSON queries.
217
+ Options --predefined, --filter, and --json-query are mutually exclusive.
218
+ """
219
+ specified_count = count_specified(filters, predefined, json_query)
220
+ if specified_count != 1:
221
+ raise typer.BadParameter(
222
+ "Exactly one of --filter, --predefined, or --json-query must be specified."
223
+ )
224
+
225
+ if predefined:
226
+ if predefined not in PREDEFINED_QUERIES:
227
+ raise typer.BadParameter(
228
+ f"Invalid predefined query: '{predefined}'. Valid predefined queries can be found using `yfin screen-predefined-queries`."
229
+ )
230
+ final_query = predefined
231
+ elif filters:
232
+ queries = [parse_filter(f) for f in filters]
233
+ final_query = (
234
+ queries[0] if len(queries) == 1 else yf.EquityQuery("and", queries)
235
+ )
236
+ else:
237
+ final_query = parse_json_query(json_query)
238
+
239
+ if sort_field:
240
+ validate_field(sort_field)
241
+
242
+ # yfinance docs say that size is only for custom queries, count is only for predefined queries
243
+ if predefined:
244
+ response = yf.screen(
245
+ final_query,
246
+ offset=offset,
247
+ count=limit,
248
+ sortField=sort_field,
249
+ sortAsc=sort_order == "asc",
250
+ )
251
+ else:
252
+ response = yf.screen(
253
+ final_query,
254
+ offset=offset,
255
+ size=limit,
256
+ sortField=sort_field,
257
+ sortAsc=sort_order == "asc",
258
+ )
259
+
260
+ return response["quotes"]
src/commands/sector.py ADDED
@@ -0,0 +1,66 @@
1
+ import yfinance as yf
2
+ from ..typer import SectorKeyType
3
+ from ..decorators import command
4
+ from ..utils import data_frame_to_list
5
+
6
+
7
+ @command
8
+ def sector_keys():
9
+ """
10
+ Get all available sector keys.
11
+ """
12
+ return [{"key": k} for k in yf.const.SECTOR_INDUSTY_MAPPING_LC.keys()]
13
+
14
+
15
+ @command
16
+ def sector_industries(key: SectorKeyType):
17
+ """
18
+ Get the industries within a sector.
19
+ """
20
+ return data_frame_to_list(yf.Sector(key).industries)
21
+
22
+
23
+ @command
24
+ def sector_overview(key: SectorKeyType):
25
+ """
26
+ Get the overview information of the domain entity.
27
+ """
28
+ return yf.Sector(key).overview
29
+
30
+
31
+ @command
32
+ def sector_research_reports(key: SectorKeyType):
33
+ """
34
+ Get research reports related to the domain entity.
35
+ """
36
+ return yf.Sector(key).research_reports
37
+
38
+
39
+ @command
40
+ def sector_top_companies(key: SectorKeyType):
41
+ """
42
+ Get the top companies within the domain entity.
43
+ """
44
+ return data_frame_to_list(yf.Sector(key).top_companies)
45
+
46
+
47
+ @command
48
+ def sector_top_etfs(key: SectorKeyType):
49
+ """
50
+ Get the top ETFs for the sector.
51
+ """
52
+ return [
53
+ {"symbol": symbol, "name": name}
54
+ for symbol, name in yf.Sector(key).top_etfs.items()
55
+ ]
56
+
57
+
58
+ @command
59
+ def sector_top_mutual_funds(key: SectorKeyType):
60
+ """
61
+ Get the top mutual funds for the sector.
62
+ """
63
+ return [
64
+ {"symbol": symbol, "name": name}
65
+ for symbol, name in yf.Sector(key).top_mutual_funds.items()
66
+ ]
src/commands/stock.py ADDED
@@ -0,0 +1,108 @@
1
+ import yfinance as yf
2
+ import typer
3
+ from ..typer import (
4
+ TickerType,
5
+ IntervalType,
6
+ default_interval,
7
+ StartDateTypeOptional,
8
+ EndDateTypeOptional,
9
+ PeriodTypeOptional,
10
+ PeriodType,
11
+ default_period,
12
+ NewsTabType,
13
+ default_news_tab,
14
+ CountType,
15
+ default_count,
16
+ )
17
+ from ..decorators import command
18
+ from ..utils import count_specified, compact, data_frame_to_list, series_to_list
19
+
20
+
21
+ @command
22
+ def history(
23
+ ticker: TickerType,
24
+ interval: IntervalType = default_interval,
25
+ period: PeriodTypeOptional = None,
26
+ start: StartDateTypeOptional = None,
27
+ end: EndDateTypeOptional = None,
28
+ ):
29
+ """
30
+ Get historical market data for a stock ticker.
31
+
32
+ Note: period, start, and end - at most 2 of these can be specified together.
33
+ """
34
+ specified_count = count_specified(period, start, end)
35
+ if specified_count > 2:
36
+ raise typer.BadParameter(
37
+ "At most 2 of --period, --start, --end can be specified together."
38
+ )
39
+
40
+ if specified_count == 0:
41
+ period = "1mo"
42
+
43
+ kwargs = compact(
44
+ interval=interval,
45
+ period=period,
46
+ start=start,
47
+ end=end,
48
+ )
49
+
50
+ stock = yf.Ticker(ticker)
51
+ data_frame = stock.history(**kwargs)
52
+ return data_frame_to_list(data_frame)
53
+
54
+
55
+ @command
56
+ def dividends(
57
+ ticker: TickerType,
58
+ period: PeriodType = default_period,
59
+ ):
60
+ """
61
+ Get dividends for a stock ticker.
62
+ """
63
+ stock = yf.Ticker(ticker)
64
+ series = stock.get_dividends(period=period)
65
+ return series_to_list(series)
66
+
67
+
68
+ @command
69
+ def fast_info(
70
+ ticker: TickerType,
71
+ ):
72
+ """
73
+ Get fast info (15 min delayed) summary for a stock ticker.
74
+
75
+ Returns key metrics like price, market cap, volume, and 52-week range.
76
+ """
77
+ stock = yf.Ticker(ticker)
78
+ fast_info = stock.get_fast_info()
79
+ if fast_info is None:
80
+ return None
81
+ fast_info_dict = dict(fast_info) # fast_info is a dict-like object
82
+ return fast_info_dict
83
+
84
+
85
+ @command
86
+ def news(
87
+ ticker: TickerType,
88
+ count: CountType = default_count,
89
+ tab: NewsTabType = default_news_tab,
90
+ ):
91
+ """
92
+ Get news for a stock ticker.
93
+ """
94
+ stock = yf.Ticker(ticker)
95
+ news_list = stock.get_news(count, tab)
96
+ processed_news_list = []
97
+ for article in news_list:
98
+ content = article.get("content") or {}
99
+ processed_news_list.append(
100
+ {
101
+ "Date": content.get("pubDate"),
102
+ "Title": content.get("title"),
103
+ "Summary": content.get("summary"),
104
+ "URL": (content.get("canonicalUrl") or {}).get("url"),
105
+ "Source": (content.get("provider") or {}).get("displayName"),
106
+ }
107
+ )
108
+ return processed_news_list
src/decorators.py ADDED
@@ -0,0 +1,33 @@
1
+ import typer
2
+ import click
3
+ from functools import wraps
4
+ from .writer import WriterFactory
5
+ from .utils import console_print_error, console_print_warning
6
+
7
+
8
+ def command(func):
9
+ """Decorator to handle standard errors and output writing in CLI commands."""
10
+
11
+ @wraps(func)
12
+ def wrapper(*args, **kwargs):
13
+ try:
14
+ data = func(*args, **kwargs)
15
+
16
+ if data is None:
17
+ console_print_warning("No data found")
18
+ raise typer.Exit(code=1)
19
+
20
+ if not isinstance(data, (dict, list)):
21
+ raise ValueError(f"Unsupported data type: {type(data).__name__}")
22
+
23
+ ctx = click.get_current_context()
24
+ output_type = ctx.obj.get("output")
25
+ writer = WriterFactory.get_writer(output_type)
26
+ writer.write(data)
27
+ except (typer.Exit, typer.Abort, typer.BadParameter):
28
+ raise
29
+ except Exception as e:
30
+ console_print_error(f"Unexpected error: {e}")
31
+ raise typer.Exit(code=1)
32
+
33
+ return wrapper
src/typer.py ADDED
@@ -0,0 +1,223 @@
1
+ import typer
2
+ from typing import Annotated
3
+ from .utils import (
4
+ get_today_date_string,
5
+ validate_date_string,
6
+ validate_value_in_list,
7
+ )
8
+
9
+ TickerType = Annotated[
10
+ str,
11
+ typer.Argument(
12
+ help="Stock ticker symbol (e.g., TSLA, AAPL)", callback=lambda x: x.upper()
13
+ ),
14
+ ]
15
+
16
+ VALID_OUTPUT_TYPES = ["json", "table"]
17
+
18
+ default_output = VALID_OUTPUT_TYPES[0]
19
+
20
+ OutputType = Annotated[
21
+ str,
22
+ typer.Option(
23
+ callback=validate_value_in_list(VALID_OUTPUT_TYPES),
24
+ help=f"Output format ({', '.join(VALID_OUTPUT_TYPES)})",
25
+ ),
26
+ ]
27
+
28
+ default_frequency = "yearly"
29
+
30
+ FrequencyType = Annotated[
31
+ str,
32
+ typer.Option(
33
+ callback=validate_value_in_list(["yearly", "quarterly"]),
34
+ help="Frequency of the data (yearly, quarterly)",
35
+ ),
36
+ ]
37
+
38
+ ExtendedFrequencyType = Annotated[
39
+ str,
40
+ typer.Option(
41
+ callback=validate_value_in_list(["yearly", "quarterly", "trailing"]),
42
+ help="Frequency of the data (yearly, quarterly, trailing)",
43
+ ),
44
+ ]
45
+
46
+ StartDateType = Annotated[
47
+ str,
48
+ typer.Option(
49
+ default_factory=get_today_date_string,
50
+ callback=validate_date_string,
51
+ help="Start date (YYYY-MM-DD), default today",
52
+ ),
53
+ ]
54
+
55
+ StartDateTypeOptional = Annotated[
56
+ str | None,
57
+ typer.Option(
58
+ callback=lambda x: validate_date_string(x) if x else None,
59
+ help="Start date (YYYY-MM-DD)",
60
+ ),
61
+ ]
62
+
63
+ EndDateType = Annotated[
64
+ str | None,
65
+ typer.Option(
66
+ callback=lambda x: validate_date_string(x) if x else None,
67
+ help="End date (YYYY-MM-DD), default 7 days from start",
68
+ ),
69
+ ]
70
+
71
+ EndDateTypeOptional = Annotated[
72
+ str | None,
73
+ typer.Option(
74
+ callback=lambda x: validate_date_string(x) if x else None,
75
+ help="End date (YYYY-MM-DD)",
76
+ ),
77
+ ]
78
+
79
+ VALID_PERIODS = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"]
80
+
81
+ default_period = VALID_PERIODS[-1] # max
82
+
83
+
84
+ PeriodType = Annotated[
85
+ str,
86
+ typer.Option(
87
+ callback=validate_value_in_list(VALID_PERIODS),
88
+ help=f"Data period {', '.join(VALID_PERIODS)}",
89
+ ),
90
+ ]
91
+
92
+ PeriodTypeOptional = Annotated[
93
+ str | None,
94
+ typer.Option(
95
+ callback=lambda x: validate_value_in_list(VALID_PERIODS)(x) if x else None,
96
+ help=f"Data period {', '.join(VALID_PERIODS)}",
97
+ ),
98
+ ]
99
+
100
+ default_limit = 12
101
+
102
+ LimitType = Annotated[
103
+ int,
104
+ typer.Option(
105
+ help="Maximum number of results to show",
106
+ ),
107
+ ]
108
+
109
+ default_offset = 0
110
+
111
+ OffsetType = Annotated[
112
+ int,
113
+ typer.Option(
114
+ help="Offset of the results",
115
+ ),
116
+ ]
117
+
118
+ default_market_cap = 0
119
+
120
+ MarketCapType = Annotated[
121
+ int,
122
+ typer.Option(
123
+ help="Market cap of the company",
124
+ ),
125
+ ]
126
+
127
+ VALID_INTERVALS = [
128
+ "1m",
129
+ "2m",
130
+ "5m",
131
+ "15m",
132
+ "30m",
133
+ "60m",
134
+ "90m",
135
+ "1h",
136
+ "1d",
137
+ "5d",
138
+ "1wk",
139
+ "1mo",
140
+ "3mo",
141
+ ]
142
+
143
+ default_interval = VALID_INTERVALS[8] # "1d"
144
+
145
+ IntervalType = Annotated[
146
+ str,
147
+ typer.Option(
148
+ callback=validate_value_in_list(VALID_INTERVALS),
149
+ help=f"Data interval ({', '.join(VALID_INTERVALS)})",
150
+ ),
151
+ ]
152
+
153
+ default_count = 10
154
+
155
+ CountType = Annotated[
156
+ int,
157
+ typer.Option(
158
+ help="Number of results to show",
159
+ ),
160
+ ]
161
+
162
+ default_news_tab = "all"
163
+
164
+ NewsTabType = Annotated[
165
+ str,
166
+ typer.Option(
167
+ callback=validate_value_in_list(["all", "news", "press releases"]),
168
+ help="Tab of the news (all, news, press releases)",
169
+ ),
170
+ ]
171
+
172
+ SectorKeyType = Annotated[
173
+ str,
174
+ typer.Argument(
175
+ help="A valid sector key, can be found using `yfin sector-keys`",
176
+ ),
177
+ ]
178
+
179
+ IndustryKeyType = Annotated[
180
+ str,
181
+ typer.Argument(
182
+ help="A valid industry key, can be found using `yfin sector-industries <sector-key>`",
183
+ ),
184
+ ]
185
+
186
+ ScreenFilterTypeOptional = Annotated[
187
+ list[str] | None,
188
+ typer.Option(
189
+ "--filter", # parameter name is plural
190
+ help="Filter in '<field> <operator> <value>' format (e.g., 'sector eq Technology')",
191
+ ),
192
+ ]
193
+
194
+ ScreenPredefinedQueryTypeOptional = Annotated[
195
+ str | None,
196
+ typer.Option(
197
+ help="Predefined query, can be found using `yfin screen-predefined-queries`",
198
+ ),
199
+ ]
200
+
201
+ ScreenJsonQueryTypeOptional = Annotated[
202
+ str | None,
203
+ typer.Option(
204
+ help="Complex query in JSON format: {'operator': 'and|or', 'queries': [<filter1>, <filter2>, <json_query>, ...]}.",
205
+ ),
206
+ ]
207
+
208
+ ScreenQueryFieldType = Annotated[
209
+ str | None,
210
+ typer.Option(
211
+ help="Query field, can be found using `yfin screen-query-fields`",
212
+ ),
213
+ ]
214
+
215
+ default_sort_order = "desc"
216
+
217
+ ScreenSortOrderType = Annotated[
218
+ str,
219
+ typer.Option(
220
+ callback=validate_value_in_list(["asc", "desc"]),
221
+ help="Sort order, can be 'asc' or 'desc'",
222
+ ),
223
+ ]