bullishpy 0.4.0__py3-none-any.whl → 0.5.0__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.
bullish/app/app.py CHANGED
@@ -1,7 +1,7 @@
1
1
  import shelve
2
2
  import uuid
3
3
  from pathlib import Path
4
- from typing import Optional
4
+ from typing import Optional, List, Type, Dict, Any
5
5
 
6
6
  import pandas as pd
7
7
  import streamlit as st
@@ -18,9 +18,14 @@ from bullish.analysis.filter import (
18
18
  FilterUpdate,
19
19
  FilteredResults,
20
20
  FilterQueryStored,
21
+ TechnicalAnalysisFilter,
22
+ FundamentalAnalysisFilter,
23
+ GROUP_MAPPING,
24
+ GeneralFilter,
21
25
  )
22
26
  from bullish.jobs.models import JobTracker
23
27
  from bullish.jobs.tasks import update, news
28
+ from pydantic import BaseModel
24
29
 
25
30
  CACHE_SHELVE = "user_cache"
26
31
  DB_KEY = "db_path"
@@ -97,6 +102,55 @@ def dialog_pick_database() -> None:
97
102
  st.stop()
98
103
 
99
104
 
105
+ def build_filter(model: Type[BaseModel]) -> Dict[str, Any]:
106
+ data: Dict[str, Any] = {}
107
+ for field, info in model.model_fields.items():
108
+ name = info.alias or field
109
+ if info.annotation == Optional[List[str]]: # type: ignore
110
+ data[field] = st.multiselect(name, GROUP_MAPPING[field], key=name)
111
+
112
+ else:
113
+ ge = next((item.ge for item in info.metadata if hasattr(item, "ge")), None)
114
+ le = next((item.le for item in info.metadata if hasattr(item, "le")), None)
115
+ data[field] = list(st.slider(name, le, ge, tuple(info.default))) # type: ignore
116
+ return data
117
+
118
+
119
+ @st.dialog("🔍 Filter", width="large")
120
+ def filter() -> None:
121
+
122
+ st.markdown(
123
+ """
124
+ <style>
125
+ div[data-testid="stDialog"] div[role="dialog"]:has(.big-dialog) {
126
+ width: 90vw;
127
+ height: 110vh;
128
+ }
129
+ </style>
130
+ """,
131
+ unsafe_allow_html=True,
132
+ )
133
+ with st.expander("Technical Analysis"):
134
+ data_ = build_filter(TechnicalAnalysisFilter)
135
+ st.session_state.filter_query.update(data_)
136
+ with st.expander("Fundamental Analysis"):
137
+ data_ = build_filter(FundamentalAnalysisFilter)
138
+ st.session_state.filter_query.update(data_)
139
+ with st.expander("General filter"):
140
+ data_ = build_filter(GeneralFilter)
141
+ st.session_state.filter_query.update(data_)
142
+ if st.button("🔍 Apply"):
143
+ query = FilterQuery.model_validate(st.session_state.filter_query)
144
+ if query.valid():
145
+ st.session_state.data = bearish_db(
146
+ st.session_state.database_path
147
+ ).read_filter_query(query)
148
+ st.session_state.ticker_figure = None
149
+ st.session_state.filter_query = {}
150
+ st.session_state.query = query
151
+ st.rerun()
152
+
153
+
100
154
  @st.dialog("📈 Price history and analysis", width="large")
101
155
  def dialog_plot_figure() -> None:
102
156
  st.markdown(
@@ -115,8 +169,43 @@ def dialog_plot_figure() -> None:
115
169
  st.session_state.ticker_figure = None
116
170
 
117
171
 
118
- def main() -> None: # noqa: PLR0915, C901
172
+ @st.dialog("⭐ Save filtered results")
173
+ def save_filtered_results(bearish_db_: BullishDb) -> None:
174
+ user_input = st.text_input("Selection name").strip()
175
+ headless = st.checkbox("Headless mode", value=True)
176
+ apply = st.button("Apply")
177
+ if apply:
178
+ if not user_input:
179
+ st.error("This field is required.")
180
+ else:
181
+ symbols = st.session_state.data["symbol"].unique().tolist()
182
+ filtered_results = FilteredResults(
183
+ name=user_input,
184
+ filter_query=FilterQueryStored.model_validate(
185
+ st.session_state.query.model_dump(
186
+ exclude_unset=True, exclude_defaults=True
187
+ )
188
+ ),
189
+ symbols=symbols,
190
+ )
191
+
192
+ bearish_db_.write_filtered_results(filtered_results)
193
+ res = news(
194
+ database_path=st.session_state.database_path,
195
+ symbols=symbols,
196
+ headless=headless,
197
+ )
198
+ bearish_db_.write_job_tracker(
199
+ JobTracker(job_id=str(res.id), type="Fetching news")
200
+ )
201
+ st.session_state.filter_query = None
202
+ st.session_state.query = None
203
+ st.rerun()
204
+
205
+
206
+ def main() -> None:
119
207
  assign_db_state()
208
+
120
209
  if st.session_state.database_path is None:
121
210
  dialog_pick_database()
122
211
  bearish_db_ = bearish_db(st.session_state.database_path)
@@ -124,43 +213,6 @@ def main() -> None: # noqa: PLR0915, C901
124
213
  if "data" not in st.session_state:
125
214
  st.session_state.data = load_analysis_data(bearish_db_)
126
215
  with st.sidebar:
127
- with st.expander("Filter"):
128
- view_query = sp.pydantic_form(key="my_form", model=FilterQuery)
129
- if view_query:
130
- st.session_state.data = bearish_db_.read_filter_query(view_query)
131
- st.session_state.ticker_figure = None
132
- st.session_state.filter_query = view_query
133
- with st.container(border=True):
134
- disabled = "filter_query" not in st.session_state
135
- if "filter_query" in st.session_state:
136
- disabled = st.session_state.filter_query is None
137
- user_input = st.text_input("Enter your name:", disabled=disabled)
138
- headless = st.checkbox("Headless mode", value=True, disabled=disabled)
139
- if st.button("Save", disabled=disabled):
140
- name = user_input.strip()
141
- if not name:
142
- st.error("This field is required.")
143
- else:
144
- symbols = st.session_state.data["symbol"].unique().tolist()
145
- filtered_results = FilteredResults(
146
- name=name,
147
- filter_query=FilterQueryStored.model_validate(
148
- st.session_state.filter_query.model_dump()
149
- ),
150
- symbols=symbols,
151
- )
152
-
153
- bearish_db_.write_filtered_results(filtered_results)
154
- res = news(
155
- database_path=st.session_state.database_path,
156
- symbols=symbols,
157
- headless=headless,
158
- )
159
- bearish_db_.write_job_tracker(
160
- JobTracker(job_id=str(res.id), type="Fetching news")
161
- )
162
- st.session_state.filter_query = None
163
- st.success(f"Hello, {user_input}!")
164
216
  with st.expander("Load"):
165
217
  existing_filtered_results = bearish_db_.read_list_filtered_results()
166
218
  option = st.selectbox("Saved results", ["", *existing_filtered_results])
@@ -190,20 +242,38 @@ def main() -> None: # noqa: PLR0915, C901
190
242
  st.success("Data update job has been enqueued.")
191
243
  st.rerun()
192
244
  with charts_tab:
193
- st.header("✅ Data overview")
194
- st.dataframe(
195
- st.session_state.data,
196
- on_select=on_table_select,
197
- selection_mode="single-row",
198
- key="selected_data",
199
- use_container_width=True,
200
- height=600,
201
- )
202
- if (
203
- "ticker_figure" in st.session_state
204
- and st.session_state.ticker_figure is not None
205
- ):
206
- dialog_plot_figure()
245
+ with st.container():
246
+ col1, col2, _ = st.columns([0.5, 0.5, 5])
247
+ with col1:
248
+ if st.button("🔍 Filter"):
249
+ st.session_state.filter_query = {}
250
+ filter()
251
+ with col2:
252
+ if (
253
+ "query" in st.session_state
254
+ and st.session_state.query is not None
255
+ and st.session_state.query.valid()
256
+ ):
257
+ favorite = st.button(" ⭐ ")
258
+ if favorite:
259
+ save_filtered_results(bearish_db_)
260
+
261
+ with st.container():
262
+ st.header("✅ Data overview")
263
+
264
+ st.dataframe(
265
+ st.session_state.data,
266
+ on_select=on_table_select,
267
+ selection_mode="single-row",
268
+ key="selected_data",
269
+ use_container_width=True,
270
+ height=600,
271
+ )
272
+ if (
273
+ "ticker_figure" in st.session_state
274
+ and st.session_state.ticker_figure is not None
275
+ ):
276
+ dialog_plot_figure()
207
277
 
208
278
  with jobs_tab:
209
279
  job_trackers = bearish_db_.read_job_trackers()
bullish/cli.py CHANGED
@@ -64,7 +64,9 @@ def serve(
64
64
  time.sleep(1)
65
65
 
66
66
  except Exception as exc: # pragma: no cover
67
- typer.secho(f"❌ Failed to start services: {exc}", fg=typer.colors.RED, err=True)
67
+ typer.secho(
68
+ f"❌ Failed to start services: {exc}", fg=typer.colors.RED, err=True
69
+ )
68
70
  _shutdown()
69
71
 
70
72
 
@@ -5,6 +5,7 @@ Revises:
5
5
  Create Date: 2025-06-14 16:50:56.919222
6
6
 
7
7
  """
8
+
8
9
  from typing import Sequence, Union
9
10
 
10
11
  import sqlalchemy as sa
@@ -0,0 +1,368 @@
1
+ """
2
+
3
+ Revision ID: 11d35a452b40
4
+ Revises: 73564b60fe24
5
+ Create Date: 2025-06-23 06:02:07.122505
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "11d35a452b40"
16
+ down_revision: Union[str, None] = "73564b60fe24"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # ### commands auto generated by Alembic - please adjust! ###
23
+ with op.batch_alter_table("analysis", schema=None) as batch_op:
24
+ batch_op.alter_column(
25
+ "quarterly_positive_free_cash_flow",
26
+ existing_type=sa.FLOAT(),
27
+ type_=sa.Boolean(),
28
+ existing_nullable=True,
29
+ )
30
+ batch_op.alter_column(
31
+ "quarterly_growing_operating_cash_flow",
32
+ existing_type=sa.FLOAT(),
33
+ type_=sa.Boolean(),
34
+ existing_nullable=True,
35
+ )
36
+ batch_op.alter_column(
37
+ "quarterly_operating_cash_flow_is_higher_than_net_income",
38
+ existing_type=sa.FLOAT(),
39
+ type_=sa.Boolean(),
40
+ existing_nullable=True,
41
+ )
42
+ batch_op.alter_column(
43
+ "quarterly_positive_net_income",
44
+ existing_type=sa.FLOAT(),
45
+ type_=sa.Boolean(),
46
+ existing_nullable=True,
47
+ )
48
+ batch_op.alter_column(
49
+ "quarterly_positive_operating_income",
50
+ existing_type=sa.FLOAT(),
51
+ type_=sa.Boolean(),
52
+ existing_nullable=True,
53
+ )
54
+ batch_op.alter_column(
55
+ "quarterly_growing_net_income",
56
+ existing_type=sa.FLOAT(),
57
+ type_=sa.Boolean(),
58
+ existing_nullable=True,
59
+ )
60
+ batch_op.alter_column(
61
+ "quarterly_growing_operating_income",
62
+ existing_type=sa.FLOAT(),
63
+ type_=sa.Boolean(),
64
+ existing_nullable=True,
65
+ )
66
+ batch_op.alter_column(
67
+ "quarterly_positive_diluted_eps",
68
+ existing_type=sa.FLOAT(),
69
+ type_=sa.Boolean(),
70
+ existing_nullable=True,
71
+ )
72
+ batch_op.alter_column(
73
+ "quarterly_positive_basic_eps",
74
+ existing_type=sa.FLOAT(),
75
+ type_=sa.Boolean(),
76
+ existing_nullable=True,
77
+ )
78
+ batch_op.alter_column(
79
+ "quarterly_growing_basic_eps",
80
+ existing_type=sa.FLOAT(),
81
+ type_=sa.Boolean(),
82
+ existing_nullable=True,
83
+ )
84
+ batch_op.alter_column(
85
+ "quarterly_growing_diluted_eps",
86
+ existing_type=sa.FLOAT(),
87
+ type_=sa.Boolean(),
88
+ existing_nullable=True,
89
+ )
90
+ batch_op.alter_column(
91
+ "quarterly_positive_debt_to_equity",
92
+ existing_type=sa.FLOAT(),
93
+ type_=sa.Boolean(),
94
+ existing_nullable=True,
95
+ )
96
+ batch_op.alter_column(
97
+ "quarterly_positive_return_on_assets",
98
+ existing_type=sa.FLOAT(),
99
+ type_=sa.Boolean(),
100
+ existing_nullable=True,
101
+ )
102
+ batch_op.alter_column(
103
+ "quarterly_positive_return_on_equity",
104
+ existing_type=sa.FLOAT(),
105
+ type_=sa.Boolean(),
106
+ existing_nullable=True,
107
+ )
108
+ batch_op.alter_column(
109
+ "positive_free_cash_flow",
110
+ existing_type=sa.FLOAT(),
111
+ type_=sa.Boolean(),
112
+ existing_nullable=True,
113
+ )
114
+ batch_op.alter_column(
115
+ "growing_operating_cash_flow",
116
+ existing_type=sa.FLOAT(),
117
+ type_=sa.Boolean(),
118
+ existing_nullable=True,
119
+ )
120
+ batch_op.alter_column(
121
+ "operating_cash_flow_is_higher_than_net_income",
122
+ existing_type=sa.FLOAT(),
123
+ type_=sa.Boolean(),
124
+ existing_nullable=True,
125
+ )
126
+ batch_op.alter_column(
127
+ "positive_net_income",
128
+ existing_type=sa.FLOAT(),
129
+ type_=sa.Boolean(),
130
+ existing_nullable=True,
131
+ )
132
+ batch_op.alter_column(
133
+ "positive_operating_income",
134
+ existing_type=sa.FLOAT(),
135
+ type_=sa.Boolean(),
136
+ existing_nullable=True,
137
+ )
138
+ batch_op.alter_column(
139
+ "growing_net_income",
140
+ existing_type=sa.FLOAT(),
141
+ type_=sa.Boolean(),
142
+ existing_nullable=True,
143
+ )
144
+ batch_op.alter_column(
145
+ "growing_operating_income",
146
+ existing_type=sa.FLOAT(),
147
+ type_=sa.Boolean(),
148
+ existing_nullable=True,
149
+ )
150
+ batch_op.alter_column(
151
+ "positive_diluted_eps",
152
+ existing_type=sa.FLOAT(),
153
+ type_=sa.Boolean(),
154
+ existing_nullable=True,
155
+ )
156
+ batch_op.alter_column(
157
+ "positive_basic_eps",
158
+ existing_type=sa.FLOAT(),
159
+ type_=sa.Boolean(),
160
+ existing_nullable=True,
161
+ )
162
+ batch_op.alter_column(
163
+ "growing_basic_eps",
164
+ existing_type=sa.FLOAT(),
165
+ type_=sa.Boolean(),
166
+ existing_nullable=True,
167
+ )
168
+ batch_op.alter_column(
169
+ "growing_diluted_eps",
170
+ existing_type=sa.FLOAT(),
171
+ type_=sa.Boolean(),
172
+ existing_nullable=True,
173
+ )
174
+ batch_op.alter_column(
175
+ "positive_debt_to_equity",
176
+ existing_type=sa.FLOAT(),
177
+ type_=sa.Boolean(),
178
+ existing_nullable=True,
179
+ )
180
+ batch_op.alter_column(
181
+ "positive_return_on_assets",
182
+ existing_type=sa.FLOAT(),
183
+ type_=sa.Boolean(),
184
+ existing_nullable=True,
185
+ )
186
+ batch_op.alter_column(
187
+ "positive_return_on_equity",
188
+ existing_type=sa.FLOAT(),
189
+ type_=sa.Boolean(),
190
+ existing_nullable=True,
191
+ )
192
+
193
+ # ### end Alembic commands ###
194
+
195
+
196
+ def downgrade() -> None:
197
+ # ### commands auto generated by Alembic - please adjust! ###
198
+ with op.batch_alter_table("analysis", schema=None) as batch_op:
199
+ batch_op.alter_column(
200
+ "positive_return_on_equity",
201
+ existing_type=sa.Boolean(),
202
+ type_=sa.FLOAT(),
203
+ existing_nullable=True,
204
+ )
205
+ batch_op.alter_column(
206
+ "positive_return_on_assets",
207
+ existing_type=sa.Boolean(),
208
+ type_=sa.FLOAT(),
209
+ existing_nullable=True,
210
+ )
211
+ batch_op.alter_column(
212
+ "positive_debt_to_equity",
213
+ existing_type=sa.Boolean(),
214
+ type_=sa.FLOAT(),
215
+ existing_nullable=True,
216
+ )
217
+ batch_op.alter_column(
218
+ "growing_diluted_eps",
219
+ existing_type=sa.Boolean(),
220
+ type_=sa.FLOAT(),
221
+ existing_nullable=True,
222
+ )
223
+ batch_op.alter_column(
224
+ "growing_basic_eps",
225
+ existing_type=sa.Boolean(),
226
+ type_=sa.FLOAT(),
227
+ existing_nullable=True,
228
+ )
229
+ batch_op.alter_column(
230
+ "positive_basic_eps",
231
+ existing_type=sa.Boolean(),
232
+ type_=sa.FLOAT(),
233
+ existing_nullable=True,
234
+ )
235
+ batch_op.alter_column(
236
+ "positive_diluted_eps",
237
+ existing_type=sa.Boolean(),
238
+ type_=sa.FLOAT(),
239
+ existing_nullable=True,
240
+ )
241
+ batch_op.alter_column(
242
+ "growing_operating_income",
243
+ existing_type=sa.Boolean(),
244
+ type_=sa.FLOAT(),
245
+ existing_nullable=True,
246
+ )
247
+ batch_op.alter_column(
248
+ "growing_net_income",
249
+ existing_type=sa.Boolean(),
250
+ type_=sa.FLOAT(),
251
+ existing_nullable=True,
252
+ )
253
+ batch_op.alter_column(
254
+ "positive_operating_income",
255
+ existing_type=sa.Boolean(),
256
+ type_=sa.FLOAT(),
257
+ existing_nullable=True,
258
+ )
259
+ batch_op.alter_column(
260
+ "positive_net_income",
261
+ existing_type=sa.Boolean(),
262
+ type_=sa.FLOAT(),
263
+ existing_nullable=True,
264
+ )
265
+ batch_op.alter_column(
266
+ "operating_cash_flow_is_higher_than_net_income",
267
+ existing_type=sa.Boolean(),
268
+ type_=sa.FLOAT(),
269
+ existing_nullable=True,
270
+ )
271
+ batch_op.alter_column(
272
+ "growing_operating_cash_flow",
273
+ existing_type=sa.Boolean(),
274
+ type_=sa.FLOAT(),
275
+ existing_nullable=True,
276
+ )
277
+ batch_op.alter_column(
278
+ "positive_free_cash_flow",
279
+ existing_type=sa.Boolean(),
280
+ type_=sa.FLOAT(),
281
+ existing_nullable=True,
282
+ )
283
+ batch_op.alter_column(
284
+ "quarterly_positive_return_on_equity",
285
+ existing_type=sa.Boolean(),
286
+ type_=sa.FLOAT(),
287
+ existing_nullable=True,
288
+ )
289
+ batch_op.alter_column(
290
+ "quarterly_positive_return_on_assets",
291
+ existing_type=sa.Boolean(),
292
+ type_=sa.FLOAT(),
293
+ existing_nullable=True,
294
+ )
295
+ batch_op.alter_column(
296
+ "quarterly_positive_debt_to_equity",
297
+ existing_type=sa.Boolean(),
298
+ type_=sa.FLOAT(),
299
+ existing_nullable=True,
300
+ )
301
+ batch_op.alter_column(
302
+ "quarterly_growing_diluted_eps",
303
+ existing_type=sa.Boolean(),
304
+ type_=sa.FLOAT(),
305
+ existing_nullable=True,
306
+ )
307
+ batch_op.alter_column(
308
+ "quarterly_growing_basic_eps",
309
+ existing_type=sa.Boolean(),
310
+ type_=sa.FLOAT(),
311
+ existing_nullable=True,
312
+ )
313
+ batch_op.alter_column(
314
+ "quarterly_positive_basic_eps",
315
+ existing_type=sa.Boolean(),
316
+ type_=sa.FLOAT(),
317
+ existing_nullable=True,
318
+ )
319
+ batch_op.alter_column(
320
+ "quarterly_positive_diluted_eps",
321
+ existing_type=sa.Boolean(),
322
+ type_=sa.FLOAT(),
323
+ existing_nullable=True,
324
+ )
325
+ batch_op.alter_column(
326
+ "quarterly_growing_operating_income",
327
+ existing_type=sa.Boolean(),
328
+ type_=sa.FLOAT(),
329
+ existing_nullable=True,
330
+ )
331
+ batch_op.alter_column(
332
+ "quarterly_growing_net_income",
333
+ existing_type=sa.Boolean(),
334
+ type_=sa.FLOAT(),
335
+ existing_nullable=True,
336
+ )
337
+ batch_op.alter_column(
338
+ "quarterly_positive_operating_income",
339
+ existing_type=sa.Boolean(),
340
+ type_=sa.FLOAT(),
341
+ existing_nullable=True,
342
+ )
343
+ batch_op.alter_column(
344
+ "quarterly_positive_net_income",
345
+ existing_type=sa.Boolean(),
346
+ type_=sa.FLOAT(),
347
+ existing_nullable=True,
348
+ )
349
+ batch_op.alter_column(
350
+ "quarterly_operating_cash_flow_is_higher_than_net_income",
351
+ existing_type=sa.Boolean(),
352
+ type_=sa.FLOAT(),
353
+ existing_nullable=True,
354
+ )
355
+ batch_op.alter_column(
356
+ "quarterly_growing_operating_cash_flow",
357
+ existing_type=sa.Boolean(),
358
+ type_=sa.FLOAT(),
359
+ existing_nullable=True,
360
+ )
361
+ batch_op.alter_column(
362
+ "quarterly_positive_free_cash_flow",
363
+ existing_type=sa.Boolean(),
364
+ type_=sa.FLOAT(),
365
+ existing_nullable=True,
366
+ )
367
+
368
+ # ### end Alembic commands ###
@@ -5,6 +5,7 @@ Revises: 037dbd721317
5
5
  Create Date: 2025-06-20 09:17:53.566652
6
6
 
7
7
  """
8
+
8
9
  from typing import Sequence, Union
9
10
 
10
11
  from alembic import op
@@ -5,6 +5,7 @@ Revises: 4b0a2f40b7d3
5
5
  Create Date: 2025-06-20 17:08:28.818293
6
6
 
7
7
  """
8
+
8
9
  from typing import Sequence, Union
9
10
 
10
11
  from alembic import op
bullish/database/crud.py CHANGED
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import logging
2
3
  from functools import cached_property
3
4
  from pathlib import Path
@@ -140,7 +141,13 @@ class BullishDb(BearishDb, BullishDbBase): # type: ignore
140
141
 
141
142
  def write_filtered_results(self, filtered_results: FilteredResults) -> None:
142
143
  with Session(self._engine) as session:
143
- data = filtered_results.model_dump()
144
- stmt = insert(FilteredResultsORM).prefix_with("OR REPLACE").values(data)
144
+ data = filtered_results.model_dump_json(
145
+ exclude_unset=True, exclude_defaults=True
146
+ )
147
+ stmt = (
148
+ insert(FilteredResultsORM)
149
+ .prefix_with("OR REPLACE")
150
+ .values(json.loads(data))
151
+ )
145
152
  session.exec(stmt) # type: ignore
146
153
  session.commit()