JayLacoma commited on
Commit
9d0e359
·
verified ·
1 Parent(s): 1089a1e

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +89 -134
app.py CHANGED
@@ -11,27 +11,23 @@ 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)"
@@ -40,7 +36,6 @@ class Config:
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()
@@ -49,38 +44,29 @@ Config.initialize()
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
@@ -91,19 +77,17 @@ class CacheManager:
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:
@@ -116,16 +100,16 @@ class StockAnalyzer:
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={
@@ -161,8 +145,7 @@ class StockAnalyzer:
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:
@@ -178,9 +161,7 @@ class StockAnalyzer:
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]
@@ -189,7 +170,6 @@ class StockAnalyzer:
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()
@@ -198,7 +178,6 @@ class StockAnalyzer:
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
@@ -218,9 +197,7 @@ class StockAnalyzer:
218
 
219
  @staticmethod
220
  def _calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
221
- """Calculates a suite of technical indicators."""
222
- df = df.copy() # Avoid modifying original
223
-
224
  # RSI
225
  delta = df['Close'].diff()
226
  gain = (delta.where(delta > 0, 0)).rolling(14).mean()
@@ -234,28 +211,23 @@ class StockAnalyzer:
234
  df['UpperBB'] = ma + 2 * std
235
  df['LowerBB'] = ma - 2 * std
236
 
237
- # Stochastic Oscillator
238
  ll = df['Low'].rolling(14).min()
239
  hh = df['High'].rolling(14).max()
240
  df['SlowK'] = ((df['Close'] - ll) / (hh - ll)) * 100
241
  df['SlowD'] = df['SlowK'].rolling(3).mean()
242
 
243
- # Chaikin Money Flow (CMF) — FIXED SECTION
244
  price_range = df['High'] - df['Low']
245
- # Avoid division by zero
246
  price_range = price_range.replace(0, np.nan)
247
  mfv = ((df['Close'] - df['Low']) - (df['High'] - df['Close'])) / price_range * df['Volume']
248
-
249
  mfv_sum = mfv.rolling(20).sum()
250
  vol_sum = df['Volume'].rolling(20).sum()
251
-
252
- # Use .values to prevent pandas alignment errors
253
  cmf_raw = mfv_sum.values / vol_sum.values
254
- # Replace inf/-inf with NaN
255
  cmf_clean = np.where(np.isfinite(cmf_raw), cmf_raw, np.nan)
256
  df['CMF'] = cmf_clean
257
 
258
- # Signals (using stricter thresholds)
259
  df['RSI_Signal'] = np.where(df['RSI'] < 20, 1, np.where(df['RSI'] > 80, -1, 0))
260
  df['BB_Signal'] = np.where(df['Close'] < df['LowerBB'], 1, np.where(df['Close'] > df['UpperBB'], -1, 0))
261
  df['Stochastic_Signal'] = np.where((df['SlowK'] < 15) & (df['SlowD'] < 15), 1, np.where((df['SlowK'] > 85) & (df['SlowD'] > 85), -1, 0))
@@ -268,23 +240,24 @@ class StockAnalyzer:
268
  # 📈 PLOTTING FUNCTIONS
269
  # ============================================================================
270
  class Plotter:
271
- """Handles the creation of all Plotly figures."""
272
  @staticmethod
273
- def create_multi_ticker_plot(data_dict: Dict[str, pd.DataFrame], show_bollinger: bool, time_range: str) -> go.Figure:
274
  fig = go.Figure()
275
  colors = px.colors.qualitative.Plotly
276
 
277
- # Determine overall date range
278
  all_dates = pd.concat([df.index.to_series() for df in data_dict.values()]).unique()
279
  if len(all_dates) == 0:
280
  return fig
281
  max_date = all_dates.max()
282
  range_map = {
283
- "1M": max_date - pd.DateOffset(months=1), "3M": max_date - pd.DateOffset(months=3),
284
- "6M": max_date - pd.DateOffset(months=6), "1Y": max_date - pd.DateOffset(years=1),
285
- "YTD": pd.to_datetime(f"{max_date.year}-01-01"), "All": all_dates.min()
 
 
 
286
  }
287
- start_date = range_map.get(time_range)
288
 
289
  for i, (ticker, df) in enumerate(data_dict.items()):
290
  df_plot = df[df.index >= start_date]
@@ -297,13 +270,16 @@ class Plotter:
297
 
298
  buy_signals = df_plot[df_plot['Technical_Score'] > 0]
299
  sell_signals = df_plot[df_plot['Technical_Score'] < 0]
300
- 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'))
301
- 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'))
302
 
303
  fig.update_layout(
304
- title="Comparative Technical Analysis", template=Config.PLOT_TEMPLATE, height=600,
 
 
305
  legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
306
- yaxis_title="Stock Price (USD)", hovermode="x unified"
 
307
  )
308
  return fig
309
 
@@ -314,7 +290,7 @@ class Plotter:
314
  mode="gauge+number", value=total_score,
315
  title={'text': decision, 'font': {'size': 24, 'color': 'white'}},
316
  gauge={
317
- 'axis': {'range': [-6, 6], 'tickwidth': 1, 'tickcolor': "darkblue"},
318
  'bar': {'color': colors.get(decision, '#FFD700')},
319
  'steps': [
320
  {'range': [-6, -4], 'color': 'rgba(255, 0, 0, 0.8)'},
@@ -352,13 +328,15 @@ class Plotter:
352
  return fig
353
 
354
  # ============================================================================
355
- # 🖥️ GRADIO INTERFACE & APP LOGIC
356
  # ============================================================================
357
  def run_full_analysis(tickers_str: str, time_range: str, show_bollinger: bool, force_refresh: bool, progress=gr.Progress()):
358
- """Main function triggered by the Gradio button."""
359
- tickers = [t.strip().upper() for t in tickers_str.split(',') if t.strip()][:5] # Limit to 5 tickers
360
  if not tickers:
361
- return "Please enter at least one ticker.", None, gr.Column(visible=False)
 
 
 
362
 
363
  progress(0, desc="Starting analysis...")
364
  all_results = {}
@@ -366,114 +344,91 @@ def run_full_analysis(tickers_str: str, time_range: str, show_bollinger: bool, f
366
  progress((i + 1) / len(tickers), desc=f"Analyzing {ticker}...")
367
  try:
368
  analyzer = StockAnalyzer(ticker, force_refresh)
369
- if analyzer.tech_df.empty:
370
- continue # Skip if no data
371
- all_results[ticker] = analyzer
372
  except Exception as e:
373
  print(f"Error analyzing {ticker}: {e}")
374
  continue
375
 
376
  if not all_results:
377
- return "Could not retrieve data for the entered tickers.", None, gr.Column(visible=False)
 
 
 
378
 
379
- # 1. Create the comparative multi-ticker plot
380
  multi_plot = Plotter.create_multi_ticker_plot(
381
  {t: r.tech_df for t, r in all_results.items()},
382
  show_bollinger, time_range
383
  )
384
 
385
- # 2. Create the dynamic accordion with results for each ticker
386
- accordion_items = []
387
- for ticker, analyzer in all_results.items():
388
- # Summary Tab Content
389
- current_price = analyzer.tech_df['Close'].iloc[-1] if not analyzer.tech_df.empty else 0.0
390
- avg_sent = analyzer.sentiment_daily['avg_sentiment'].mean() if analyzer.sentiment_daily is not None else 0.0
391
- summary_md = f"""
392
- ### 🎯 Decision: **{analyzer.decision}** (Score: {analyzer.total_score}/6)
393
- - **Current Price:** `${current_price:.2f}`
394
- - **Technical Score:** `{analyzer.scores['Technical']}`
395
- - **Sentiment Score:** `{analyzer.scores['Sentiment']}` (Avg: {avg_sent:.2f})
396
- - **Forecast Score:** `{analyzer.scores['Forecast']}` ({analyzer.forecast_pct:.1f}% change to `${analyzer.forecast_price:.2f}`)
397
- """
398
- gauge_plot = Plotter.create_decision_gauge(analyzer.decision, analyzer.total_score)
399
- summary_col = gr.Column(
400
- gr.Markdown(summary_md),
401
- gr.Plot(gauge_plot)
402
- )
403
 
404
- # Sentiment Tab Content
405
- sentiment_plot = Plotter.create_sentiment_plot(analyzer.sentiment_daily, ticker) if analyzer.sentiment_daily is not None else gr.Markdown("Sentiment data not available.")
 
 
406
 
407
- # Forecast Tab Content
408
- 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.")
409
 
410
- # Assemble the accordion item
411
- ticker_accordion = gr.Accordion(
412
- label=f"📊 {ticker} Analysis",
413
- open=ticker == tickers[0] # Open the first one by default
414
- )
415
- with ticker_accordion:
416
- with gr.Tabs():
417
- with gr.TabItem("📈 Summary & Decision"):
418
- summary_col.render()
419
- with gr.TabItem("😊 Sentiment Analysis"):
420
- if isinstance(sentiment_plot, go.Figure):
421
- gr.Plot(sentiment_plot).render()
422
- else:
423
- sentiment_plot.render()
424
- with gr.TabItem("🔮 Forecast"):
425
- if isinstance(forecast_plot, go.Figure):
426
- gr.Plot(forecast_plot).render()
427
- else:
428
- forecast_plot.render()
429
- accordion_items.append(ticker_accordion)
430
-
431
- progress(1, "Analysis complete!")
432
- return "Analysis complete!", multi_plot, gr.Column(*accordion_items, visible=True)
433
-
434
-
435
- # Custom CSS for better appearance
436
  custom_css = """
437
- .gradio-container {
438
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
439
- }
440
- .container {
441
- max-width: 1200px;
442
- margin: auto;
443
- }
444
- button#analyze-btn {
445
- background-color: #003366;
446
- color: white;
447
- border: none;
448
- }
449
  """
450
 
451
- # --- Build the Gradio App ---
452
  with gr.Blocks(theme=gr.themes.Monochrome(), css=custom_css) as demo:
453
  gr.Markdown("# 📊 Unified Stock Intelligence Dashboard")
454
- gr.Markdown("An advanced tool for technical, sentiment, and predictive analysis of stocks.")
455
 
456
  with gr.Row():
457
- with gr.Column(scale=1, min_width=300):
458
  gr.Markdown("### Controls")
459
  tickers_input = gr.Textbox(label="Tickers (comma-separated, max 5)", value="NVDA,TSLA,MSFT")
460
  time_range = gr.Radio(choices=["1M", "3M", "6M", "1Y", "YTD", "All"], value="1Y", label="Chart Time Range")
461
  show_bb = gr.Checkbox(label="Show Bollinger Bands", value=True)
462
- force_refresh = gr.Checkbox(label="Force Refresh Data (ignore cache)", value=False)
463
  analyze_btn = gr.Button("Analyze Stocks", variant="primary")
464
  status_output = gr.Textbox(label="Status", interactive=False)
465
- progress_bar = gr.Progress(track_tqdm=True)
466
 
467
  with gr.Column(scale=4):
468
- gr.Markdown("### Comparative Analysis")
469
  technical_plot_output = gr.Plot()
470
- results_accordion_output = gr.Column(visible=False)
471
 
 
 
 
 
 
 
 
 
 
 
 
472
  analyze_btn.click(
473
  fn=run_full_analysis,
474
  inputs=[tickers_input, time_range, show_bb, force_refresh],
475
- outputs=[status_output, technical_plot_output, results_accordion_output]
476
  )
477
 
478
  if __name__ == "__main__":
479
- demo.launch(debug=True)
 
11
  from prophet import Prophet
12
  import plotly.express as px
13
  import warnings
14
+ from typing import Optional
 
15
 
16
+ # Ignore common warnings
17
  warnings.filterwarnings('ignore')
18
 
19
  # ============================================================================
20
  # ⚙️ CONFIGURATION & SETUP
21
  # ============================================================================
22
  class Config:
 
 
23
  FINNHUB_API_KEY = "cuj17q1r01qm7p9n307gcuj17q1r01qm7p9n3080"
24
  DATA_DIR = "data_cache"
25
+ CACHE_TTL_HOURS = 12
26
+ SENTIMENT_DAYS = 90
27
+ TECH_DATA_YEARS = 3
28
 
 
29
  PLOT_TEMPLATE = "plotly_dark"
30
+ PRIMARY_COLOR = "#00BFFF"
31
  SENTIMENT_POSITIVE_COLOR = "rgba(0, 204, 102, 0.7)"
32
  SENTIMENT_NEGATIVE_COLOR = "rgba(255, 51, 51, 0.7)"
33
  SENTIMENT_NEUTRAL_COLOR = "rgba(128, 128, 128, 0.6)"
 
36
 
37
  @classmethod
38
  def initialize(cls):
 
39
  os.makedirs(cls.DATA_DIR, exist_ok=True)
40
 
41
  Config.initialize()
 
44
  # 📦 DATA CACHING
45
  # ============================================================================
46
  class CacheManager:
 
47
  @staticmethod
48
  def get_path(filename: str) -> str:
49
  return os.path.join(Config.DATA_DIR, filename)
50
 
51
  @staticmethod
52
  def save_df(df: pd.DataFrame, filename: str):
 
53
  df.to_csv(CacheManager.get_path(filename))
54
 
55
  @staticmethod
56
  def load_df(filename: str) -> Optional[pd.DataFrame]:
 
 
 
 
57
  path = CacheManager.get_path(filename)
58
  if not os.path.exists(path):
59
  return None
60
 
 
61
  file_mod_time = datetime.fromtimestamp(os.path.getmtime(path))
62
  if datetime.now() - file_mod_time > timedelta(hours=Config.CACHE_TTL_HOURS):
63
  return None
64
 
65
  try:
66
  df = pd.read_csv(path)
 
67
  for col in df.columns:
68
  if 'date' in col.lower():
69
  df[col] = pd.to_datetime(df[col])
 
70
  if 'Date' in df.columns and df.columns[0] == 'Date':
71
  df.set_index('Date', inplace=True)
72
  return df
 
77
  # 🧠 CORE ANALYSIS LOGIC
78
  # ============================================================================
79
  class StockAnalyzer:
 
80
  _sentiment_analyzer = SentimentIntensityAnalyzer()
81
 
82
  def __init__(self, ticker: str, force_refresh: bool = False):
83
  self.ticker = ticker.upper()
84
  self.force_refresh = force_refresh
85
  self.tech_df = self._get_technical_data()
86
+ self.sentiment_daily, _ = self._get_sentiment_data()
87
  self.forecast_pct, self.forecast_price, self.forecast_df = self._get_forecast()
88
  self.scores, self.decision, self.total_score = self._calculate_decision()
89
 
90
  def _get_technical_data(self) -> pd.DataFrame:
 
91
  cache_file = f"{self.ticker}_technical.csv"
92
  df = CacheManager.load_df(cache_file)
93
  if df is None or self.force_refresh:
 
100
  CacheManager.save_df(df.reset_index(), cache_file)
101
  return df
102
 
103
+ def _get_sentiment_data(self):
 
104
  cache_file = f"{self.ticker}_sentiment.csv"
105
  df_daily = CacheManager.load_df(cache_file)
106
  if df_daily is not None and not self.force_refresh:
107
+ return df_daily, None
108
 
109
  end_date = datetime.now()
110
  start_date = end_date - timedelta(days=Config.SENTIMENT_DAYS)
111
  try:
112
+ # FIXED: Removed trailing spaces in URL!
113
  res = requests.get(
114
  "https://finnhub.io/api/v1/company-news",
115
  params={
 
145
  CacheManager.save_df(daily_sentiment, cache_file)
146
  return daily_sentiment, news_df
147
 
148
+ def _get_forecast(self):
 
149
  if self.tech_df.empty:
150
  return 0, 0, None
151
  try:
 
161
  except Exception:
162
  return 0, 0, None
163
 
164
+ def _calculate_decision(self):
 
 
165
  tech_score = 0
166
  if not self.tech_df.empty:
167
  last_signal = self.tech_df['Technical_Score'].iloc[-1]
 
170
  elif last_signal <= -1: tech_score = -1
171
  elif last_signal <= -3: tech_score = -2
172
 
 
173
  sentiment_score = 0
174
  if self.sentiment_daily is not None:
175
  avg_sentiment = self.sentiment_daily['avg_sentiment'].mean()
 
178
  elif avg_sentiment < -0.1: sentiment_score = -1
179
  elif avg_sentiment < -0.3: sentiment_score = -2
180
 
 
181
  forecast_score = 0
182
  if self.forecast_pct > 8: forecast_score = 2
183
  elif self.forecast_pct > 3: forecast_score = 1
 
197
 
198
  @staticmethod
199
  def _calculate_indicators(df: pd.DataFrame) -> pd.DataFrame:
200
+ df = df.copy()
 
 
201
  # RSI
202
  delta = df['Close'].diff()
203
  gain = (delta.where(delta > 0, 0)).rolling(14).mean()
 
211
  df['UpperBB'] = ma + 2 * std
212
  df['LowerBB'] = ma - 2 * std
213
 
214
+ # Stochastic
215
  ll = df['Low'].rolling(14).min()
216
  hh = df['High'].rolling(14).max()
217
  df['SlowK'] = ((df['Close'] - ll) / (hh - ll)) * 100
218
  df['SlowD'] = df['SlowK'].rolling(3).mean()
219
 
220
+ # CMF
221
  price_range = df['High'] - df['Low']
 
222
  price_range = price_range.replace(0, np.nan)
223
  mfv = ((df['Close'] - df['Low']) - (df['High'] - df['Close'])) / price_range * df['Volume']
 
224
  mfv_sum = mfv.rolling(20).sum()
225
  vol_sum = df['Volume'].rolling(20).sum()
 
 
226
  cmf_raw = mfv_sum.values / vol_sum.values
 
227
  cmf_clean = np.where(np.isfinite(cmf_raw), cmf_raw, np.nan)
228
  df['CMF'] = cmf_clean
229
 
230
+ # Signals
231
  df['RSI_Signal'] = np.where(df['RSI'] < 20, 1, np.where(df['RSI'] > 80, -1, 0))
232
  df['BB_Signal'] = np.where(df['Close'] < df['LowerBB'], 1, np.where(df['Close'] > df['UpperBB'], -1, 0))
233
  df['Stochastic_Signal'] = np.where((df['SlowK'] < 15) & (df['SlowD'] < 15), 1, np.where((df['SlowK'] > 85) & (df['SlowD'] > 85), -1, 0))
 
240
  # 📈 PLOTTING FUNCTIONS
241
  # ============================================================================
242
  class Plotter:
 
243
  @staticmethod
244
+ def create_multi_ticker_plot(data_dict, show_bollinger, time_range):
245
  fig = go.Figure()
246
  colors = px.colors.qualitative.Plotly
247
 
 
248
  all_dates = pd.concat([df.index.to_series() for df in data_dict.values()]).unique()
249
  if len(all_dates) == 0:
250
  return fig
251
  max_date = all_dates.max()
252
  range_map = {
253
+ "1M": max_date - pd.DateOffset(months=1),
254
+ "3M": max_date - pd.DateOffset(months=3),
255
+ "6M": max_date - pd.DateOffset(months=6),
256
+ "1Y": max_date - pd.DateOffset(years=1),
257
+ "YTD": pd.to_datetime(f"{max_date.year}-01-01"),
258
+ "All": all_dates.min()
259
  }
260
+ start_date = range_map.get(time_range, all_dates.min())
261
 
262
  for i, (ticker, df) in enumerate(data_dict.items()):
263
  df_plot = df[df.index >= start_date]
 
270
 
271
  buy_signals = df_plot[df_plot['Technical_Score'] > 0]
272
  sell_signals = df_plot[df_plot['Technical_Score'] < 0]
273
+ 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=8), hoverinfo='skip'))
274
+ 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=6, line=dict(color=color, width=1)), hoverinfo='skip'))
275
 
276
  fig.update_layout(
277
+ title="Comparative Technical Analysis",
278
+ template=Config.PLOT_TEMPLATE,
279
+ height=600,
280
  legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
281
+ yaxis_title="Stock Price (USD)",
282
+ hovermode="x unified"
283
  )
284
  return fig
285
 
 
290
  mode="gauge+number", value=total_score,
291
  title={'text': decision, 'font': {'size': 24, 'color': 'white'}},
292
  gauge={
293
+ 'axis': {'range': [-6, 6]},
294
  'bar': {'color': colors.get(decision, '#FFD700')},
295
  'steps': [
296
  {'range': [-6, -4], 'color': 'rgba(255, 0, 0, 0.8)'},
 
328
  return fig
329
 
330
  # ============================================================================
331
+ # 🖥️ GRADIO INTERFACE
332
  # ============================================================================
333
  def run_full_analysis(tickers_str: str, time_range: str, show_bollinger: bool, force_refresh: bool, progress=gr.Progress()):
334
+ tickers = [t.strip().upper() for t in tickers_str.split(',') if t.strip()][:5]
 
335
  if not tickers:
336
+ return (
337
+ "Please enter at least one ticker.",
338
+ None, None, None, None
339
+ )
340
 
341
  progress(0, desc="Starting analysis...")
342
  all_results = {}
 
344
  progress((i + 1) / len(tickers), desc=f"Analyzing {ticker}...")
345
  try:
346
  analyzer = StockAnalyzer(ticker, force_refresh)
347
+ if not analyzer.tech_df.empty:
348
+ all_results[ticker] = analyzer
 
349
  except Exception as e:
350
  print(f"Error analyzing {ticker}: {e}")
351
  continue
352
 
353
  if not all_results:
354
+ return (
355
+ "Could not retrieve data for any ticker.",
356
+ None, None, None, None
357
+ )
358
 
359
+ # Multi-ticker price plot
360
  multi_plot = Plotter.create_multi_ticker_plot(
361
  {t: r.tech_df for t, r in all_results.items()},
362
  show_bollinger, time_range
363
  )
364
 
365
+ # Detailed analysis for FIRST ticker only
366
+ primary_ticker = tickers[0]
367
+ primary_analyzer = all_results.get(primary_ticker)
368
+ if not primary_analyzer:
369
+ primary_ticker = list(all_results.keys())[0]
370
+ primary_analyzer = all_results[primary_ticker]
371
+
372
+ # Summary
373
+ current_price = primary_analyzer.tech_df['Close'].iloc[-1]
374
+ avg_sent = primary_analyzer.sentiment_daily['avg_sentiment'].mean() if primary_analyzer.sentiment_daily is not None else 0.0
375
+ summary_md = f"""
376
+ ### 🎯 Decision: **{primary_analyzer.decision}** (Score: {primary_analyzer.total_score}/6)
377
+ - **Ticker**: {primary_ticker}
378
+ - **Current Price**: ${current_price:.2f}
379
+ - **Technical Score**: `{primary_analyzer.scores['Technical']}`
380
+ - **Sentiment Score**: `{primary_analyzer.scores['Sentiment']}` (Avg: {avg_sent:.2f})
381
+ - **Forecast Score**: `{primary_analyzer.scores['Forecast']}` ({primary_analyzer.forecast_pct:.1f}% → ${primary_analyzer.forecast_price:.2f})
382
+ """
383
 
384
+ # Plots
385
+ gauge_plot = Plotter.create_decision_gauge(primary_analyzer.decision, primary_analyzer.total_score)
386
+ sentiment_plot = Plotter.create_sentiment_plot(primary_analyzer.sentiment_daily, primary_ticker) if primary_analyzer.sentiment_daily is not None else None
387
+ forecast_plot = Plotter.create_forecast_plot(primary_analyzer.forecast_df, primary_ticker) if primary_analyzer.forecast_df is not None else None
388
 
389
+ progress(1.0, "Done!")
390
+ return summary_md, multi_plot, gauge_plot, sentiment_plot, forecast_plot
391
 
392
+ # Custom CSS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  custom_css = """
394
+ .gradio-container { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
 
 
 
 
 
 
 
 
 
 
 
395
  """
396
 
397
+ # Build App
398
  with gr.Blocks(theme=gr.themes.Monochrome(), css=custom_css) as demo:
399
  gr.Markdown("# 📊 Unified Stock Intelligence Dashboard")
400
+ gr.Markdown("Technical, sentiment, and predictive analysis for up to 5 stocks.")
401
 
402
  with gr.Row():
403
+ with gr.Column(scale=1):
404
  gr.Markdown("### Controls")
405
  tickers_input = gr.Textbox(label="Tickers (comma-separated, max 5)", value="NVDA,TSLA,MSFT")
406
  time_range = gr.Radio(choices=["1M", "3M", "6M", "1Y", "YTD", "All"], value="1Y", label="Chart Time Range")
407
  show_bb = gr.Checkbox(label="Show Bollinger Bands", value=True)
408
+ force_refresh = gr.Checkbox(label="Force Refresh Data", value=False)
409
  analyze_btn = gr.Button("Analyze Stocks", variant="primary")
410
  status_output = gr.Textbox(label="Status", interactive=False)
 
411
 
412
  with gr.Column(scale=4):
413
+ gr.Markdown("### Comparative Price Chart")
414
  technical_plot_output = gr.Plot()
 
415
 
416
+ # Detailed Analysis for Primary Ticker
417
+ gr.Markdown("### 🔍 Detailed Analysis (First Ticker)")
418
+ with gr.Row():
419
+ summary_output = gr.Markdown()
420
+ decision_gauge_output = gr.Plot()
421
+
422
+ with gr.Row():
423
+ sentiment_output = gr.Plot()
424
+ forecast_output = gr.Plot()
425
+
426
+ # NO gr.Progress() in layout!
427
  analyze_btn.click(
428
  fn=run_full_analysis,
429
  inputs=[tickers_input, time_range, show_bb, force_refresh],
430
+ outputs=[status_output, technical_plot_output, decision_gauge_output, sentiment_output, forecast_output]
431
  )
432
 
433
  if __name__ == "__main__":
434
+ demo.launch()