bullishpy 0.4.0__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.
Potentially problematic release.
This version of bullishpy might be problematic. Click here for more details.
- bullishpy-0.4.0/PKG-INFO +22 -0
- bullishpy-0.4.0/README.md +1 -0
- bullishpy-0.4.0/bullish/__init__.py +0 -0
- bullishpy-0.4.0/bullish/analysis/__init__.py +0 -0
- bullishpy-0.4.0/bullish/analysis/analysis.py +608 -0
- bullishpy-0.4.0/bullish/analysis/filter.py +123 -0
- bullishpy-0.4.0/bullish/app/__init__.py +0 -0
- bullishpy-0.4.0/bullish/app/app.py +218 -0
- bullishpy-0.4.0/bullish/cli.py +72 -0
- bullishpy-0.4.0/bullish/database/__init__.py +0 -0
- bullishpy-0.4.0/bullish/database/alembic/README +1 -0
- bullishpy-0.4.0/bullish/database/alembic/alembic.ini +117 -0
- bullishpy-0.4.0/bullish/database/alembic/env.py +80 -0
- bullishpy-0.4.0/bullish/database/alembic/script.py.mako +26 -0
- bullishpy-0.4.0/bullish/database/alembic/versions/037dbd721317_.py +148 -0
- bullishpy-0.4.0/bullish/database/alembic/versions/4b0a2f40b7d3_.py +50 -0
- bullishpy-0.4.0/bullish/database/alembic/versions/73564b60fe24_.py +37 -0
- bullishpy-0.4.0/bullish/database/crud.py +146 -0
- bullishpy-0.4.0/bullish/database/schemas.py +33 -0
- bullishpy-0.4.0/bullish/database/scripts/create_revision.py +20 -0
- bullishpy-0.4.0/bullish/database/scripts/stamp.py +25 -0
- bullishpy-0.4.0/bullish/database/scripts/upgrade.py +27 -0
- bullishpy-0.4.0/bullish/database/settings.py +4 -0
- bullishpy-0.4.0/bullish/exceptions.py +2 -0
- bullishpy-0.4.0/bullish/figures/__init__.py +0 -0
- bullishpy-0.4.0/bullish/figures/figures.py +107 -0
- bullishpy-0.4.0/bullish/interface/__init__.py +0 -0
- bullishpy-0.4.0/bullish/interface/interface.py +96 -0
- bullishpy-0.4.0/bullish/jobs/__init__.py +0 -0
- bullishpy-0.4.0/bullish/jobs/app.py +3 -0
- bullishpy-0.4.0/bullish/jobs/models.py +25 -0
- bullishpy-0.4.0/bullish/jobs/tasks.py +77 -0
- bullishpy-0.4.0/pyproject.toml +108 -0
bullishpy-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: bullishpy
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary:
|
|
5
|
+
Author: aan
|
|
6
|
+
Author-email: andoludovic.andriamamonjy@gmail.com
|
|
7
|
+
Requires-Python: >=3.10,<3.13
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Requires-Dist: bearishpy (>=0.19.0,<0.20.0)
|
|
13
|
+
Requires-Dist: huey (>=2.5.3,<3.0.0)
|
|
14
|
+
Requires-Dist: pandas-ta (>=0.3.14b0,<0.4.0)
|
|
15
|
+
Requires-Dist: plotly (>=6.1.2,<7.0.0)
|
|
16
|
+
Requires-Dist: streamlit (>=1.45.1,<2.0.0)
|
|
17
|
+
Requires-Dist: streamlit-file-browser (>=3.2.22,<4.0.0)
|
|
18
|
+
Requires-Dist: streamlit-pydantic (>=v0.6.1-rc.3,<0.7.0)
|
|
19
|
+
Requires-Dist: tickermood (>=0.4.0,<0.5.0)
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
## Bullish
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
## Bullish
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from datetime import date
|
|
3
|
+
from typing import (
|
|
4
|
+
Annotated,
|
|
5
|
+
Any,
|
|
6
|
+
List,
|
|
7
|
+
Optional,
|
|
8
|
+
Sequence,
|
|
9
|
+
Type,
|
|
10
|
+
cast,
|
|
11
|
+
get_args,
|
|
12
|
+
TYPE_CHECKING,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
import pandas_ta as ta # type: ignore
|
|
17
|
+
from bearish.interface.interface import BearishDbBase # type: ignore
|
|
18
|
+
from bearish.models.assets.equity import BaseEquity # type: ignore
|
|
19
|
+
from bearish.models.base import ( # type: ignore
|
|
20
|
+
DataSourceBase,
|
|
21
|
+
Ticker,
|
|
22
|
+
PriceTracker,
|
|
23
|
+
TrackerQuery,
|
|
24
|
+
FinancialsTracker,
|
|
25
|
+
)
|
|
26
|
+
from bearish.models.financials.balance_sheet import ( # type: ignore
|
|
27
|
+
BalanceSheet,
|
|
28
|
+
QuarterlyBalanceSheet,
|
|
29
|
+
)
|
|
30
|
+
from bearish.models.financials.base import Financials # type: ignore
|
|
31
|
+
from bearish.models.financials.cash_flow import ( # type: ignore
|
|
32
|
+
CashFlow,
|
|
33
|
+
QuarterlyCashFlow,
|
|
34
|
+
)
|
|
35
|
+
from bearish.models.financials.metrics import ( # type: ignore
|
|
36
|
+
FinancialMetrics,
|
|
37
|
+
QuarterlyFinancialMetrics,
|
|
38
|
+
)
|
|
39
|
+
from bearish.models.price.prices import Prices # type: ignore
|
|
40
|
+
from bearish.models.query.query import AssetQuery, Symbols # type: ignore
|
|
41
|
+
from bearish.types import TickerOnlySources # type: ignore
|
|
42
|
+
from pydantic import BaseModel, BeforeValidator, Field, create_model
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from bullish.database.crud import BullishDb
|
|
46
|
+
|
|
47
|
+
QUARTERLY = "quarterly"
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_float(value: Any) -> Optional[float]:
|
|
52
|
+
if value == "None":
|
|
53
|
+
return None
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
if isinstance(value, str):
|
|
57
|
+
try:
|
|
58
|
+
return float(value)
|
|
59
|
+
except ValueError:
|
|
60
|
+
return None
|
|
61
|
+
return float(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def price_growth(prices: pd.DataFrame, days: int, max: bool = False) -> Optional[float]:
|
|
65
|
+
prices_ = prices.copy()
|
|
66
|
+
last_index = prices_.last_valid_index()
|
|
67
|
+
delta = pd.Timedelta(days=days)
|
|
68
|
+
start_index = last_index - delta # type: ignore
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
closest_index = prices_.index.unique().asof(start_index) # type: ignore
|
|
72
|
+
price = (
|
|
73
|
+
prices_.loc[closest_index].close
|
|
74
|
+
if not max
|
|
75
|
+
else prices_[closest_index:].close.max()
|
|
76
|
+
)
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning(
|
|
79
|
+
f"""Failing to calculate price growth: {e}.""",
|
|
80
|
+
exc_info=True,
|
|
81
|
+
)
|
|
82
|
+
return None
|
|
83
|
+
return ( # type: ignore
|
|
84
|
+
(prices_.loc[last_index].close - price) * 100 / prices_.loc[last_index].close
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def buy_opportunity(
|
|
89
|
+
series_a: pd.Series, series_b: pd.Series # type: ignore
|
|
90
|
+
) -> Optional[date]:
|
|
91
|
+
sell = ta.cross(series_a=series_a, series_b=series_b)
|
|
92
|
+
buy = ta.cross(series_a=series_b, series_b=series_a)
|
|
93
|
+
if not buy[buy == 1].index.empty and not sell[sell == 1].index.empty:
|
|
94
|
+
last_buy_signal = pd.Timestamp(buy[buy == 1].index[-1])
|
|
95
|
+
last_sell_signal = pd.Timestamp(sell[sell == 1].index[-1])
|
|
96
|
+
if last_buy_signal > last_sell_signal:
|
|
97
|
+
return last_buy_signal
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def perc(data: pd.Series) -> float: # type: ignore
|
|
102
|
+
return cast(float, ((data.iloc[-1] - data.iloc[0]) / data.iloc[0]) * 100)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def yoy(prices: pd.DataFrame) -> pd.Series: # type: ignore
|
|
106
|
+
return prices.close.resample("YE").apply(perc) # type: ignore
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def mom(prices: pd.DataFrame) -> pd.Series: # type: ignore
|
|
110
|
+
return prices.close.resample("ME").apply(perc) # type: ignore
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def wow(prices: pd.DataFrame) -> pd.Series: # type: ignore
|
|
114
|
+
return prices.close.resample("W").apply(perc) # type: ignore
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _load_data(
|
|
118
|
+
data: Sequence[DataSourceBase], symbol: str, class_: Type[DataSourceBase]
|
|
119
|
+
) -> pd.DataFrame:
|
|
120
|
+
try:
|
|
121
|
+
records = pd.DataFrame.from_records(
|
|
122
|
+
[f.model_dump() for f in data if f.symbol == symbol]
|
|
123
|
+
)
|
|
124
|
+
return records.set_index("date").sort_index()
|
|
125
|
+
except Exception as e:
|
|
126
|
+
logger.warning(f"Failed to load data from {symbol}: {e}")
|
|
127
|
+
columns = list(class_.model_fields)
|
|
128
|
+
return pd.DataFrame(columns=columns).sort_index()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _compute_growth(series: pd.Series) -> bool: # type: ignore
|
|
132
|
+
if series.empty:
|
|
133
|
+
return False
|
|
134
|
+
return all(series.pct_change(fill_method=None).dropna() > 0)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _all_positive(series: pd.Series) -> bool: # type: ignore
|
|
138
|
+
if series.empty:
|
|
139
|
+
return False
|
|
140
|
+
return all(series.dropna() > 0)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _get_last(data: pd.Series) -> Optional[float]: # type: ignore
|
|
144
|
+
return data.iloc[-1] if not data.empty else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _abs(data: pd.Series) -> pd.Series: # type: ignore
|
|
148
|
+
try:
|
|
149
|
+
return abs(data)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.warning(f"Failed to compute absolute value: {e}")
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class TechnicalAnalysis(BaseModel):
|
|
156
|
+
rsi_last_value: Optional[float] = None
|
|
157
|
+
macd_12_26_9_buy_date: Optional[date] = None
|
|
158
|
+
ma_50_200_buy_date: Optional[date] = None
|
|
159
|
+
slope_7: Optional[float] = None
|
|
160
|
+
slope_14: Optional[float] = None
|
|
161
|
+
slope_30: Optional[float] = None
|
|
162
|
+
slope_60: Optional[float] = None
|
|
163
|
+
last_adx: Optional[float] = None
|
|
164
|
+
last_dmp: Optional[float] = None
|
|
165
|
+
last_dmn: Optional[float] = None
|
|
166
|
+
last_price: Annotated[
|
|
167
|
+
Optional[float],
|
|
168
|
+
BeforeValidator(to_float),
|
|
169
|
+
Field(
|
|
170
|
+
default=None,
|
|
171
|
+
),
|
|
172
|
+
]
|
|
173
|
+
last_price_date: Annotated[
|
|
174
|
+
Optional[date],
|
|
175
|
+
Field(
|
|
176
|
+
default=None,
|
|
177
|
+
),
|
|
178
|
+
]
|
|
179
|
+
year_to_date_growth: Annotated[
|
|
180
|
+
Optional[float],
|
|
181
|
+
Field(
|
|
182
|
+
default=None,
|
|
183
|
+
),
|
|
184
|
+
]
|
|
185
|
+
last_52_weeks_growth: Annotated[
|
|
186
|
+
Optional[float],
|
|
187
|
+
Field(
|
|
188
|
+
default=None,
|
|
189
|
+
),
|
|
190
|
+
]
|
|
191
|
+
last_week_growth: Annotated[
|
|
192
|
+
Optional[float],
|
|
193
|
+
Field(
|
|
194
|
+
default=None,
|
|
195
|
+
),
|
|
196
|
+
]
|
|
197
|
+
last_month_growth: Annotated[
|
|
198
|
+
Optional[float],
|
|
199
|
+
Field(
|
|
200
|
+
default=None,
|
|
201
|
+
),
|
|
202
|
+
]
|
|
203
|
+
last_year_growth: Annotated[
|
|
204
|
+
Optional[float],
|
|
205
|
+
Field(
|
|
206
|
+
default=None,
|
|
207
|
+
),
|
|
208
|
+
]
|
|
209
|
+
year_to_date_max_growth: Annotated[
|
|
210
|
+
Optional[float],
|
|
211
|
+
Field(
|
|
212
|
+
default=None,
|
|
213
|
+
),
|
|
214
|
+
]
|
|
215
|
+
last_week_max_growth: Annotated[
|
|
216
|
+
Optional[float],
|
|
217
|
+
Field(
|
|
218
|
+
default=None,
|
|
219
|
+
),
|
|
220
|
+
]
|
|
221
|
+
last_month_max_growth: Annotated[
|
|
222
|
+
Optional[float],
|
|
223
|
+
Field(
|
|
224
|
+
default=None,
|
|
225
|
+
),
|
|
226
|
+
]
|
|
227
|
+
last_year_max_growth: Annotated[
|
|
228
|
+
Optional[float],
|
|
229
|
+
Field(
|
|
230
|
+
default=None,
|
|
231
|
+
),
|
|
232
|
+
]
|
|
233
|
+
macd_12_26_9_buy: Annotated[
|
|
234
|
+
Optional[float],
|
|
235
|
+
Field(
|
|
236
|
+
default=None,
|
|
237
|
+
),
|
|
238
|
+
]
|
|
239
|
+
star_yoy: Annotated[
|
|
240
|
+
Optional[float],
|
|
241
|
+
Field(
|
|
242
|
+
default=None,
|
|
243
|
+
),
|
|
244
|
+
]
|
|
245
|
+
star_wow: Annotated[
|
|
246
|
+
Optional[float],
|
|
247
|
+
Field(
|
|
248
|
+
default=None,
|
|
249
|
+
),
|
|
250
|
+
]
|
|
251
|
+
star_mom: Annotated[
|
|
252
|
+
Optional[float],
|
|
253
|
+
Field(
|
|
254
|
+
default=None,
|
|
255
|
+
),
|
|
256
|
+
]
|
|
257
|
+
|
|
258
|
+
@classmethod
|
|
259
|
+
def from_data(cls, prices: pd.DataFrame) -> "TechnicalAnalysis":
|
|
260
|
+
try:
|
|
261
|
+
last_index = prices.last_valid_index()
|
|
262
|
+
year_to_date_days = (
|
|
263
|
+
last_index
|
|
264
|
+
- pd.Timestamp(year=last_index.year, month=1, day=1, tz="UTC") # type: ignore
|
|
265
|
+
).days
|
|
266
|
+
year_to_date_growth = price_growth(prices, year_to_date_days)
|
|
267
|
+
last_52_weeks_growth = price_growth(prices=prices, days=399)
|
|
268
|
+
last_week_growth = price_growth(prices=prices, days=7)
|
|
269
|
+
last_month_growth = price_growth(prices=prices, days=31)
|
|
270
|
+
last_year_growth = price_growth(prices=prices, days=365)
|
|
271
|
+
year_to_date_max_growth = price_growth(prices, year_to_date_days, max=True)
|
|
272
|
+
last_week_max_growth = price_growth(prices=prices, days=7, max=True)
|
|
273
|
+
last_month_max_growth = price_growth(prices=prices, days=31, max=True)
|
|
274
|
+
last_year_max_growth = price_growth(prices=prices, days=365, max=True)
|
|
275
|
+
prices.ta.sma(50, append=True)
|
|
276
|
+
prices.ta.sma(200, append=True)
|
|
277
|
+
prices.ta.adx(append=True)
|
|
278
|
+
prices["SLOPE_14"] = ta.linreg(prices.close, slope=True, length=14)
|
|
279
|
+
prices["SLOPE_7"] = ta.linreg(prices.close, slope=True, length=7)
|
|
280
|
+
prices["SLOPE_30"] = ta.linreg(prices.close, slope=True, length=30)
|
|
281
|
+
prices["SLOPE_60"] = ta.linreg(prices.close, slope=True, length=60)
|
|
282
|
+
prices.ta.macd(append=True)
|
|
283
|
+
prices.ta.rsi(append=True)
|
|
284
|
+
|
|
285
|
+
rsi_last_value = prices.RSI_14.iloc[-1]
|
|
286
|
+
macd_12_26_9_buy_date = buy_opportunity(
|
|
287
|
+
prices.MACDs_12_26_9, prices.MACD_12_26_9
|
|
288
|
+
)
|
|
289
|
+
star_yoy = yoy(prices).median()
|
|
290
|
+
star_mom = mom(prices).median()
|
|
291
|
+
star_wow = wow(prices).median()
|
|
292
|
+
try:
|
|
293
|
+
macd_12_26_9_buy = (
|
|
294
|
+
prices.MACD_12_26_9.iloc[-1] > prices.MACDs_12_26_9.iloc[-1]
|
|
295
|
+
)
|
|
296
|
+
except Exception as e:
|
|
297
|
+
logger.warning(
|
|
298
|
+
f"Failing to calculate MACD buy date: {e}", exc_info=True
|
|
299
|
+
)
|
|
300
|
+
macd_12_26_9_buy = None
|
|
301
|
+
ma_50_200_buy_date = buy_opportunity(prices.SMA_200, prices.SMA_50)
|
|
302
|
+
return cls(
|
|
303
|
+
rsi_last_value=rsi_last_value,
|
|
304
|
+
macd_12_26_9_buy_date=macd_12_26_9_buy_date,
|
|
305
|
+
macd_12_26_9_buy=macd_12_26_9_buy,
|
|
306
|
+
ma_50_200_buy_date=ma_50_200_buy_date,
|
|
307
|
+
last_price=prices.close.iloc[-1],
|
|
308
|
+
last_price_date=prices.index[-1],
|
|
309
|
+
last_adx=prices.ADX_14.iloc[-1],
|
|
310
|
+
last_dmp=prices.DMP_14.iloc[-1],
|
|
311
|
+
last_dmn=prices.DMN_14.iloc[-1],
|
|
312
|
+
slope_7=prices.SLOPE_7.iloc[-1],
|
|
313
|
+
slope_14=prices.SLOPE_14.iloc[-1],
|
|
314
|
+
slope_30=prices.SLOPE_30.iloc[-1],
|
|
315
|
+
slope_60=prices.SLOPE_60.iloc[-1],
|
|
316
|
+
year_to_date_growth=year_to_date_growth,
|
|
317
|
+
last_52_weeks_growth=last_52_weeks_growth,
|
|
318
|
+
last_week_growth=last_week_growth,
|
|
319
|
+
last_month_growth=last_month_growth,
|
|
320
|
+
last_year_growth=last_year_growth,
|
|
321
|
+
year_to_date_max_growth=year_to_date_max_growth,
|
|
322
|
+
last_week_max_growth=last_week_max_growth,
|
|
323
|
+
last_month_max_growth=last_month_max_growth,
|
|
324
|
+
last_year_max_growth=last_year_max_growth,
|
|
325
|
+
star_yoy=star_yoy,
|
|
326
|
+
star_mom=star_mom,
|
|
327
|
+
star_wow=star_wow,
|
|
328
|
+
)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.error(f"Failing to calculate technical analysis: {e}", exc_info=True)
|
|
331
|
+
return cls() # type: ignore
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class BaseFundamentalAnalysis(BaseModel):
|
|
335
|
+
positive_free_cash_flow: Optional[float] = None
|
|
336
|
+
growing_operating_cash_flow: Optional[float] = None
|
|
337
|
+
operating_cash_flow_is_higher_than_net_income: Optional[float] = None
|
|
338
|
+
mean_capex_ratio: Optional[float] = None
|
|
339
|
+
max_capex_ratio: Optional[float] = None
|
|
340
|
+
min_capex_ratio: Optional[float] = None
|
|
341
|
+
mean_dividend_payout_ratio: Optional[float] = None
|
|
342
|
+
max_dividend_payout_ratio: Optional[float] = None
|
|
343
|
+
min_dividend_payout_ratio: Optional[float] = None
|
|
344
|
+
positive_net_income: Optional[float] = None
|
|
345
|
+
positive_operating_income: Optional[float] = None
|
|
346
|
+
growing_net_income: Optional[float] = None
|
|
347
|
+
growing_operating_income: Optional[float] = None
|
|
348
|
+
positive_diluted_eps: Optional[float] = None
|
|
349
|
+
positive_basic_eps: Optional[float] = None
|
|
350
|
+
growing_basic_eps: Optional[float] = None
|
|
351
|
+
growing_diluted_eps: Optional[float] = None
|
|
352
|
+
positive_debt_to_equity: Optional[float] = None
|
|
353
|
+
positive_return_on_assets: Optional[float] = None
|
|
354
|
+
positive_return_on_equity: Optional[float] = None
|
|
355
|
+
earning_per_share: Optional[float] = None
|
|
356
|
+
|
|
357
|
+
def is_empty(self) -> bool:
|
|
358
|
+
return all(getattr(self, field) is None for field in self.model_fields)
|
|
359
|
+
|
|
360
|
+
@classmethod
|
|
361
|
+
def from_financials(
|
|
362
|
+
cls, financials: "Financials", ticker: Ticker
|
|
363
|
+
) -> "BaseFundamentalAnalysis":
|
|
364
|
+
return cls._from_financials(
|
|
365
|
+
balance_sheets=financials.balance_sheets,
|
|
366
|
+
financial_metrics=financials.financial_metrics,
|
|
367
|
+
cash_flows=financials.cash_flows,
|
|
368
|
+
ticker=ticker,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
@classmethod
|
|
372
|
+
def _from_financials(
|
|
373
|
+
cls,
|
|
374
|
+
balance_sheets: List[BalanceSheet] | List[QuarterlyBalanceSheet],
|
|
375
|
+
financial_metrics: List[FinancialMetrics] | List[QuarterlyFinancialMetrics],
|
|
376
|
+
cash_flows: List[CashFlow] | List[QuarterlyCashFlow],
|
|
377
|
+
ticker: Ticker,
|
|
378
|
+
) -> "BaseFundamentalAnalysis":
|
|
379
|
+
try:
|
|
380
|
+
symbol = ticker.symbol
|
|
381
|
+
|
|
382
|
+
balance_sheet = _load_data(balance_sheets, symbol, BalanceSheet)
|
|
383
|
+
financial = _load_data(financial_metrics, symbol, FinancialMetrics)
|
|
384
|
+
cash_flow = _load_data(cash_flows, symbol, CashFlow)
|
|
385
|
+
|
|
386
|
+
# Debt-to-equity
|
|
387
|
+
debt_to_equity = (
|
|
388
|
+
balance_sheet.total_liabilities / balance_sheet.total_shareholder_equity
|
|
389
|
+
).dropna()
|
|
390
|
+
positive_debt_to_equity = _all_positive(debt_to_equity)
|
|
391
|
+
|
|
392
|
+
# Add relevant balance sheet data to financials
|
|
393
|
+
financial["total_shareholder_equity"] = balance_sheet[
|
|
394
|
+
"total_shareholder_equity"
|
|
395
|
+
]
|
|
396
|
+
financial["common_stock_shares_outstanding"] = balance_sheet[
|
|
397
|
+
"common_stock_shares_outstanding"
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
# EPS and income checks
|
|
401
|
+
earning_per_share = _get_last(
|
|
402
|
+
(
|
|
403
|
+
financial.net_income / financial.common_stock_shares_outstanding
|
|
404
|
+
).dropna()
|
|
405
|
+
)
|
|
406
|
+
positive_net_income = _all_positive(financial.net_income)
|
|
407
|
+
positive_operating_income = _all_positive(financial.operating_income)
|
|
408
|
+
growing_net_income = _compute_growth(financial.net_income)
|
|
409
|
+
growing_operating_income = _compute_growth(financial.operating_income)
|
|
410
|
+
positive_diluted_eps = _all_positive(financial.diluted_eps)
|
|
411
|
+
positive_basic_eps = _all_positive(financial.basic_eps)
|
|
412
|
+
growing_basic_eps = _compute_growth(financial.basic_eps)
|
|
413
|
+
growing_diluted_eps = _compute_growth(financial.diluted_eps)
|
|
414
|
+
|
|
415
|
+
# Profitability ratios
|
|
416
|
+
return_on_equity = (
|
|
417
|
+
financial.net_income * 100 / financial.total_shareholder_equity
|
|
418
|
+
).dropna()
|
|
419
|
+
return_on_assets = (
|
|
420
|
+
financial.net_income * 100 / balance_sheet.total_assets
|
|
421
|
+
).dropna()
|
|
422
|
+
positive_return_on_assets = _all_positive(return_on_assets)
|
|
423
|
+
positive_return_on_equity = _all_positive(return_on_equity)
|
|
424
|
+
# Cash flow analysis
|
|
425
|
+
cash_flow["net_income"] = financial["net_income"]
|
|
426
|
+
free_cash_flow = (
|
|
427
|
+
cash_flow["operating_cash_flow"] - cash_flow["capital_expenditure"]
|
|
428
|
+
)
|
|
429
|
+
positive_free_cash_flow = _all_positive(free_cash_flow)
|
|
430
|
+
growing_operating_cash_flow = _compute_growth(
|
|
431
|
+
cash_flow["operating_cash_flow"]
|
|
432
|
+
)
|
|
433
|
+
operating_income_net_income = cash_flow[
|
|
434
|
+
["operating_cash_flow", "net_income"]
|
|
435
|
+
].dropna()
|
|
436
|
+
operating_cash_flow_is_higher_than_net_income = all(
|
|
437
|
+
operating_income_net_income["operating_cash_flow"]
|
|
438
|
+
>= operating_income_net_income["net_income"]
|
|
439
|
+
)
|
|
440
|
+
cash_flow["capex_ratio"] = (
|
|
441
|
+
cash_flow["capital_expenditure"] / cash_flow["operating_cash_flow"]
|
|
442
|
+
).dropna()
|
|
443
|
+
mean_capex_ratio = cash_flow["capex_ratio"].mean()
|
|
444
|
+
max_capex_ratio = cash_flow["capex_ratio"].max()
|
|
445
|
+
min_capex_ratio = cash_flow["capex_ratio"].min()
|
|
446
|
+
dividend_payout_ratio = (
|
|
447
|
+
_abs(cash_flow["cash_dividends_paid"]) / free_cash_flow
|
|
448
|
+
).dropna()
|
|
449
|
+
mean_dividend_payout_ratio = dividend_payout_ratio.mean()
|
|
450
|
+
max_dividend_payout_ratio = dividend_payout_ratio.max()
|
|
451
|
+
min_dividend_payout_ratio = dividend_payout_ratio.min()
|
|
452
|
+
|
|
453
|
+
return cls(
|
|
454
|
+
earning_per_share=earning_per_share,
|
|
455
|
+
positive_debt_to_equity=positive_debt_to_equity,
|
|
456
|
+
positive_return_on_assets=positive_return_on_assets,
|
|
457
|
+
positive_return_on_equity=positive_return_on_equity,
|
|
458
|
+
growing_net_income=growing_net_income,
|
|
459
|
+
growing_operating_income=growing_operating_income,
|
|
460
|
+
positive_diluted_eps=positive_diluted_eps,
|
|
461
|
+
positive_basic_eps=positive_basic_eps,
|
|
462
|
+
growing_basic_eps=growing_basic_eps,
|
|
463
|
+
growing_diluted_eps=growing_diluted_eps,
|
|
464
|
+
positive_net_income=positive_net_income,
|
|
465
|
+
positive_operating_income=positive_operating_income,
|
|
466
|
+
positive_free_cash_flow=positive_free_cash_flow,
|
|
467
|
+
growing_operating_cash_flow=growing_operating_cash_flow,
|
|
468
|
+
operating_cash_flow_is_higher_than_net_income=operating_cash_flow_is_higher_than_net_income,
|
|
469
|
+
mean_capex_ratio=mean_capex_ratio,
|
|
470
|
+
max_capex_ratio=max_capex_ratio,
|
|
471
|
+
min_capex_ratio=min_capex_ratio,
|
|
472
|
+
mean_dividend_payout_ratio=mean_dividend_payout_ratio,
|
|
473
|
+
max_dividend_payout_ratio=max_dividend_payout_ratio,
|
|
474
|
+
min_dividend_payout_ratio=min_dividend_payout_ratio,
|
|
475
|
+
)
|
|
476
|
+
except Exception as e:
|
|
477
|
+
logger.error(
|
|
478
|
+
f"Failed to compute fundamental analysis for {ticker}: {e}",
|
|
479
|
+
exc_info=True,
|
|
480
|
+
)
|
|
481
|
+
return cls()
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class YearlyFundamentalAnalysis(BaseFundamentalAnalysis):
|
|
485
|
+
...
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
fields_with_prefix = {
|
|
489
|
+
f"{QUARTERLY}_{name}": (Optional[float], Field(default=None))
|
|
490
|
+
for name in BaseFundamentalAnalysis.model_fields
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
# Create the new model
|
|
494
|
+
BaseQuarterlyFundamentalAnalysis = create_model( # type: ignore
|
|
495
|
+
"BaseQuarterlyFundamentalAnalysis", **fields_with_prefix
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class QuarterlyFundamentalAnalysis(BaseQuarterlyFundamentalAnalysis): # type: ignore
|
|
500
|
+
@classmethod
|
|
501
|
+
def from_quarterly_financials(
|
|
502
|
+
cls, financials: "Financials", ticker: Ticker
|
|
503
|
+
) -> "QuarterlyFundamentalAnalysis":
|
|
504
|
+
base_financial_analisys = BaseFundamentalAnalysis._from_financials(
|
|
505
|
+
balance_sheets=financials.quarterly_balance_sheets,
|
|
506
|
+
financial_metrics=financials.quarterly_financial_metrics,
|
|
507
|
+
cash_flows=financials.quarterly_cash_flows,
|
|
508
|
+
ticker=ticker,
|
|
509
|
+
)
|
|
510
|
+
return cls.model_validate({f"{QUARTERLY}_{k}": v for k, v in base_financial_analisys.model_dump().items()}) # type: ignore # noqa: E501
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class FundamentalAnalysis(YearlyFundamentalAnalysis, QuarterlyFundamentalAnalysis):
|
|
514
|
+
@classmethod
|
|
515
|
+
def from_financials(
|
|
516
|
+
cls, financials: Financials, ticker: Ticker
|
|
517
|
+
) -> "FundamentalAnalysis":
|
|
518
|
+
yearly_analysis = YearlyFundamentalAnalysis.from_financials(
|
|
519
|
+
financials=financials, ticker=ticker
|
|
520
|
+
)
|
|
521
|
+
quarterly_analysis = QuarterlyFundamentalAnalysis.from_quarterly_financials(
|
|
522
|
+
financials=financials, ticker=ticker
|
|
523
|
+
)
|
|
524
|
+
return FundamentalAnalysis.model_validate(
|
|
525
|
+
yearly_analysis.model_dump() | quarterly_analysis.model_dump()
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class AnalysisView(BaseModel):
|
|
530
|
+
sector: Annotated[
|
|
531
|
+
Optional[str],
|
|
532
|
+
Field(
|
|
533
|
+
None,
|
|
534
|
+
description="Broad sector to which the company belongs, "
|
|
535
|
+
"such as 'Real Estate' or 'Technology'",
|
|
536
|
+
),
|
|
537
|
+
]
|
|
538
|
+
industry: Annotated[
|
|
539
|
+
Optional[str],
|
|
540
|
+
Field(
|
|
541
|
+
None,
|
|
542
|
+
description="Detailed industry categorization for the company, "
|
|
543
|
+
"like 'Real Estate Management & Development'",
|
|
544
|
+
),
|
|
545
|
+
]
|
|
546
|
+
market_capitalization: Annotated[
|
|
547
|
+
Optional[float],
|
|
548
|
+
BeforeValidator(to_float),
|
|
549
|
+
Field(
|
|
550
|
+
default=None,
|
|
551
|
+
description="Market capitalization value",
|
|
552
|
+
),
|
|
553
|
+
]
|
|
554
|
+
country: Annotated[
|
|
555
|
+
Optional[str],
|
|
556
|
+
Field(None, description="Country where the company's headquarters is located"),
|
|
557
|
+
]
|
|
558
|
+
symbol: str = Field(
|
|
559
|
+
description="Unique ticker symbol identifying the company on the stock exchange"
|
|
560
|
+
)
|
|
561
|
+
name: Annotated[
|
|
562
|
+
Optional[str],
|
|
563
|
+
Field(None, description="Full name of the company"),
|
|
564
|
+
]
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
class Analysis(AnalysisView, BaseEquity, TechnicalAnalysis, FundamentalAnalysis): # type: ignore
|
|
568
|
+
price_per_earning_ratio: Optional[float] = None
|
|
569
|
+
|
|
570
|
+
@classmethod
|
|
571
|
+
def from_ticker(cls, bearish_db: BearishDbBase, ticker: Ticker) -> "Analysis":
|
|
572
|
+
asset = bearish_db.read_assets(
|
|
573
|
+
AssetQuery(
|
|
574
|
+
symbols=Symbols(equities=[ticker]),
|
|
575
|
+
excluded_sources=get_args(TickerOnlySources),
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
equity = asset.get_one_equity()
|
|
579
|
+
financials = Financials.from_ticker(bearish_db, ticker)
|
|
580
|
+
fundamental_analysis = FundamentalAnalysis.from_financials(financials, ticker)
|
|
581
|
+
prices = Prices.from_ticker(bearish_db, ticker)
|
|
582
|
+
technical_analysis = TechnicalAnalysis.from_data(prices.to_dataframe())
|
|
583
|
+
return cls.model_validate(
|
|
584
|
+
equity.model_dump()
|
|
585
|
+
| fundamental_analysis.model_dump()
|
|
586
|
+
| technical_analysis.model_dump()
|
|
587
|
+
| {
|
|
588
|
+
"price_per_earning_ratio": (
|
|
589
|
+
(
|
|
590
|
+
technical_analysis.last_price
|
|
591
|
+
/ fundamental_analysis.earning_per_share
|
|
592
|
+
)
|
|
593
|
+
if technical_analysis.last_price is not None
|
|
594
|
+
and fundamental_analysis.earning_per_share != 0
|
|
595
|
+
and fundamental_analysis.earning_per_share is not None
|
|
596
|
+
else None
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def run_analysis(bullish_db: "BullishDb") -> None:
|
|
603
|
+
price_trackers = set(bullish_db._read_tracker(TrackerQuery(), PriceTracker))
|
|
604
|
+
finance_trackers = set(bullish_db._read_tracker(TrackerQuery(), FinancialsTracker))
|
|
605
|
+
tickers = list(price_trackers.intersection(finance_trackers))
|
|
606
|
+
for ticker in tickers:
|
|
607
|
+
analysis = Analysis.from_ticker(bullish_db, ticker)
|
|
608
|
+
bullish_db.write_analysis(analysis)
|