bullishpy 0.55.0__py3-none-any.whl → 0.75.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.
Potentially problematic release.
This version of bullishpy might be problematic. Click here for more details.
- bullish/analysis/analysis.py +28 -3
- bullish/analysis/constants.py +54 -0
- bullish/analysis/filter.py +3 -1
- bullish/analysis/functions.py +37 -2
- bullish/analysis/indicators.py +23 -2
- bullish/analysis/openai.py +91 -0
- bullish/analysis/predefined_filters.py +177 -109
- bullish/app/app.py +15 -4
- bullish/database/alembic/versions/65662e214031_.py +48 -0
- bullish/database/alembic/versions/660897c02c00_.py +36 -0
- bullish/database/alembic/versions/b36c310f49ec_.py +43 -0
- bullish/database/alembic/versions/c828e29e1105_.py +87 -0
- bullish/database/alembic/versions/cc28171c21a4_.py +43 -0
- bullish/database/crud.py +53 -4
- bullish/database/schemas.py +9 -0
- bullish/figures/figures.py +28 -18
- bullish/interface/interface.py +7 -1
- bullish/jobs/tasks.py +45 -10
- {bullishpy-0.55.0.dist-info → bullishpy-0.75.0.dist-info}/METADATA +8 -6
- {bullishpy-0.55.0.dist-info → bullishpy-0.75.0.dist-info}/RECORD +23 -17
- {bullishpy-0.55.0.dist-info → bullishpy-0.75.0.dist-info}/WHEEL +1 -1
- {bullishpy-0.55.0.dist-info → bullishpy-0.75.0.dist-info}/entry_points.txt +0 -0
- {bullishpy-0.55.0.dist-info → bullishpy-0.75.0.dist-info/licenses}/LICENSE +0 -0
bullish/analysis/analysis.py
CHANGED
|
@@ -497,6 +497,11 @@ class AnalysisView(BaseModel):
|
|
|
497
497
|
weekly_growth: Optional[float] = None
|
|
498
498
|
monthly_growth: Optional[float] = None
|
|
499
499
|
upside: Optional[float] = None
|
|
500
|
+
oai_high_price_target: Optional[float] = None
|
|
501
|
+
oai_low_price_target: Optional[float] = None
|
|
502
|
+
rsi: Optional[float] = None
|
|
503
|
+
oai_recommendation: Optional[str] = None
|
|
504
|
+
oai_moat: Optional[bool] = None
|
|
500
505
|
|
|
501
506
|
|
|
502
507
|
def json_loads(value: Any) -> Any:
|
|
@@ -527,11 +532,26 @@ class SubjectAnalysis(BaseModel):
|
|
|
527
532
|
] = None
|
|
528
533
|
summary: Annotated[Optional[Dict[str, Any]], BeforeValidator(json_loads)] = None
|
|
529
534
|
upside: Optional[float] = None
|
|
535
|
+
downside: Optional[float] = None
|
|
536
|
+
|
|
537
|
+
oai_high_price_target: Optional[float] = None
|
|
538
|
+
oai_low_price_target: Optional[float] = None
|
|
539
|
+
oai_news_date: Optional[datetime] = None
|
|
540
|
+
oai_recent_news: Optional[str] = None
|
|
541
|
+
oai_recommendation: Optional[str] = None
|
|
542
|
+
oai_explanation: Optional[str] = None
|
|
543
|
+
oai_moat: Optional[bool] = None
|
|
530
544
|
|
|
531
545
|
def compute_upside(self, last_price: float) -> None:
|
|
532
|
-
if self.
|
|
546
|
+
if self.oai_high_price_target is not None:
|
|
533
547
|
self.upside = (
|
|
534
|
-
(float(self.
|
|
548
|
+
(float(self.oai_high_price_target) - float(last_price))
|
|
549
|
+
* 100
|
|
550
|
+
/ float(last_price)
|
|
551
|
+
)
|
|
552
|
+
if self.oai_low_price_target is not None:
|
|
553
|
+
self.downside = (
|
|
554
|
+
(float(last_price) - float(self.oai_low_price_target))
|
|
535
555
|
* 100
|
|
536
556
|
/ float(last_price)
|
|
537
557
|
)
|
|
@@ -541,12 +561,17 @@ class SubjectAnalysis(BaseModel):
|
|
|
541
561
|
return None
|
|
542
562
|
return "".join(
|
|
543
563
|
[
|
|
544
|
-
f"<p>{
|
|
564
|
+
f"<p>{t.get('content').replace("\n","")}</p>" # type: ignore
|
|
545
565
|
for t in self.news_summary
|
|
546
566
|
if t.get("content")
|
|
547
567
|
]
|
|
548
568
|
)
|
|
549
569
|
|
|
570
|
+
def to_date(self) -> Optional[date]:
|
|
571
|
+
if self.news_date:
|
|
572
|
+
return self.news_date.date()
|
|
573
|
+
return None
|
|
574
|
+
|
|
550
575
|
|
|
551
576
|
class Analysis(SubjectAnalysis, AnalysisEarningsDate, AnalysisView, BaseEquity, TechnicalAnalysis, FundamentalAnalysis): # type: ignore
|
|
552
577
|
|
bullish/analysis/constants.py
CHANGED
|
@@ -400,3 +400,57 @@ Country = Literal[
|
|
|
400
400
|
"Liberia",
|
|
401
401
|
"Kenya",
|
|
402
402
|
]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
HighGrowthIndustry = Literal[
|
|
406
|
+
"Software - Infrastructure",
|
|
407
|
+
"Software - Application",
|
|
408
|
+
"Internet Retail",
|
|
409
|
+
"Internet Content & Information",
|
|
410
|
+
"Electronic Gaming & Multimedia",
|
|
411
|
+
"Semiconductors",
|
|
412
|
+
"Semiconductor Equipment & Materials",
|
|
413
|
+
"Information Technology Services",
|
|
414
|
+
"Communication Equipment",
|
|
415
|
+
"Consumer Electronics",
|
|
416
|
+
"Health Information Services",
|
|
417
|
+
"Biotechnology",
|
|
418
|
+
"Medical Devices",
|
|
419
|
+
"Diagnostics & Research",
|
|
420
|
+
"Medical Instruments & Supplies",
|
|
421
|
+
"Drug Manufacturers - Specialty & Generic",
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
DefensiveIndustries = Literal[
|
|
425
|
+
"Utilities - Independent Power Producers",
|
|
426
|
+
"Utilities - Diversified",
|
|
427
|
+
"Utilities - Renewable",
|
|
428
|
+
"Utilities - Regulated Gas",
|
|
429
|
+
"Utilities - Regulated Water",
|
|
430
|
+
"Utilities - Regulated Electric",
|
|
431
|
+
"Household & Personal Products",
|
|
432
|
+
"Food Distribution",
|
|
433
|
+
"Packaged Foods",
|
|
434
|
+
"Grocery Stores",
|
|
435
|
+
"Beverages - Non - Alcoholic",
|
|
436
|
+
"Beverages - Brewers",
|
|
437
|
+
"Confectioners",
|
|
438
|
+
"Tobacco",
|
|
439
|
+
"Paper & Paper Products",
|
|
440
|
+
"Medical Devices",
|
|
441
|
+
"Drug Manufacturers - Specialty & Generic",
|
|
442
|
+
"Medical Instruments & Supplies",
|
|
443
|
+
"Medical Distribution",
|
|
444
|
+
"Medical Care Facilities",
|
|
445
|
+
"Drug Manufacturers - General",
|
|
446
|
+
"Healthcare Plans",
|
|
447
|
+
"Pharmaceutical Retailers",
|
|
448
|
+
"Waste Management",
|
|
449
|
+
"Pollution & Treatment Controls",
|
|
450
|
+
"Insurance Brokers",
|
|
451
|
+
"Insurance - Property & Casualty",
|
|
452
|
+
"Insurance - Specialty",
|
|
453
|
+
"Insurance - Reinsurance",
|
|
454
|
+
"Insurance - Diversified",
|
|
455
|
+
"Insurance - Life",
|
|
456
|
+
]
|
bullish/analysis/filter.py
CHANGED
|
@@ -191,7 +191,9 @@ class FilterQuery(GeneralFilter, *TechnicalAnalysisFilters, *FundamentalAnalysis
|
|
|
191
191
|
)
|
|
192
192
|
|
|
193
193
|
def to_query(self) -> str: # noqa: C901
|
|
194
|
-
parameters = self.model_dump(
|
|
194
|
+
parameters = self.model_dump(
|
|
195
|
+
exclude_defaults=True, exclude_unset=True, exclude={"name"}
|
|
196
|
+
)
|
|
195
197
|
query = []
|
|
196
198
|
order_by_desc = ""
|
|
197
199
|
order_by_asc = ""
|
bullish/analysis/functions.py
CHANGED
|
@@ -4,7 +4,7 @@ from typing import Optional, Callable, cast
|
|
|
4
4
|
|
|
5
5
|
import numpy as np
|
|
6
6
|
import pandas as pd
|
|
7
|
-
import pandas_ta as ta
|
|
7
|
+
import pandas_ta as ta
|
|
8
8
|
|
|
9
9
|
from pydantic import BaseModel
|
|
10
10
|
|
|
@@ -18,7 +18,7 @@ except Exception:
|
|
|
18
18
|
def cross_simple(
|
|
19
19
|
series_a: pd.Series, series_b: pd.Series, above: bool = True
|
|
20
20
|
) -> pd.Series:
|
|
21
|
-
crossing = ta.cross(
|
|
21
|
+
crossing = ta.cross(x=series_a, y=series_b, above=above) # type: ignore
|
|
22
22
|
return crossing # type: ignore
|
|
23
23
|
|
|
24
24
|
|
|
@@ -434,3 +434,38 @@ def add_indicators(data: pd.DataFrame) -> pd.DataFrame:
|
|
|
434
434
|
f"Expected columns {expected_columns} not found in data columns {data.columns.tolist()}"
|
|
435
435
|
)
|
|
436
436
|
return data
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class Line(BaseModel):
|
|
440
|
+
value: float
|
|
441
|
+
previous: float
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class SupportResistance(BaseModel):
|
|
445
|
+
support: Line
|
|
446
|
+
resistance: Line
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def support_resistance(df: pd.DataFrame, window: int = 5) -> SupportResistance:
|
|
450
|
+
|
|
451
|
+
w = window * 2 + 1
|
|
452
|
+
highs = df.high.rolling(w, center=True).max()
|
|
453
|
+
lows = df.low.rolling(w, center=True).min()
|
|
454
|
+
swing_high_mask = df.high == highs
|
|
455
|
+
swing_low_mask = df.low == lows
|
|
456
|
+
|
|
457
|
+
raw_res = df.loc[swing_high_mask, "high"].to_numpy()
|
|
458
|
+
raw_sup = df.loc[swing_low_mask, "low"].to_numpy()
|
|
459
|
+
return SupportResistance(
|
|
460
|
+
support=Line(value=float(raw_sup[-1]), previous=float(raw_sup[-2])),
|
|
461
|
+
resistance=Line(value=float(raw_res[-1]), previous=float(raw_res[-2])),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def bollinger_bands(
|
|
466
|
+
data: pd.DataFrame, window: int = 20, std_dev: float = 2.0
|
|
467
|
+
) -> pd.DataFrame:
|
|
468
|
+
bbands = ta.bbands(
|
|
469
|
+
data.close, timeperiod=window, nbdevup=std_dev, nbdevdn=std_dev, matype=0 # type: ignore
|
|
470
|
+
)
|
|
471
|
+
return bbands
|
bullish/analysis/indicators.py
CHANGED
|
@@ -175,6 +175,20 @@ def indicators_factory() -> List[Indicator]:
|
|
|
175
175
|
type=Optional[date],
|
|
176
176
|
function=lambda d: (d.ADX_14 > 20) & (d.MINUS_DI > d.PLUS_DI),
|
|
177
177
|
),
|
|
178
|
+
Signal(
|
|
179
|
+
name="ADX_14",
|
|
180
|
+
description="ADX 14",
|
|
181
|
+
type_info="Short",
|
|
182
|
+
type=Optional[date],
|
|
183
|
+
function=lambda d: (d.ADX_14 > 25),
|
|
184
|
+
),
|
|
185
|
+
Signal(
|
|
186
|
+
name="ADX_14_OVERBOUGHT",
|
|
187
|
+
description="ADX 14 OVERBOUGHT",
|
|
188
|
+
type_info="Short",
|
|
189
|
+
type=Optional[date],
|
|
190
|
+
function=lambda d: (d.ADX_14 > 50),
|
|
191
|
+
),
|
|
178
192
|
],
|
|
179
193
|
),
|
|
180
194
|
Indicator(
|
|
@@ -266,7 +280,7 @@ def indicators_factory() -> List[Indicator]:
|
|
|
266
280
|
description="RSI Oversold Signal",
|
|
267
281
|
type_info="Oversold",
|
|
268
282
|
type=Optional[date],
|
|
269
|
-
function=lambda d: (d.RSI
|
|
283
|
+
function=lambda d: (d.RSI <= 30) & (d.RSI > 0),
|
|
270
284
|
in_use_backtest=True,
|
|
271
285
|
),
|
|
272
286
|
Signal(
|
|
@@ -281,7 +295,14 @@ def indicators_factory() -> List[Indicator]:
|
|
|
281
295
|
description="RSI Neutral Signal",
|
|
282
296
|
type_info="Overbought",
|
|
283
297
|
type=Optional[date],
|
|
284
|
-
function=lambda d: (d.RSI < 60) & (d.RSI >
|
|
298
|
+
function=lambda d: (d.RSI < 60) & (d.RSI > 30),
|
|
299
|
+
),
|
|
300
|
+
Signal(
|
|
301
|
+
name="RSI",
|
|
302
|
+
description="RSI value",
|
|
303
|
+
type_info="Overbought",
|
|
304
|
+
type=Optional[float],
|
|
305
|
+
function=lambda d: d.RSI,
|
|
285
306
|
),
|
|
286
307
|
],
|
|
287
308
|
),
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from datetime import date
|
|
5
|
+
from typing import Optional, List, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
from openai import OpenAI
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from bullish.database.crud import BullishDb
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def prompt(ticker: str) -> str:
|
|
17
|
+
return f"""
|
|
18
|
+
You are a financial analysis assistant.
|
|
19
|
+
|
|
20
|
+
Using the latest reliable public data from the web — including analyst price targets from multiple reputable
|
|
21
|
+
sources — analyze the stock ticker {ticker}.
|
|
22
|
+
|
|
23
|
+
Return ONLY valid JSON matching EXACTLY the schema below — no explanations, no preamble, no markdown, no code
|
|
24
|
+
fences, no extra text:
|
|
25
|
+
|
|
26
|
+
{{
|
|
27
|
+
"high_price_target": float, // Analyst consensus high price target in USD (based on multiple sources)
|
|
28
|
+
"low_price_target": float, // Analyst consensus low price target in USD (based on multiple sources)
|
|
29
|
+
"recent_news": str, // Detailed, multi-sentence summary of recent news affecting the company;
|
|
30
|
+
include credible source names inline
|
|
31
|
+
"recommendation": str, // One of: "Strong Buy", "Buy", "Hold", "Sell", "Strong Sell"
|
|
32
|
+
"explanation": str // Concise explanation for the recommendation above, covering key pros/cons
|
|
33
|
+
for investors
|
|
34
|
+
"moat": bool // Give as a boolean true or false if the company has a strong economic moat
|
|
35
|
+
}}
|
|
36
|
+
|
|
37
|
+
Formatting rules:
|
|
38
|
+
- Output must be a single valid JSON object with no surrounding text or formatting.
|
|
39
|
+
- Use plain numbers for high_price_target and low_price_target (no currency symbols, no commas).
|
|
40
|
+
- All text fields must be professional, investor-oriented, and reference credible named sources in `recent_news`.
|
|
41
|
+
- If exact data is unavailable, estimate based on web search results and note uncertainty in the relevant field.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OpenAINews(BaseModel):
|
|
46
|
+
symbol: str
|
|
47
|
+
news_date: date = Field(default_factory=date.today)
|
|
48
|
+
high_price_target: Optional[float] = None
|
|
49
|
+
low_price_target: Optional[float] = None
|
|
50
|
+
recent_news: Optional[str] = None
|
|
51
|
+
recommendation: Optional[str] = None
|
|
52
|
+
explanation: Optional[str] = None
|
|
53
|
+
moat: Optional[bool] = None
|
|
54
|
+
|
|
55
|
+
def valid(self) -> bool:
|
|
56
|
+
return bool(
|
|
57
|
+
self.model_dump(
|
|
58
|
+
exclude_none=True,
|
|
59
|
+
exclude_unset=True,
|
|
60
|
+
exclude_defaults=True,
|
|
61
|
+
exclude={"symbol"},
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_ticker(cls, ticker: str) -> "OpenAINews":
|
|
67
|
+
if "OPENAI_API_KEY" not in os.environ:
|
|
68
|
+
return cls(symbol=ticker)
|
|
69
|
+
print(f"Fetching OpenAI news for {ticker}...")
|
|
70
|
+
client = OpenAI()
|
|
71
|
+
resp = client.responses.create(
|
|
72
|
+
model="gpt-4o", input=prompt(ticker), tools=[{"type": "web_search"}] # type: ignore
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
return cls.model_validate(json.loads(resp.output_text) | {"symbol": ticker})
|
|
76
|
+
except Exception as e:
|
|
77
|
+
logger.error(f"Failed to parse OpenAI response for {ticker}: {e}")
|
|
78
|
+
return cls(symbol=ticker)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_tickers(cls, tickers: List[str]) -> List["OpenAINews"]:
|
|
82
|
+
return [cls.from_ticker(t) for t in tickers]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_open_ai_news(bullish_db: "BullishDb", tickers: List[str]) -> bool:
|
|
86
|
+
news = OpenAINews.from_tickers(tickers)
|
|
87
|
+
valid_news = [n for n in news if n.valid()]
|
|
88
|
+
if valid_news:
|
|
89
|
+
bullish_db.write_many_openai_news(valid_news)
|
|
90
|
+
return True
|
|
91
|
+
return False
|