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.

@@ -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.high_price_target is not None:
546
+ if self.oai_high_price_target is not None:
533
547
  self.upside = (
534
- (float(self.high_price_target) - float(last_price))
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>{scrub(t.get('content').replace("\n",""))}</p>" # type: ignore
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
 
@@ -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
+ ]
@@ -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(exclude_defaults=True, exclude_unset=True)
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 = ""
@@ -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 # type: ignore
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(series_a=series_a, series_b=series_b, above=above)
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
@@ -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 < 30) & (d.RSI > 0),
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 > 40),
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