JayLacoma commited on
Commit
3863d58
·
verified ·
1 Parent(s): 9f6acf8

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +386 -300
app.py CHANGED
@@ -8,348 +8,434 @@ from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
8
  import plotly.graph_objects as go
9
  from plotly.subplots import make_subplots
10
  import yfinance as yf
11
- import warnings
12
  from prophet import Prophet
13
  import plotly.express as px
 
 
 
14
 
 
15
  warnings.filterwarnings('ignore')
16
 
17
  # ============================================================================
18
- # CONFIGURATION (FIXED DATES)
19
  # ============================================================================
 
 
 
 
 
 
 
 
20
 
21
- TECH_START = "2022-01-01"
22
- TECH_END = "2026-01-01"
23
- SENTIMENT_DAYS = 90 # Always last 90 days
 
 
 
 
 
24
 
25
- class Config:
26
- FINNHUB_API_KEY = "cuj17q1r01qm7p9n307gcuj17q1r01qm7p9n3080" # Replace with your key
27
- DATA_DIR = "data"
28
-
29
  @classmethod
30
  def initialize(cls):
 
31
  os.makedirs(cls.DATA_DIR, exist_ok=True)
32
 
33
  Config.initialize()
34
 
35
  # ============================================================================
36
- # SENTIMENT ANALYSIS (90 DAYS ONLY)
37
  # ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
- class SentimentAnalyzer:
40
- def __init__(self):
41
- self.analyzer = SentimentIntensityAnalyzer()
42
-
43
- def analyze(self, text):
44
- if not isinstance(text, str) or not text.strip():
45
- return 0
46
- return self.analyzer.polarity_scores(text)['compound']
47
-
48
- class StockNewsAnalyzer:
49
- def __init__(self, symbol):
50
- self.symbol = symbol
51
- self.sentiment_analyzer = SentimentAnalyzer()
52
-
53
- def get_file_path(self, file_type):
54
- return os.path.join(Config.DATA_DIR, f"{self.symbol}_{file_type}.csv")
55
-
56
- def get_news(self, force_refresh=False):
57
- file_path = self.get_file_path("news")
58
- if os.path.exists(file_path) and not force_refresh:
59
- try:
60
- return pd.read_csv(file_path, parse_dates=['datetime'])
61
- except Exception:
62
- pass
63
-
64
- end_date = datetime.now()
65
- start_date = end_date - timedelta(days=SENTIMENT_DAYS)
66
-
67
- url = "https://finnhub.io/api/v1/company-news" # FIXED: no trailing spaces
68
- params = {
69
- "symbol": self.symbol,
70
- "from": start_date.strftime('%Y-%m-%d'),
71
- "to": end_date.strftime('%Y-%m-%d'),
72
- "token": Config.FINNHUB_API_KEY,
73
- }
74
-
75
  try:
76
- response = requests.get(url, params=params, timeout=10)
77
- data = response.json()
78
- if not data or not isinstance(data, list):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  return pd.DataFrame()
80
- df = pd.DataFrame(data)
81
- if 'datetime' in df.columns:
82
- df['datetime'] = pd.to_datetime(df['datetime'], unit='s')
83
- df.to_csv(file_path, index=False)
84
- return df
85
- return pd.DataFrame()
86
- except Exception as e:
87
- print(f"Error fetching news: {e}")
88
- return pd.DataFrame()
89
-
90
- def get_sentiment_data(self):
91
- news_df = self.get_news()
92
- if news_df.empty or 'headline' not in news_df.columns:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  return None, None
94
-
95
- news_df['sentiment_score'] = news_df['headline'].apply(self.sentiment_analyzer.analyze)
 
 
 
 
96
  news_df['date'] = pd.to_datetime(news_df['datetime'].dt.date)
97
-
98
- daily = news_df.groupby('date').agg(
99
- avg_sentiment=('sentiment_score', 'mean'),
100
- article_count=('sentiment_score', 'count'),
101
- positive_count=('sentiment_score', lambda x: sum(x > 0.05)),
102
- negative_count=('sentiment_score', lambda x: sum(x < -0.05)),
103
- neutral_count=('sentiment_score', lambda x: sum((x >= -0.05) & (x <= 0.05)))
104
  ).reset_index()
105
-
106
- return daily, news_df
107
 
108
- # ============================================================================
109
- # TECHNICAL INDICATORS (ULTRA-STRICT, FIXED DATES)
110
- # ============================================================================
111
 
112
- def calculate_rsi(df):
113
- delta = df['Close'].diff()
114
- gain = (delta.where(delta > 0, 0)).rolling(14).mean()
115
- loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
116
- rs = gain / loss
117
- return 100 - (100 / (1 + rs))
118
-
119
- def calculate_bollinger_bands(df):
120
- ma = df['Close'].rolling(20).mean()
121
- std = df['Close'].rolling(20).std()
122
- return ma, ma + 2*std, ma - 2*std
123
-
124
- def calculate_stochastic_oscillator(df):
125
- ll = df['Low'].rolling(14).min()
126
- hh = df['High'].rolling(14).max()
127
- k = ((df['Close'] - ll) / (hh - ll)) * 100
128
- d = k.rolling(3).mean()
129
- return k, d
130
-
131
- def calculate_cmf(df, window=20):
132
- mfv = ((df['Close'] - df['Low']) - (df['High'] - df['Close'])) / (df['High'] - df['Low']) * df['Volume']
133
- return mfv.rolling(window).sum() / df['Volume'].rolling(window).sum()
134
-
135
- def generate_signals(df):
136
- df['RSI'] = calculate_rsi(df)
137
- df['MiddleBB'], df['UpperBB'], df['LowerBB'] = calculate_bollinger_bands(df)
138
- df['SlowK'], df['SlowD'] = calculate_stochastic_oscillator(df)
139
- df['CMF'] = calculate_cmf(df)
140
-
141
- # Ultra-strict thresholds
142
- df['RSI_Signal'] = np.where(df['RSI'] < 15, 1, np.where(df['RSI'] > 90, -1, 0))
143
- df['BB_Signal'] = np.where(df['Close'] < df['LowerBB'] * 0.97, 1, np.where(df['Close'] > df['UpperBB'] * 1.03, -1, 0))
144
- df['Stochastic_Signal'] = np.where((df['SlowK'] < 10) & (df['SlowD'] < 10), 1, np.where((df['SlowK'] > 95) & (df['SlowD'] > 95), -1, 0))
145
- df['CMF_Signal'] = np.where(df['CMF'] < -0.4, 1, np.where(df['CMF'] > 0.4, -1, 0))
146
-
147
- df['Technical_Score'] = df[['RSI_Signal', 'BB_Signal', 'Stochastic_Signal', 'CMF_Signal']].sum(axis=1)
148
- return df
149
 
150
- # ============================================================================
151
- # FORECASTING (PROPHET, FIXED DATES)
152
- # ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
 
154
- def prophet_forecast(df):
155
- try:
156
- prophet_df = df.reset_index()[['Date', 'Close']].rename(columns={'Date': 'ds', 'Close': 'y'})
157
- prophet_df['ds'] = pd.to_datetime(prophet_df['ds'])
158
- model = Prophet()
159
- model.fit(prophet_df)
160
- future = model.make_future_dataframe(periods=30)
161
- forecast = model.predict(future)
162
- current = prophet_df['y'].iloc[-1]
163
- future_price = forecast['yhat'].iloc[-1]
164
- pct_change = ((future_price - current) / current) * 100
165
- return pct_change, future_price, forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']]
166
- except Exception as e:
167
- print(f"Forecast error: {e}")
168
- return 0, 0, None
 
 
 
 
 
 
 
 
169
 
170
  # ============================================================================
171
- # VISUALIZATIONS
172
  # ============================================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
174
- def create_multi_ticker_plot(data_dict, show_bollinger, time_range):
175
- fig = go.Figure()
176
- COLORS = px.colors.qualitative.Plotly
177
- all_dates = pd.concat([df.index.to_series() for df in data_dict.values()], ignore_index=True)
178
- if all_dates.empty:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  return fig
180
- end = all_dates.max()
181
- start = {
182
- "1M": end - pd.DateOffset(months=1),
183
- "3M": end - pd.DateOffset(months=3),
184
- "6M": end - pd.DateOffset(months=6),
185
- "1Y": end - pd.DateOffset(years=1),
186
- "YTD": pd.to_datetime(f"{end.year}-01-01"),
187
- "All": all_dates.min()
188
- }[time_range]
189
-
190
- buy_points, sell_points = [], []
191
- for i, (ticker, df) in enumerate(data_dict.items()):
192
- df_plot = df[df.index >= start]
193
- if df_plot.empty:
194
- continue
195
- color = COLORS[i % len(COLORS)]
196
- fig.add_trace(go.Scatter(x=df_plot.index, y=df_plot['Close'], mode='lines', line=dict(color=color, width=1.8), name=ticker))
197
- if show_bollinger:
198
- fig.add_trace(go.Scatter(x=df_plot.index, y=df_plot['UpperBB'], mode='lines', line=dict(color='rgba(150,150,150,0.4)', width=1, dash='dot'), showlegend=False, hoverinfo='skip'))
199
- fig.add_trace(go.Scatter(x=df_plot.index, y=df_plot['LowerBB'], mode='lines', line=dict(color='rgba(150,150,150,0.4)', width=1, dash='dot'), fill='tonexty', fillcolor='rgba(150,150,150,0.05)', showlegend=False, hoverinfo='skip'))
200
- for date in df_plot.index:
201
- signals = [('RSI', df_plot.loc[date, 'RSI_Signal']), ('BB', df_plot.loc[date, 'BB_Signal']), ('Stochastic', df_plot.loc[date, 'Stochastic_Signal']), ('CMF', df_plot.loc[date, 'CMF_Signal'])]
202
- total = sum(sig for _, sig in signals)
203
- price = df_plot.loc[date, 'Close']
204
- if total > 0:
205
- active = [name for name, sig in signals if sig == 1]
206
- hover = f"<b>{ticker}</b><br>Buy: {', '.join(active)}<br>{date.strftime('%Y-%m-%d')}<br>${price:.2f}"
207
- buy_points.append((date, price * 0.997, hover))
208
- elif total < 0:
209
- active = [name for name, sig in signals if sig == -1]
210
- hover = f"<b>{ticker}</b><br>Sell: {', '.join(active)}<br>{date.strftime('%Y-%m-%d')}<br>${price:.2f}"
211
- sell_points.append((date, price * 1.003, hover))
212
-
213
- if buy_points:
214
- x, y, text = zip(*buy_points)
215
- fig.add_trace(go.Scatter(x=x, y=y, mode='markers', marker=dict(symbol='triangle-up', size=9, color='white', line=dict(color='black', width=0.8)), hovertext=text, hoverinfo='text', showlegend=False))
216
- if sell_points:
217
- x, y, text = zip(*sell_points)
218
- fig.add_trace(go.Scatter(x=x, y=y, mode='markers', marker=dict(symbol='triangle-down', size=9, color='black', line=dict(color='white', width=0.8)), hovertext=text, hoverinfo='text', showlegend=False))
219
-
220
- fig.update_layout(
221
- plot_bgcolor='black', paper_bgcolor='black', font=dict(color='white'),
222
- xaxis=dict(showgrid=False, zeroline=False, showline=False, ticks=''),
223
- yaxis=dict(showgrid=False, zeroline=False, showline=False, ticks='', tickprefix='$'),
224
- legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='center', x=0.5, bgcolor='rgba(0,0,0,0.6)'),
225
- margin=dict(l=20, r=20, t=30, b=30), height=700, width=1100, hovermode='x unified'
226
- )
227
- return fig
228
-
229
- def create_sentiment_plot(sentiment_daily, stock_data, symbol):
230
- if sentiment_daily is None:
231
- return None
232
- fig = make_subplots(rows=2, cols=1, specs=[[{"secondary_y": True}], [{}]], row_heights=[0.7, 0.3])
233
- if not stock_data.empty:
234
- fig.add_trace(go.Scatter(x=stock_data.index, y=stock_data['Close'], name='Price', line=dict(color='#1f77b4')), row=1, col=1, secondary_y=False)
235
- fig.add_trace(go.Scatter(x=sentiment_daily['date'], y=sentiment_daily['avg_sentiment'], name='Sentiment', line=dict(color='#ff7f0e')), row=1, col=1, secondary_y=True)
236
- fig.add_trace(go.Bar(x=sentiment_daily['date'], y=sentiment_daily['article_count'], name='Articles', marker_color='rgba(135,206,235,0.5)'), row=2, col=1)
237
- fig.add_trace(go.Bar(x=sentiment_daily['date'], y=sentiment_daily['positive_count'], name='Positive', marker_color='rgba(0,128,0,0.7)'), row=2, col=1)
238
- fig.add_trace(go.Bar(x=sentiment_daily['date'], y=sentiment_daily['negative_count'], name='Negative', marker_color='rgba(255,0,0,0.7)'), row=2, col=1)
239
- fig.add_trace(go.Bar(x=sentiment_daily['date'], y=sentiment_daily['neutral_count'], name='Neutral', marker_color='rgba(128,128,128,0.7)'), row=2, col=1)
240
- fig.update_layout(title=f"{symbol} News Sentiment (Last {SENTIMENT_DAYS} Days)", template='plotly_white', barmode='stack', height=600)
241
- return fig
242
-
243
- def create_decision_gauge(decision, total_score):
244
- colors = {'STRONG BUY': '#00FF00', 'BUY': '#90EE90', 'HOLD': '#FFD700', 'SELL': '#FFA500', 'STRONG SELL': '#FF0000'}
245
- fig = go.Figure(go.Indicator(
246
- mode="gauge+number", value=total_score,
247
- title={'text': f"{decision}", 'font': {'size': 24}},
248
- gauge={'axis': {'range': [-6, 6]}, 'bar': {'color': colors.get(decision, '#FFD700')},
249
- 'steps': [{'range': [-6, -2], 'color': 'red'}, {'range': [-2, 2], 'color': 'gray'}, {'range': [2, 6], 'color': 'green'}]}
250
- ))
251
- fig.update_layout(paper_bgcolor='black', plot_bgcolor='black', font=dict(color='white', size=16), height=300)
252
- return fig
253
 
254
  # ============================================================================
255
- # MAIN ANALYSIS FUNCTION
256
  # ============================================================================
 
 
 
 
 
257
 
258
- def run_analysis(tickers_str, show_bollinger, time_range, refresh_sentiment):
259
- try:
260
- tickers = [t.strip().upper() for t in tickers_str.split(',') if t.strip()][:8]
261
- if not tickers:
262
- return "Enter at least one ticker", None, None, None, None, "No ticker provided"
263
-
264
- # === TECHNICAL ANALYSIS (FIXED DATES) ===
265
- data_dict = {}
266
- for t in tickers:
267
- df = yf.download(t, start=TECH_START, end=TECH_END)
268
- if not df.empty:
269
- if isinstance(df.columns, pd.MultiIndex):
270
- df.columns = df.columns.droplevel(1)
271
- data_dict[t] = generate_signals(df)
272
-
273
- if not data_dict:
274
- return "No technical data found", None, None, None, None, "No data"
275
-
276
- # === SENTIMENT & FORECAST (FIRST TICKER ONLY) ===
277
- first_ticker = tickers[0]
278
- news_analyzer = StockNewsAnalyzer(first_ticker)
279
- sentiment_daily, _ = news_analyzer.get_sentiment_data()
280
-
281
- # Get stock data for sentiment plot (last 90 days)
282
- end_now = datetime.now()
283
- start_90 = end_now - timedelta(days=90)
284
- stock_90d = yf.download(first_ticker, start=start_90, end=end_now)
285
- if isinstance(stock_90d.columns, pd.MultiIndex):
286
- stock_90d.columns = stock_90d.columns.droplevel(1)
287
-
288
- # Forecast
289
- tech_df = data_dict[first_ticker]
290
- forecast_change, forecast_price, _ = prophet_forecast(tech_df)
291
-
292
- # Scoring
293
- current_technical = tech_df['Technical_Score'].iloc[-1]
294
- avg_sentiment = sentiment_daily['avg_sentiment'].mean() if sentiment_daily is not None else 0
295
- scores = {
296
- 'technical': 2 if current_technical >= 3 else 1 if current_technical >= 1 else -1 if current_technical <= -1 else -2 if current_technical <= -3 else 0,
297
- 'sentiment': 2 if avg_sentiment > 0.3 else 1 if avg_sentiment > 0.1 else -1 if avg_sentiment < -0.1 else -2 if avg_sentiment < -0.3 else 0,
298
- 'forecast': 2 if forecast_change > 8 else 1 if forecast_change > 3 else -1 if forecast_change < -3 else -2 if forecast_change < -8 else 0
299
- }
300
- total_score = sum(scores.values())
301
- decision = "STRONG BUY" if total_score >= 4 else "BUY" if total_score >= 2 else "SELL" if total_score <= -2 else "STRONG SELL" if total_score <= -4 else "HOLD"
302
-
303
- # === PLOTS ===
304
- technical_plot = create_multi_ticker_plot(data_dict, show_bollinger, time_range)
305
- sentiment_plot = create_sentiment_plot(sentiment_daily, stock_90d, first_ticker) if sentiment_daily is not None else None
306
- decision_gauge = create_decision_gauge(decision, total_score)
307
-
308
- summary = f"""
309
- ## 🎯 Decision: **{decision}**
310
- - **Ticker**: {first_ticker}
311
- - **Current Price**: ${tech_df['Close'].iloc[-1]:.2f}
312
- - **Total Score**: {total_score}/6
313
- - **Sentiment**: {avg_sentiment:.2f} ({sentiment_daily['article_count'].sum() if sentiment_daily is not None else 0} articles)
314
- - **Forecast**: {forecast_change:.1f}% → ${forecast_price:.2f}
315
  """
316
-
317
- return summary, technical_plot, sentiment_plot, decision_gauge, None, "Success"
318
-
319
- except Exception as e:
320
- return f"Error: {str(e)}", None, None, None, None, "Failed"
321
 
322
- # ============================================================================
323
- # GRADIO INTERFACE
324
- # ============================================================================
325
 
326
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  gr.Markdown("# 📊 Unified Stock Intelligence Dashboard")
328
- gr.Markdown(f"**Technical Data**: {TECH_START} to {TECH_END} | **Sentiment**: Last {SENTIMENT_DAYS} days")
329
-
330
- with gr.Row():
331
- with gr.Column():
332
- tickers_input = gr.Textbox(label="Tickers (comma-separated, max 8)", value="NVDA, AAPL, MSFT")
333
- with gr.Row():
334
- show_bb = gr.Checkbox(label="Show Bollinger Bands", value=False)
335
- time_range = gr.Radio(choices=["1M", "3M", "6M", "1Y", "YTD", "All"], value="1Y", label="Time Range")
336
- refresh_sentiment = gr.Checkbox(label="Refresh News Data", value=False)
337
- analyze_btn = gr.Button("Analyze", variant="primary")
338
-
339
- status = gr.Textbox(label="Status", interactive=False)
340
- summary = gr.Markdown()
341
-
342
  with gr.Row():
343
- decision_gauge = gr.Plot(label="Decision")
344
- sentiment_plot = gr.Plot(label="Sentiment Analysis")
345
-
346
- technical_plot = gr.Plot(label="Multi-Ticker Technical Signals")
347
-
 
 
 
 
 
 
 
 
 
 
348
  analyze_btn.click(
349
- run_analysis,
350
- inputs=[tickers_input, show_bb, time_range, refresh_sentiment],
351
- outputs=[summary, technical_plot, sentiment_plot, decision_gauge, gr.Plot(), status]
352
  )
353
 
354
  if __name__ == "__main__":
355
- demo.launch()
 
8
  import plotly.graph_objects as go
9
  from plotly.subplots import make_subplots
10
  import yfinance as yf
 
11
  from prophet import Prophet
12
  import plotly.express as px
13
+ import warnings
14
+ import json
15
+ from typing import List, Dict, Tuple, Optional
16
 
17
+ # Ignore common warnings from Prophet and yfinance
18
  warnings.filterwarnings('ignore')
19
 
20
  # ============================================================================
21
+ # ⚙️ CONFIGURATION & SETUP
22
  # ============================================================================
23
+ class Config:
24
+ """Central configuration for the application."""
25
+ # API key hardcoded as in the original script
26
+ FINNHUB_API_KEY = "cuj17q1r01qm7p9n307gcuj17q1r01qm7p9n3080"
27
+ DATA_DIR = "data_cache"
28
+ CACHE_TTL_HOURS = 12 # Time-to-live for cache files
29
+ SENTIMENT_DAYS = 90 # How many days back to fetch news for
30
+ TECH_DATA_YEARS = 3 # How many years of historical data for technicals
31
 
32
+ # Plotting styles
33
+ PLOT_TEMPLATE = "plotly_dark"
34
+ PRIMARY_COLOR = "#00BFFF" # DeepSkyBlue
35
+ SENTIMENT_POSITIVE_COLOR = "rgba(0, 204, 102, 0.7)"
36
+ SENTIMENT_NEGATIVE_COLOR = "rgba(255, 51, 51, 0.7)"
37
+ SENTIMENT_NEUTRAL_COLOR = "rgba(128, 128, 128, 0.6)"
38
+ BOLLINGER_FILL_COLOR = "rgba(255, 255, 255, 0.1)"
39
+ BOLLINGER_LINE_STYLE = dict(color="rgba(255, 255, 255, 0.3)", width=1, dash='dot')
40
 
 
 
 
 
41
  @classmethod
42
  def initialize(cls):
43
+ """Create the data directory if it doesn't exist."""
44
  os.makedirs(cls.DATA_DIR, exist_ok=True)
45
 
46
  Config.initialize()
47
 
48
  # ============================================================================
49
+ # 📦 DATA CACHING
50
  # ============================================================================
51
+ class CacheManager:
52
+ """Handles saving and loading of dataframes to avoid redundant API calls."""
53
+ @staticmethod
54
+ def get_path(filename: str) -> str:
55
+ return os.path.join(Config.DATA_DIR, filename)
56
+
57
+ @staticmethod
58
+ def save_df(df: pd.DataFrame, filename: str):
59
+ """Saves a pandas DataFrame to a CSV file."""
60
+ df.to_csv(CacheManager.get_path(filename))
61
+
62
+ @staticmethod
63
+ def load_df(filename: str) -> Optional[pd.DataFrame]:
64
+ """
65
+ Loads a DataFrame from a CSV file if it exists and is not stale.
66
+ Returns None if the file is invalid, missing, or too old.
67
+ """
68
+ path = CacheManager.get_path(filename)
69
+ if not os.path.exists(path):
70
+ return None
71
+
72
+ # Check if cache is stale
73
+ file_mod_time = datetime.fromtimestamp(os.path.getmtime(path))
74
+ if datetime.now() - file_mod_time > timedelta(hours=Config.CACHE_TTL_HOURS):
75
+ return None
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  try:
78
+ df = pd.read_csv(path)
79
+ # Convert date columns back to datetime objects
80
+ for col in df.columns:
81
+ if 'date' in col.lower():
82
+ df[col] = pd.to_datetime(df[col])
83
+ # If the first column is the index, set it
84
+ if 'Date' in df.columns and df.columns[0] == 'Date':
85
+ df.set_index('Date', inplace=True)
86
+ return df
87
+ except Exception:
88
+ return None
89
+
90
+ # ============================================================================
91
+ # 🧠 CORE ANALYSIS LOGIC
92
+ # ============================================================================
93
+ class StockAnalyzer:
94
+ """A comprehensive analyzer for a single stock ticker."""
95
+ _sentiment_analyzer = SentimentIntensityAnalyzer()
96
+
97
+ def __init__(self, ticker: str, force_refresh: bool = False):
98
+ self.ticker = ticker.upper()
99
+ self.force_refresh = force_refresh
100
+ self.tech_df = self._get_technical_data()
101
+ self.sentiment_daily, self.news_df = self._get_sentiment_data()
102
+ self.forecast_pct, self.forecast_price, self.forecast_df = self._get_forecast()
103
+ self.scores, self.decision, self.total_score = self._calculate_decision()
104
+
105
+ def _get_technical_data(self) -> pd.DataFrame:
106
+ """Fetches and processes technical indicator data for the stock."""
107
+ cache_file = f"{self.ticker}_technical.csv"
108
+ df = CacheManager.load_df(cache_file)
109
+ if df is None or self.force_refresh:
110
+ end_date = datetime.now()
111
+ start_date = end_date - timedelta(days=365 * Config.TECH_DATA_YEARS)
112
+ df = yf.download(self.ticker, start=start_date, end=end_date)
113
+ if df.empty:
114
  return pd.DataFrame()
115
+ df = self._calculate_indicators(df)
116
+ CacheManager.save_df(df.reset_index(), cache_file)
117
+ return df
118
+
119
+ def _get_sentiment_data(self) -> Tuple[Optional[pd.DataFrame], Optional[pd.DataFrame]]:
120
+ """Fetches and analyzes news sentiment."""
121
+ cache_file = f"{self.ticker}_sentiment.csv"
122
+ df_daily = CacheManager.load_df(cache_file)
123
+ if df_daily is not None and not self.force_refresh:
124
+ return df_daily, None # Detailed news_df not needed from cache
125
+
126
+ end_date = datetime.now()
127
+ start_date = end_date - timedelta(days=Config.SENTIMENT_DAYS)
128
+ try:
129
+ res = requests.get(
130
+ "https://finnhub.io/api/v1/company-news",
131
+ params={
132
+ "symbol": self.ticker,
133
+ "from": start_date.strftime('%Y-%m-%d'),
134
+ "to": end_date.strftime('%Y-%m-%d'),
135
+ "token": Config.FINNHUB_API_KEY
136
+ },
137
+ timeout=10
138
+ )
139
+ res.raise_for_status()
140
+ news = res.json()
141
+ if not news or not isinstance(news, list):
142
+ return None, None
143
+ except requests.RequestException:
144
  return None, None
145
+
146
+ news_df = pd.DataFrame(news)
147
+ news_df['datetime'] = pd.to_datetime(news_df['datetime'], unit='s')
148
+ news_df['sentiment'] = news_df['headline'].apply(
149
+ lambda text: self._sentiment_analyzer.polarity_scores(text)['compound']
150
+ )
151
  news_df['date'] = pd.to_datetime(news_df['datetime'].dt.date)
152
+
153
+ daily_sentiment = news_df.groupby('date').agg(
154
+ avg_sentiment=('sentiment', 'mean'),
155
+ article_count=('sentiment', 'count'),
156
+ positive_count=('sentiment', lambda x: (x > 0.05).sum()),
157
+ negative_count=('sentiment', lambda x: (x < -0.05).sum()),
158
+ neutral_count=('sentiment', lambda x: ((x >= -0.05) & (x <= 0.05)).sum())
159
  ).reset_index()
 
 
160
 
161
+ CacheManager.save_df(daily_sentiment, cache_file)
162
+ return daily_sentiment, news_df
 
163
 
164
+ def _get_forecast(self) -> Tuple[float, float, Optional[pd.DataFrame]]:
165
+ """Generates a 30-day price forecast using Prophet."""
166
+ if self.tech_df.empty:
167
+ return 0, 0, None
168
+ try:
169
+ prophet_df = self.tech_df.reset_index()[['Date', 'Close']].rename(columns={'Date': 'ds', 'Close': 'y'})
170
+ model = Prophet(daily_seasonality=True)
171
+ model.fit(prophet_df)
172
+ future = model.make_future_dataframe(periods=30)
173
+ forecast = model.predict(future)
174
+ current_price = prophet_df['y'].iloc[-1]
175
+ future_price = forecast['yhat'].iloc[-1]
176
+ pct_change = ((future_price - current_price) / current_price) * 100
177
+ return pct_change, future_price, forecast
178
+ except Exception:
179
+ return 0, 0, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
+ def _calculate_decision(self) -> Tuple[Dict, str, int]:
182
+ """Calculates scores and a final investment decision."""
183
+ # Technical Score
184
+ tech_score = 0
185
+ if not self.tech_df.empty:
186
+ last_signal = self.tech_df['Technical_Score'].iloc[-1]
187
+ if last_signal >= 3: tech_score = 2
188
+ elif last_signal >= 1: tech_score = 1
189
+ elif last_signal <= -1: tech_score = -1
190
+ elif last_signal <= -3: tech_score = -2
191
+
192
+ # Sentiment Score
193
+ sentiment_score = 0
194
+ if self.sentiment_daily is not None:
195
+ avg_sentiment = self.sentiment_daily['avg_sentiment'].mean()
196
+ if avg_sentiment > 0.3: sentiment_score = 2
197
+ elif avg_sentiment > 0.1: sentiment_score = 1
198
+ elif avg_sentiment < -0.1: sentiment_score = -1
199
+ elif avg_sentiment < -0.3: sentiment_score = -2
200
+
201
+ # Forecast Score
202
+ forecast_score = 0
203
+ if self.forecast_pct > 8: forecast_score = 2
204
+ elif self.forecast_pct > 3: forecast_score = 1
205
+ elif self.forecast_pct < -3: forecast_score = -1
206
+ elif self.forecast_pct < -8: forecast_score = -2
207
+
208
+ scores = {'Technical': tech_score, 'Sentiment': sentiment_score, 'Forecast': forecast_score}
209
+ total_score = sum(scores.values())
210
+
211
+ if total_score >= 4: decision = "STRONG BUY"
212
+ elif total_score >= 2: decision = "BUY"
213
+ elif total_score <= -2: decision = "SELL"
214
+ elif total_score <= -4: decision = "STRONG SELL"
215
+ else: decision = "HOLD"
216
+
217
+ return scores, decision, total_score
218
+
219
+ @staticmethod
220
+ def _calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
221
+ """Calculates a suite of technical indicators."""
222
+ # RSI
223
+ delta = df['Close'].diff()
224
+ gain = (delta.where(delta > 0, 0)).rolling(14).mean()
225
+ loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
226
+ rs = gain / loss
227
+ df['RSI'] = 100 - (100 / (1 + rs))
228
 
229
+ # Bollinger Bands
230
+ ma = df['Close'].rolling(20).mean()
231
+ std = df['Close'].rolling(20).std()
232
+ df['UpperBB'] = ma + 2 * std
233
+ df['LowerBB'] = ma - 2 * std
234
+
235
+ # Stochastic Oscillator
236
+ ll = df['Low'].rolling(14).min()
237
+ hh = df['High'].rolling(14).max()
238
+ df['SlowK'] = ((df['Close'] - ll) / (hh - ll)) * 100
239
+ df['SlowD'] = df['SlowK'].rolling(3).mean()
240
+
241
+ # Chaikin Money Flow (CMF)
242
+ mfv = ((df['Close'] - df['Low']) - (df['High'] - df['Close'])) / (df['High'] - df['Low']) * df['Volume']
243
+ df['CMF'] = mfv.rolling(20).sum() / df['Volume'].rolling(20).sum()
244
+
245
+ # Signals (using stricter thresholds)
246
+ df['RSI_Signal'] = np.where(df['RSI'] < 20, 1, np.where(df['RSI'] > 80, -1, 0))
247
+ df['BB_Signal'] = np.where(df['Close'] < df['LowerBB'], 1, np.where(df['Close'] > df['UpperBB'], -1, 0))
248
+ df['Stochastic_Signal'] = np.where((df['SlowK'] < 15) & (df['SlowD'] < 15), 1, np.where((df['SlowK'] > 85) & (df['SlowD'] > 85), -1, 0))
249
+ df['CMF_Signal'] = np.where(df['CMF'] < -0.25, 1, np.where(df['CMF'] > 0.25, -1, 0))
250
+ df['Technical_Score'] = df[['RSI_Signal', 'BB_Signal', 'Stochastic_Signal', 'CMF_Signal']].sum(axis=1)
251
+ return df
252
 
253
  # ============================================================================
254
+ # 📈 PLOTTING FUNCTIONS
255
  # ============================================================================
256
+ class Plotter:
257
+ """Handles the creation of all Plotly figures."""
258
+ @staticmethod
259
+ def create_multi_ticker_plot(data_dict: Dict[str, pd.DataFrame], show_bollinger: bool, time_range: str) -> go.Figure:
260
+ fig = go.Figure()
261
+ colors = px.colors.qualitative.Plotly
262
+
263
+ # Determine overall date range
264
+ all_dates = pd.concat([df.index.to_series() for df in data_dict.values()]).unique()
265
+ if len(all_dates) == 0:
266
+ return fig
267
+ max_date = all_dates.max()
268
+ range_map = {
269
+ "1M": max_date - pd.DateOffset(months=1), "3M": max_date - pd.DateOffset(months=3),
270
+ "6M": max_date - pd.DateOffset(months=6), "1Y": max_date - pd.DateOffset(years=1),
271
+ "YTD": pd.to_datetime(f"{max_date.year}-01-01"), "All": all_dates.min()
272
+ }
273
+ start_date = range_map.get(time_range)
274
 
275
+ for i, (ticker, df) in enumerate(data_dict.items()):
276
+ df_plot = df[df.index >= start_date]
277
+ color = colors[i % len(colors)]
278
+ fig.add_trace(go.Scatter(x=df_plot.index, y=df_plot['Close'], mode='lines', name=ticker, line=dict(color=color, width=2)))
279
+
280
+ if show_bollinger:
281
+ fig.add_trace(go.Scatter(x=df_plot.index, y=df_plot['UpperBB'], mode='lines', line=Config.BOLLINGER_LINE_STYLE, showlegend=False, hoverinfo='skip'))
282
+ fig.add_trace(go.Scatter(x=df_plot.index, y=df_plot['LowerBB'], mode='lines', line=Config.BOLLINGER_LINE_STYLE, fill='tonexty', fillcolor=Config.BOLLINGER_FILL_COLOR, showlegend=False, hoverinfo='skip'))
283
+
284
+ buy_signals = df_plot[df_plot['Technical_Score'] > 0]
285
+ sell_signals = df_plot[df_plot['Technical_Score'] < 0]
286
+ fig.add_trace(go.Scatter(x=buy_signals.index, y=buy_signals['Close'], mode='markers', name=f'{ticker} Buy', marker=dict(symbol='triangle-up', color=color, size=10), hoverinfo='skip'))
287
+ fig.add_trace(go.Scatter(x=sell_signals.index, y=sell_signals['Close'], mode='markers', name=f'{ticker} Sell', marker=dict(symbol='triangle-down', color='white', size=8, line=dict(color=color, width=2)), hoverinfo='skip'))
288
+
289
+ fig.update_layout(
290
+ title="Comparative Technical Analysis", template=Config.PLOT_TEMPLATE, height=600,
291
+ legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
292
+ yaxis_title="Stock Price (USD)", hovermode="x unified"
293
+ )
294
+ return fig
295
+
296
+ @staticmethod
297
+ def create_decision_gauge(decision: str, total_score: int) -> go.Figure:
298
+ colors = {'STRONG BUY': '#00FF00', 'BUY': '#90EE90', 'HOLD': '#FFD700', 'SELL': '#FFA500', 'STRONG SELL': '#FF0000'}
299
+ fig = go.Figure(go.Indicator(
300
+ mode="gauge+number", value=total_score,
301
+ title={'text': decision, 'font': {'size': 24, 'color': 'white'}},
302
+ gauge={
303
+ 'axis': {'range': [-6, 6], 'tickwidth': 1, 'tickcolor': "darkblue"},
304
+ 'bar': {'color': colors.get(decision, '#FFD700')},
305
+ 'steps': [
306
+ {'range': [-6, -4], 'color': 'rgba(255, 0, 0, 0.8)'},
307
+ {'range': [-4, -2], 'color': 'rgba(255, 165, 0, 0.7)'},
308
+ {'range': [-2, 2], 'color': 'rgba(255, 215, 0, 0.6)'},
309
+ {'range': [2, 4], 'color': 'rgba(144, 238, 144, 0.7)'},
310
+ {'range': [4, 6], 'color': 'rgba(0, 255, 0, 0.8)'},
311
+ ],
312
+ }
313
+ ))
314
+ fig.update_layout(template=Config.PLOT_TEMPLATE, height=250, margin=dict(t=40, b=40))
315
+ return fig
316
+
317
+ @staticmethod
318
+ def create_sentiment_plot(daily_sentiment: pd.DataFrame, ticker: str) -> go.Figure:
319
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, row_heights=[0.7, 0.3], vertical_spacing=0.1)
320
+ fig.add_trace(go.Scatter(x=daily_sentiment['date'], y=daily_sentiment['avg_sentiment'], name='Avg. Sentiment', line=dict(color=Config.PRIMARY_COLOR, width=2)), row=1, col=1)
321
+ fig.add_trace(go.Bar(x=daily_sentiment['date'], y=daily_sentiment['positive_count'], name='Positive', marker_color=Config.SENTIMENT_POSITIVE_COLOR), row=2, col=1)
322
+ fig.add_trace(go.Bar(x=daily_sentiment['date'], y=daily_sentiment['negative_count'], name='Negative', marker_color=Config.SENTIMENT_NEGATIVE_COLOR), row=2, col=1)
323
+ fig.add_trace(go.Bar(x=daily_sentiment['date'], y=daily_sentiment['neutral_count'], name='Neutral', marker_color=Config.SENTIMENT_NEUTRAL_COLOR), row=2, col=1)
324
+ fig.update_layout(
325
+ title=f"News Sentiment & Article Volume (Last {Config.SENTIMENT_DAYS} Days)",
326
+ template=Config.PLOT_TEMPLATE, barmode='stack', height=450, showlegend=False,
327
+ yaxis1_title="Sentiment Score", yaxis2_title="Article Count"
328
+ )
329
+ return fig
330
+
331
+ @staticmethod
332
+ def create_forecast_plot(forecast_df: pd.DataFrame, ticker: str) -> go.Figure:
333
+ fig = go.Figure()
334
+ fig.add_trace(go.Scatter(x=forecast_df['ds'], y=forecast_df['yhat'], name='Forecast', line=dict(color=Config.PRIMARY_COLOR, width=2)))
335
+ fig.add_trace(go.Scatter(x=forecast_df['ds'], y=forecast_df['yhat_upper'], fill=None, mode='lines', line=dict(color='rgba(0, 191, 255, 0.3)'), showlegend=False))
336
+ fig.add_trace(go.Scatter(x=forecast_df['ds'], y=forecast_df['yhat_lower'], fill='tonexty', mode='lines', line=dict(color='rgba(0, 191, 255, 0.3)'), showlegend=False))
337
+ fig.update_layout(title="30-Day Price Forecast", template=Config.PLOT_TEMPLATE, height=450, yaxis_title="Predicted Price (USD)")
338
  return fig
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
  # ============================================================================
341
+ # 🖥️ GRADIO INTERFACE & APP LOGIC
342
  # ============================================================================
343
+ def run_full_analysis(tickers_str: str, time_range: str, show_bollinger: bool, force_refresh: bool, progress=gr.Progress()):
344
+ """Main function triggered by the Gradio button."""
345
+ tickers = [t.strip().upper() for t in tickers_str.split(',') if t.strip()][:5] # Limit to 5 tickers
346
+ if not tickers:
347
+ return "Please enter at least one ticker.", None, gr.Accordion(visible=False)
348
 
349
+ progress(0, desc="Starting analysis...")
350
+ all_results = {}
351
+ for i, ticker in enumerate(tickers):
352
+ progress((i + 1) / len(tickers), desc=f"Analyzing {ticker}...")
353
+ try:
354
+ analyzer = StockAnalyzer(ticker, force_refresh)
355
+ if analyzer.tech_df.empty:
356
+ continue # Skip if no data
357
+ all_results[ticker] = analyzer
358
+ except Exception as e:
359
+ print(f"Error analyzing {ticker}: {e}")
360
+ continue
361
+
362
+ if not all_results:
363
+ return "Could not retrieve data for the entered tickers.", None, gr.Accordion(visible=False)
364
+
365
+ # 1. Create the comparative multi-ticker plot
366
+ multi_plot = Plotter.create_multi_ticker_plot(
367
+ {t: r.tech_df for t, r in all_results.items()},
368
+ show_bollinger, time_range
369
+ )
370
+
371
+ # 2. Create the dynamic accordion with results for each ticker
372
+ accordion_items = []
373
+ for ticker, analyzer in all_results.items():
374
+ # Summary Tab Content
375
+ summary_md = f"""
376
+ ### 🎯 Decision: **{analyzer.decision}** (Score: {analyzer.total_score}/6)
377
+ - **Current Price:** `${analyzer.tech_df['Close'].iloc[-1]:.2f}`
378
+ - **Technical Score:** `{analyzer.scores['Technical']}`
379
+ - **Sentiment Score:** `{analyzer.scores['Sentiment']}` (Avg: {analyzer.sentiment_daily['avg_sentiment'].mean():.2f})
380
+ - **Forecast Score:** `{analyzer.scores['Forecast']}` ({analyzer.forecast_pct:.1f}% change to `${analyzer.forecast_price:.2f}`)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  """
382
+ gauge_plot = Plotter.create_decision_gauge(analyzer.decision, analyzer.total_score)
383
+ summary_col = gr.Column(
384
+ gr.Markdown(summary_md),
385
+ gr.Plot(gauge_plot)
386
+ )
387
 
388
+ # Sentiment Tab Content
389
+ sentiment_plot = Plotter.create_sentiment_plot(analyzer.sentiment_daily, ticker) if analyzer.sentiment_daily is not None else gr.Markdown("Sentiment data not available.")
 
390
 
391
+ # Forecast Tab Content
392
+ forecast_plot = Plotter.create_forecast_plot(analyzer.forecast_df, ticker) if analyzer.forecast_df is not None else gr.Markdown("Forecast could not be generated.")
393
+
394
+ # Assemble the accordion item
395
+ ticker_accordion = gr.Accordion(
396
+ label=f"📊 {ticker} Analysis",
397
+ open=ticker == tickers[0] # Open the first one by default
398
+ )
399
+ with ticker_accordion:
400
+ with gr.Tabs():
401
+ with gr.TabItem("📈 Summary & Decision"):
402
+ summary_col.render()
403
+ with gr.TabItem("😊 Sentiment Analysis"):
404
+ gr.Plot(sentiment_plot).render() if isinstance(sentiment_plot, go.Figure) else sentiment_plot.render()
405
+ with gr.TabItem("🔮 Forecast"):
406
+ gr.Plot(forecast_plot).render() if isinstance(forecast_plot, go.Figure) else forecast_plot.render()
407
+ accordion_items.append(ticker_accordion)
408
+
409
+ progress(1, "Analysis complete!")
410
+ return "Analysis complete!", multi_plot, gr.Column(*accordion_items, visible=True)
411
+
412
+
413
+ # --- Build the Gradio App ---
414
+ with gr.Blocks(theme=gr.themes.Default(primary_hue="blue", secondary_hue="blue"), title="Stock Intelligence Dashboard") as demo:
415
  gr.Markdown("# 📊 Unified Stock Intelligence Dashboard")
416
+ gr.Markdown("An advanced tool for technical, sentiment, and predictive analysis of stocks.")
417
+
 
 
 
 
 
 
 
 
 
 
 
 
418
  with gr.Row():
419
+ with gr.Column(scale=1, min_width=300):
420
+ gr.Markdown("### Controls")
421
+ tickers_input = gr.Textbox(label="Tickers (comma-separated, max 5)", value="NVDA,TSLA,MSFT")
422
+ time_range = gr.Radio(choices=["1M", "3M", "6M", "1Y", "YTD", "All"], value="1Y", label="Chart Time Range")
423
+ show_bb = gr.Checkbox(label="Show Bollinger Bands", value=True)
424
+ force_refresh = gr.Checkbox(label="Force Refresh Data (ignore cache)", value=False)
425
+ analyze_btn = gr.Button("Analyze Stocks", variant="primary")
426
+ status_output = gr.Textbox(label="Status", interactive=False)
427
+ progress_bar = gr.Progress(track_tqdm=True)
428
+
429
+ with gr.Column(scale=4):
430
+ gr.Markdown("### Comparative Analysis")
431
+ technical_plot_output = gr.Plot()
432
+ results_accordion_output = gr.Column(visible=False)
433
+
434
  analyze_btn.click(
435
+ fn=run_full_analysis,
436
+ inputs=[tickers_input, time_range, show_bb, force_refresh],
437
+ outputs=[status_output, technical_plot_output, results_accordion_output]
438
  )
439
 
440
  if __name__ == "__main__":
441
+ demo.launch(debug=True)