JayLacoma commited on
Commit
b8eab1a
·
verified ·
1 Parent(s): 5fd21fb

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +181 -480
app.py CHANGED
@@ -1,508 +1,209 @@
1
- import os
 
2
  import pandas as pd
3
  import numpy as np
4
- import gradio as gr
5
- import plotly.graph_objects as go
6
- from plotly.subplots import make_subplots
7
  import plotly.express as px
8
- import yfinance as yf
9
- import requests
10
  from datetime import datetime, timedelta
11
- from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
12
- from prophet import Prophet
13
- import warnings
14
- import logging
15
- import asyncio
16
- import concurrent.futures
17
- import tempfile
18
- from functools import lru_cache
19
- from typing import Dict, List, Optional, Any, Tuple
20
-
21
- warnings.filterwarnings("ignore")
22
- logging.basicConfig(level=logging.INFO)
23
- logger = logging.getLogger("unified_stock_app")
24
-
25
- # Optional: Try importing TimesFM
26
- TIMESFM_AVAILABLE = False
27
- try:
28
- import timesfm
29
- TIMESFM_AVAILABLE = True
30
- except ImportError:
31
- pass
32
-
33
- # ============================================================================
34
- # CONFIGURATION
35
- # ============================================================================
36
- class Config:
37
- FINNHUB_API_KEY = os.getenv("FINNHUB_API_KEY", "cuj17q1r01qm7p9n307gcuj17q1r01qm7p9n3080")
38
- DATA_DIR = "data_cache"
39
- CACHE_TTL_HOURS = 12
40
- SENTIMENT_DAYS = 90
41
- TECH_DATA_YEARS = 3
42
-
43
- @classmethod
44
- def initialize(cls):
45
- os.makedirs(cls.DATA_DIR, exist_ok=True)
46
 
47
- Config.initialize()
 
48
 
49
- # ============================================================================
50
- # CACHING UTILS
51
- # ============================================================================
52
- class CacheManager:
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
- df.to_csv(CacheManager.get_path(filename), index=False)
60
-
61
- @staticmethod
62
- def load_df(filename: str) -> Optional[pd.DataFrame]:
63
- path = CacheManager.get_path(filename)
64
- if not os.path.exists(path):
65
- return None
66
- file_mod_time = datetime.fromtimestamp(os.path.getmtime(path))
67
- if datetime.now() - file_mod_time > timedelta(hours=Config.CACHE_TTL_HOURS):
68
- return None
69
- try:
70
- df = pd.read_csv(path)
71
- for col in df.columns:
72
- if 'date' in col.lower():
73
- df[col] = pd.to_datetime(df[col])
74
- return df
75
- except Exception:
76
- return None
77
-
78
- # ============================================================================
79
- # FUNDAMENTALS MODULE
80
- # ============================================================================
81
- @lru_cache(maxsize=100)
82
- def get_financial_data(ticker: str) -> Optional[Dict[str, Any]]:
83
  try:
84
- stock = yf.Ticker(ticker)
85
- info = stock.info
86
- return {
87
- 'Ticker': ticker,
88
- 'PE_Ratio': info.get('forwardPE'),
89
- 'Debt_to_Equity': info.get('debtToEquity'),
90
- 'Revenue_Growth': info.get('revenueGrowth'),
91
- 'ROE': info.get('returnOnEquity'),
92
- 'ROA': info.get('returnOnAssets'),
93
- 'Gross_Margin': info.get('grossMargins'),
94
- 'EBITDA': info.get('ebitda'),
95
- 'Market_Cap': info.get('marketCap'),
96
- 'Dividend_Yield': info.get('dividendYield'),
97
- 'Profit_Margin': info.get('profitMargins'),
98
- 'EPS_Growth': info.get('earningsGrowth'),
99
- 'Price_to_Book': info.get('priceToBook'),
100
- 'Current_Price': info.get('currentPrice')
101
- }
102
- except Exception as e:
103
- logger.error(f"Error fetching data for {ticker}: {e}")
104
- return None
105
-
106
- async def fetch_data_concurrently(tickers: List[str]) -> List[Dict[str, Any]]:
107
- loop = asyncio.get_event_loop()
108
- with concurrent.futures.ThreadPoolExecutor() as executor:
109
- tasks = [loop.run_in_executor(executor, get_financial_data, ticker) for ticker in tickers]
110
- results = await asyncio.gather(*tasks)
111
- return [r for r in results if r is not None]
112
-
113
- def sanitize_financial_data(df: pd.DataFrame) -> pd.DataFrame:
114
- df = df.copy()
115
- for col in ['ROE', 'ROA', 'Profit_Margin', 'Gross_Margin']:
116
- if col in df.columns:
117
- df[col] = df[col].where((df[col] >= -2) & (df[col] <= 2), np.nan)
118
- for col in ['Revenue_Growth', 'EPS_Growth']:
119
- if col in df.columns:
120
- df[col] = df[col].where((df[col] >= -1) & (df[col] <= 5), np.nan)
121
- for col in ['Debt_to_Equity', 'Dividend_Yield']:
122
- if col in df.columns:
123
- df[col] = df[col].where(df[col] >= 0, np.nan)
124
- for col in ['PE_Ratio', 'Price_to_Book']:
125
- if col in df.columns:
126
- df[col] = df[col].where((df[col] > 0) & (df[col] < 1000), np.nan)
127
- for col in ['Market_Cap', 'EBITDA']:
128
- if col in df.columns:
129
- df[col] = df[col].where(df[col] > 0, np.nan)
130
- if 'Current_Price' in df.columns:
131
- df['Current_Price'] = df['Current_Price'].where(df['Current_Price'] > 0, np.nan)
132
  return df
133
 
134
- def normalize(series: pd.Series, reverse: bool = False, lower_percentile: float = 0.10, upper_percentile: float = 0.90) -> pd.Series:
135
- valid_series = series.dropna()
136
- if len(valid_series) == 0 or len(valid_series.unique()) <= 1:
137
- return pd.Series(5.0, index=series.index, dtype=float)
138
- q_low = valid_series.quantile(lower_percentile)
139
- q_high = valid_series.quantile(upper_percentile)
140
- if q_high <= q_low:
141
- return pd.Series(5.0, index=series.index, dtype=float)
142
- clipped = series.clip(q_low, q_high)
143
- normalized = (clipped - q_low) / (q_high - q_low)
144
- normalized = normalized.clip(0, 1)
145
- result = 10 * (1 - normalized) if reverse else 10 * normalized
146
- return result
147
-
148
- def calculate_scores(df: pd.DataFrame, growth_weight: float, value_weight: float, risk_weight: float) -> pd.DataFrame:
149
- scored_df = df.copy()
150
- scored_df['Revenue_Growth_Score'] = normalize(df['Revenue_Growth'])
151
- scored_df['EPS_Growth_Score'] = normalize(df['EPS_Growth'])
152
- scored_df['ROE_Score'] = normalize(df['ROE'])
153
- scored_df['ROA_Score'] = normalize(df['ROA'])
154
- scored_df['Growth_Score'] = scored_df[['Revenue_Growth_Score', 'EPS_Growth_Score', 'ROE_Score', 'ROA_Score']].mean(axis=1)
155
-
156
- scored_df['PE_Ratio_Score'] = normalize(df['PE_Ratio'], reverse=True)
157
- scored_df['Price_to_Book_Score'] = normalize(df['Price_to_Book'], reverse=True)
158
- scored_df['Dividend_Yield_Score'] = normalize(df['Dividend_Yield'])
159
- scored_df['Value_Score'] = scored_df[['PE_Ratio_Score', 'Price_to_Book_Score', 'Dividend_Yield_Score']].mean(axis=1)
160
-
161
- scored_df['Debt_to_Equity_No_Risk_Score'] = normalize(df['Debt_to_Equity'], reverse=True)
162
- scored_df['Profit_Margin_No_Risk_Score'] = normalize(df['Profit_Margin'])
163
- scored_df['Market_Cap_No_Risk_Score'] = normalize(df['Market_Cap'])
164
- scored_df['No_Risk_Score'] = scored_df[['Debt_to_Equity_No_Risk_Score', 'Profit_Margin_No_Risk_Score', 'Market_Cap_No_Risk_Score']].mean(axis=1)
165
-
166
- total = growth_weight + value_weight + risk_weight
167
- if total == 0:
168
- gw = vw = rw = 1/3
169
- else:
170
- gw, vw, rw = growth_weight/total, value_weight/total, risk_weight/total
171
- scored_df['Total_Score'] = gw * scored_df['Growth_Score'] + vw * scored_df['Value_Score'] + rw * scored_df['No_Risk_Score']
172
- return scored_df
173
-
174
- def create_metrics_table(df: pd.DataFrame) -> pd.DataFrame:
175
- metrics_df = df[['Ticker', 'Current_Price', 'PE_Ratio', 'Price_to_Book',
176
- 'Debt_to_Equity', 'ROE', 'ROA', 'Revenue_Growth',
177
- 'EPS_Growth', 'Profit_Margin', 'Dividend_Yield']].copy()
178
- for col in ['ROE', 'ROA', 'Revenue_Growth', 'EPS_Growth', 'Profit_Margin', 'Dividend_Yield']:
179
- metrics_df[col] = metrics_df[col].apply(lambda x: f"{x*100:.2f}%" if pd.notnull(x) else "N/A")
180
- for col in ['PE_Ratio', 'Price_to_Book', 'Debt_to_Equity']:
181
- metrics_df[col] = metrics_df[col].apply(lambda x: f"{x:.2f}" if pd.notnull(x) else "N/A")
182
- metrics_df['Current_Price'] = metrics_df['Current_Price'].apply(lambda x: f"${x:.2f}" if pd.notnull(x) else "N/A")
183
- return metrics_df
184
-
185
- # ============================================================================
186
- # SENTIMENT MODULE
187
- # ============================================================================
188
- class SentimentAnalyzer:
189
- def __init__(self):
190
- self.analyzer = SentimentIntensityAnalyzer()
191
- def analyze(self, text):
192
- if not isinstance(text, str) or not text.strip():
193
- return 0
194
- return self.analyzer.polarity_scores(text)['compound']
195
-
196
- class StockNewsAnalyzer:
197
- def __init__(self, symbol):
198
- self.symbol = symbol
199
- self.sentiment_analyzer = SentimentAnalyzer()
200
-
201
- def get_news(self, days=Config.SENTIMENT_DAYS, force_refresh=False):
202
- cache_file = f"{self.symbol}_news.csv"
203
- df = CacheManager.load_df(cache_file)
204
- if df is not None and not force_refresh:
205
- return df
206
-
207
- end_date = datetime.now()
208
- start_date = end_date - timedelta(days=days)
209
- # FIXED URL: NO TRAILING SPACES!
210
- url = "https://finnhub.io/api/v1/company-news"
211
- params = {
212
- "symbol": self.symbol,
213
- "from": start_date.strftime('%Y-%m-%d'),
214
- "to": end_date.strftime('%Y-%m-%d'),
215
- "token": Config.FINNHUB_API_KEY,
216
- }
217
- try:
218
- response = requests.get(url, params=params, timeout=10)
219
- response.raise_for_status()
220
- data = response.json()
221
- if not data or not isinstance(data, list):
222
- return pd.DataFrame()
223
- df = pd.DataFrame(data)
224
- if 'datetime' in df.columns:
225
- df['datetime'] = pd.to_datetime(df['datetime'], unit='s')
226
- CacheManager.save_df(df, cache_file)
227
- return df
228
- return pd.DataFrame()
229
- except Exception as e:
230
- print(f"Error fetching news: {e}")
231
- return pd.DataFrame()
232
-
233
- def get_sentiment_data(self, days=Config.SENTIMENT_DAYS, force_refresh=False):
234
- news_df = self.get_news(days, force_refresh)
235
- if news_df.empty or 'headline' not in news_df.columns:
236
- return None, None
237
- news_df['sentiment'] = news_df['headline'].apply(self.sentiment_analyzer.analyze)
238
- news_df['date'] = pd.to_datetime(news_df['datetime'].dt.date)
239
- daily = news_df.groupby('date').agg(
240
- avg_sentiment=('sentiment', 'mean'),
241
- article_count=('sentiment', 'count'),
242
- positive_count=('sentiment', lambda x: (x > 0.05).sum()),
243
- negative_count=('sentiment', lambda x: (x < -0.05).sum()),
244
- neutral_count=('sentiment', lambda x: ((x >= -0.05) & (x <= 0.05)).sum())
245
- ).reset_index()
246
- return daily, news_df
247
-
248
- def create_sentiment_plot(daily_sentiment: pd.DataFrame, symbol: str) -> go.Figure:
249
- fig = make_subplots(rows=2, cols=1, shared_xaxes=True, row_heights=[0.7, 0.3])
250
- fig.add_trace(go.Scatter(x=daily_sentiment['date'], y=daily_sentiment['avg_sentiment'], name='Avg Sentiment', line=dict(color='#ff7f0e')), row=1, col=1)
251
- fig.add_trace(go.Bar(x=daily_sentiment['date'], y=daily_sentiment['positive_count'], name='Positive', marker_color='rgba(0,200,0,0.7)'), row=2, col=1)
252
- fig.add_trace(go.Bar(x=daily_sentiment['date'], y=daily_sentiment['negative_count'], name='Negative', marker_color='rgba(255,0,0,0.7)'), row=2, col=1)
253
- fig.add_trace(go.Bar(x=daily_sentiment['date'], y=daily_sentiment['neutral_count'], name='Neutral', marker_color='rgba(128,128,128,0.6)'), row=2, col=1)
254
- fig.update_layout(title=f"{symbol} News Sentiment (Last {Config.SENTIMENT_DAYS} Days)", template="plotly_dark", barmode='stack', height=500)
255
- return fig
256
-
257
- # ============================================================================
258
- # TECHNICAL & FORECASTING MODULES
259
- # ============================================================================
260
- def calculate_rsi(df):
261
- delta = df['Close'].diff()
262
- gain = (delta.where(delta > 0, 0)).rolling(14).mean()
263
- loss = (-delta.where(delta < 0, 0)).rolling(14).mean()
264
- rs = gain / loss
265
- return 100 - (100 / (1 + rs))
266
-
267
- def calculate_bollinger_bands(df):
268
- ma = df['Close'].rolling(20).mean()
269
- std = df['Close'].rolling(20).std()
270
- return ma, ma + 2*std, ma - 2*std
271
-
272
- def calculate_stochastic_oscillator(df):
273
- ll = df['Low'].rolling(14).min()
274
- hh = df['High'].rolling(14).max()
275
- k = ((df['Close'] - ll) / (hh - ll)) * 100
276
- d = k.rolling(3).mean()
277
- return k, d
278
-
279
- def calculate_cmf(df, window=20):
280
- price_range = df['High'] - df['Low']
281
- price_range = price_range.replace(0, np.nan)
282
- mfv = ((df['Close'] - df['Low']) - (df['High'] - df['Close'])) / price_range * df['Volume']
283
- mfv_sum = mfv.rolling(window).sum()
284
- vol_sum = df['Volume'].rolling(window).sum()
285
- return np.where(vol_sum > 0, mfv_sum / vol_sum, np.nan)
286
 
287
- def generate_signals(df):
288
  df = df.copy()
289
- df['RSI'] = calculate_rsi(df)
290
- df['MiddleBB'], df['UpperBB'], df['LowerBB'] = calculate_bollinger_bands(df)
291
- df['SlowK'], df['SlowD'] = calculate_stochastic_oscillator(df)
292
- df['CMF'] = calculate_cmf(df)
293
-
294
- df['RSI_Signal'] = np.where(df['RSI'] < 20, 1, np.where(df['RSI'] > 80, -1, 0))
295
- df['BB_Signal'] = np.where(df['Close'] < df['LowerBB'], 1, np.where(df['Close'] > df['UpperBB'], -1, 0))
296
- df['Stochastic_Signal'] = np.where((df['SlowK'] < 15) & (df['SlowD'] < 15), 1, np.where((df['SlowK'] > 85) & (df['SlowD'] > 85), -1, 0))
297
- df['CMF_Signal'] = np.where(df['CMF'] < -0.25, 1, np.where(df['CMF'] > 0.25, -1, 0))
298
- df['Technical_Score'] = df[['RSI_Signal', 'BB_Signal', 'Stochastic_Signal', 'CMF_Signal']].sum(axis=1)
 
 
299
  return df
300
 
301
- def prophet_forecast(ticker, start_date, end_date):
302
- try:
303
- df = yf.download(ticker, start=start_date, end=end_date)
304
- if isinstance(df.columns, pd.MultiIndex):
305
- df.columns = df.columns.droplevel(1)
306
- df_plot = df.reset_index()[['Date', 'Close']].rename(columns={'Date': 'ds', 'Close': 'y'})
307
- model = Prophet()
308
- model.fit(df_plot)
309
- future = model.make_future_dataframe(periods=30)
310
- forecast = model.predict(future)
311
- fig1 = go.Figure()
312
- fig1.add_trace(go.Scatter(x=df_plot['ds'], y=df_plot['y'], mode='lines', name='Actual'))
313
- fig1.add_trace(go.Scatter(x=forecast['ds'], y=forecast['trend'], mode='lines', name='Trend'))
314
- fig1.update_layout(title=f"{ticker} Price & Trend", template="plotly_dark")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
316
- fig2 = go.Figure()
317
- tail = forecast[['ds', 'yhat', 'yhat_lower', 'yhat_upper']].tail(40)
318
- fig2.add_trace(go.Scatter(x=tail['ds'], y=tail['yhat'], mode='lines', name='Forecast'))
319
- fig2.add_trace(go.Scatter(x=list(tail['ds']) + list(tail['ds'])[::-1],
320
- y=list(tail['yhat_upper']) + list(tail['yhat_lower'])[::-1],
321
- fill='toself', name='Confidence'))
322
- fig2.update_layout(title=f"{ticker} 30-Day Forecast", template="plotly_dark")
323
- return fig1, fig2
324
- except Exception as e:
325
- empty = go.Figure()
326
- empty.add_annotation(text=f"Error: {e}", x=0.5, y=0.5, xref="paper", yref="paper")
327
- return empty, empty
 
 
 
 
 
 
 
 
 
328
 
329
- def timesfm_forecast(ticker, start_date, end_date):
330
- if not TIMESFM_AVAILABLE:
 
 
 
 
 
331
  fig = go.Figure()
332
- fig.add_annotation(text="TimesFM not installed", x=0.5, y=0.5, xref="paper", yref="paper")
333
- fig.update_layout(template="plotly_dark")
334
- return fig
335
- try:
336
- df = yf.download(ticker, start=start_date, end=end_date)
337
- if isinstance(df.columns, pd.MultiIndex):
338
- df.columns = df.columns.droplevel(1)
339
- df = df.reset_index()[['Date', 'Close']].rename(columns={'Date': 'ds', 'Close': 'y'})
340
- df['ds'] = pd.to_datetime(df['ds'])
341
- df['unique_id'] = ticker
342
-
343
- tfm = timesfm.TimesFm(
344
- hparams=timesfm.TimesFmHparams(
345
- backend="pytorch",
346
- per_core_batch_size=32,
347
- horizon_len=30,
348
- input_patch_len=32,
349
- output_patch_len=128,
350
- num_layers=50,
351
- model_dims=1280,
352
- ),
353
- checkpoint=timesfm.TimesFmCheckpoint(huggingface_repo_id="google/timesfm-2.0-500m-pytorch")
354
  )
355
- forecast_df = tfm.forecast_on_df(inputs=df, freq="D", value_name="y")
356
- forecast_df.rename(columns={"timesfm": "forecast"}, inplace=True)
357
-
358
- fig = go.Figure()
359
- fig.add_trace(go.Scatter(x=df["ds"], y=df["y"], mode="lines", name="Actual"))
360
- fig.add_trace(go.Scatter(x=forecast_df["ds"], y=forecast_df["forecast"], mode="lines", name="Forecast"))
361
- fig.update_layout(title=f"{ticker} TimesFM Forecast", template="plotly_dark")
362
- return fig
363
- except Exception as e:
364
- fig = go.Figure()
365
- fig.add_annotation(text=f"TimesFM Error: {e}", x=0.5, y=0.5, xref="paper", yref="paper")
366
- fig.update_layout(template="plotly_dark")
367
  return fig
 
368
 
369
- def plot_technical_signals(df, ticker):
370
- df = generate_signals(df)
371
- df_120 = df.tail(120)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  fig = go.Figure()
373
- fig.add_trace(go.Scatter(x=df_120.index, y=df_120['Close'], mode='lines', name='Price'))
374
- buy = df_120[df_120['Technical_Score'] > 0]
375
- sell = df_120[df_120['Technical_Score'] < 0]
376
- fig.add_trace(go.Scatter(x=buy.index, y=buy['Close'], mode='markers', name='Buy', marker=dict(symbol='triangle-up', color='green')))
377
- fig.add_trace(go.Scatter(x=sell.index, y=sell['Close'], mode='markers', name='Sell', marker=dict(symbol='triangle-down', color='red')))
378
- fig.update_layout(title=f"{ticker} Technical Signals", template="plotly_dark")
 
 
 
 
 
379
  return fig
380
 
381
- # ============================================================================
382
- # MAIN ANALYSIS FUNCTION
383
- # ============================================================================
384
- async def run_unified_analysis(
385
- tickers_str: str,
386
- start_date: str,
387
- end_date: str,
388
- days_sentiment: int,
389
- refresh_news: bool,
390
- growth_w: float,
391
- value_w: float,
392
- risk_w: float
393
- ):
394
- tickers = [t.strip().upper() for t in tickers_str.split(",") if t.strip()][:5]
395
- if not tickers:
396
- empty = go.Figure()
397
- empty.add_annotation(text="Enter tickers", x=0.5, y=0.5, xref="paper", yref="paper")
398
- return ("No tickers",) + (empty,) * 8 + (pd.DataFrame(), pd.DataFrame())
399
-
400
- primary = tickers[0]
401
-
402
- # Fundamentals
403
- scores_df, metrics_df = pd.DataFrame(), pd.DataFrame()
404
- try:
405
- fund_data = await fetch_data_concurrently(tickers)
406
- if fund_data:
407
- df = pd.DataFrame(fund_data)
408
- df = sanitize_financial_data(df)
409
- numerical_cols = df.select_dtypes(include=[np.number]).columns
410
- for col in numerical_cols:
411
- df[col] = df[col].fillna(df[col].median() if not pd.isna(df[col].median()) else 0)
412
- df = calculate_scores(df, growth_w, value_w, risk_w)
413
- df = df.sort_values('Total_Score', ascending=False).reset_index(drop=True)
414
- scores_df = df[['Ticker', 'Total_Score', 'Growth_Score', 'Value_Score', 'No_Risk_Score']].round(2)
415
- metrics_df = create_metrics_table(df)
416
- except Exception as e:
417
- logger.error(f"Fundamentals error: {e}")
418
-
419
- # Sentiment
420
- sentiment_plot = go.Figure()
421
- try:
422
- analyzer = StockNewsAnalyzer(primary)
423
- daily_sent, _ = analyzer.get_sentiment_data(days=days_sentiment, force_refresh=refresh_news)
424
- if daily_sent is not None:
425
- sentiment_plot = create_sentiment_plot(daily_sent, primary)
426
- except Exception as e:
427
- logger.error(f"Sentiment error: {e}")
428
-
429
- # Technicals
430
- tech_plot = go.Figure()
431
- try:
432
- tech_df = yf.download(primary, start=start_date, end=end_date)
433
- if not tech_df.empty:
434
- if isinstance(tech_df.columns, pd.MultiIndex):
435
- tech_df.columns = tech_df.columns.droplevel(1)
436
- tech_plot = plot_technical_signals(tech_df, primary)
437
- except Exception as e:
438
- logger.error(f"Technical error: {e}")
439
-
440
- # Forecasting
441
- prophet_price, prophet_forecast = prophet_forecast(primary, start_date, end_date)
442
- timesfm_plot = timesfm_forecast(primary, start_date, end_date)
443
-
444
- return (
445
- f"Analysis for: {', '.join(tickers)}",
446
- scores_df,
447
- metrics_df,
448
- sentiment_plot,
449
- tech_plot,
450
- timesfm_plot,
451
- prophet_price,
452
- prophet_forecast
453
- )
454
-
455
- # ============================================================================
456
- # GRADIO INTERFACE
457
- # ============================================================================
458
- with gr.Blocks(theme=gr.themes.Soft()) as demo:
459
- gr.Markdown("# 🚀 Unified Stock Intelligence Platform")
460
- gr.Markdown("Fundamentals + Sentiment + Technicals + AI Forecasting")
461
-
462
- with gr.Row():
463
- tickers = gr.Textbox(label="Tickers (comma-separated)", value="NVDA, AAPL, MSFT")
464
- start_date = gr.Textbox(label="Start Date", value="2022-01-01")
465
- end_date = gr.Textbox(label="End Date", value="2026-01-01")
466
-
467
- with gr.Row():
468
- days_sentiment = gr.Slider(7, 90, value=90, label="Sentiment Days")
469
- refresh_news = gr.Checkbox(label="Refresh News", value=False)
470
- growth_w = gr.Slider(0, 1, 0.4, label="Growth Weight")
471
- value_w = gr.Slider(0, 1, 0.4, label="Value Weight")
472
- risk_w = gr.Slider(0, 1, 0.2, label="Risk Weight")
473
-
474
- run_btn = gr.Button("Analyze", variant="primary")
475
 
 
 
 
476
  with gr.Tabs():
477
- with gr.Tab("📊 Fundamentals"):
478
- scores_table = gr.Dataframe()
479
- metrics_table = gr.Dataframe()
480
-
481
- with gr.Tab("📰 Sentiment"):
482
- sentiment_plot = gr.Plot()
483
-
484
- with gr.Tab("📈 Technicals"):
485
- tech_plot = gr.Plot()
486
-
487
- with gr.Tab("🔮 Forecasting"):
488
- with gr.Row():
489
- timesfm_plot = gr.Plot()
490
- prophet_price = gr.Plot()
491
- prophet_forecast = gr.Plot()
492
-
493
- run_btn.click(
494
- lambda *args: asyncio.run(run_unified_analysis(*args)),
495
- inputs=[tickers, start_date, end_date, days_sentiment, refresh_news, growth_w, value_w, risk_w],
496
- outputs=[
497
- gr.Textbox(label="Status"),
498
- scores_table, metrics_table,
499
- sentiment_plot,
500
- tech_plot,
501
- timesfm_plot,
502
- prophet_price,
503
- prophet_forecast
504
- ]
505
- )
506
 
507
  if __name__ == "__main__":
508
  demo.launch()
 
1
+ # app.py
2
+ import gradio as gr
3
  import pandas as pd
4
  import numpy as np
 
 
 
5
  import plotly.express as px
6
+ import plotly.graph_objects as go
 
7
  from datetime import datetime, timedelta
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
+ # Import your data engine
10
+ from geo_macro import UnifiedMarketDataDownloader, FRED_API_KEY
11
 
12
+ # ======================
13
+ # DATA LOADING & CACHING
14
+ # ======================
 
 
 
 
15
 
16
+ @gr.cache
17
+ def load_or_download_data():
18
+ """
19
+ Load from CSV if exists, else download fresh data.
20
+ """
21
+ data_file = "unified_market_data.csv"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  try:
23
+ df = pd.read_csv(data_file, index_col=0, parse_dates=True)
24
+ print("✅ Loaded data from CSV")
25
+ except FileNotFoundError:
26
+ print("🔄 CSV not found. Downloading fresh data...")
27
+ downloader = UnifiedMarketDataDownloader(fred_api_key=FRED_API_KEY)
28
+ df = downloader.download_all_data(start_date='2018-01-01')
29
+ df.to_csv(data_file)
30
+ print(f"💾 Saved to {data_file}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  return df
32
 
33
+ # ======================
34
+ # FEATURE ENGINEERING
35
+ # ======================
36
+
37
+ def add_thematic_features(df):
38
+ THEMES = {
39
+ "AI & Datacenters": ["XLK", "SMH", "SKYY", "BOTZ", "FINX"],
40
+ "Defense & Security": ["ITA", "XAR", "HACK", "URA", "Aerospace_Defense"],
41
+ "Nuclear Renaissance": ["URA", "XLE", "Utilities"],
42
+ "China Stress": ["KWEB", "FXI", "CNY=X"],
43
+ "Commodity Inflation": ["DBA", "DBB", "Oil", "Copper", "Gold"],
44
+ "Gold & Safe Havens": ["GLD", "TLT", "JPY=X", "CHF=X", "Gold"],
45
+ "Early Cycle": ["IWN", "XHB", "Staffing", "Small_Cap_Value"],
46
+ "Late Cycle": ["VYM", "XLU", "Consumer_Staples", "High_Dividend"],
47
+ "Credit Stress": ["EMB", "HYG", "BKLN", "JNK", "Preferred_Stock"],
48
+ "Liquidity Conditions": ["M2", "WALCL", "Short_Term_Treasuries"]
49
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
 
51
  df = df.copy()
52
+ for name, assets in THEMES.items():
53
+ available = [a for a in assets if a in df.columns]
54
+ if available:
55
+ # Equal-weight momentum
56
+ mom = df[available].pct_change().mean(axis=1)
57
+ df[f"{name}_Momentum"] = mom.rolling(60).sum()
58
+ # Z-score over 2 years
59
+ mean = df[f"{name}_Momentum"].rolling(500, min_periods=100).mean()
60
+ std = df[f"{name}_Momentum"].rolling(500, min_periods=100).std()
61
+ df[f"{name}_Z"] = (df[f"{name}_Momentum"] - mean) / std
62
+ else:
63
+ df[f"{name}_Z"] = np.nan
64
  return df
65
 
66
+ # ======================
67
+ # PLOT FUNCTIONS (same as before, but use cached data)
68
+ # ======================
69
+
70
+ SHOCK_EVENTS = {
71
+ "2020-03-16 (Pandemic Crash)": "2020-03-16",
72
+ "2022-02-24 (Ukraine Invasion)": "2022-02-24",
73
+ "2023-03-10 (SVB Collapse)": "2023-03-10",
74
+ "2024-01-15 (Debt Ceiling)": "2024-01-15",
75
+ "2025-04-01 (Middle East Escalation)": "2025-04-01"
76
+ }
77
+
78
+ def get_processed_data():
79
+ df = load_or_download_data()
80
+ return add_thematic_features(df)
81
+
82
+ def plot_regime_dashboard(start_date, end_date):
83
+ df = get_processed_data()
84
+ df = df[(df.index >= pd.to_datetime(start_date)) & (df.index <= pd.to_datetime(end_date))]
85
+
86
+ z_cols = [col for col in df.columns if col.endswith('_Z')]
87
+ if not z_cols:
88
+ return go.Figure()
89
+
90
+ clean_names = [col.replace('_Z', '').replace('_', ' ') for col in z_cols]
91
+ heatmap_data = df[z_cols].fillna(0)
92
+
93
+ fig = go.Figure(data=go.Heatmap(
94
+ z=heatmap_data.T.values,
95
+ x=heatmap_data.index,
96
+ y=clean_names,
97
+ colorscale='RdBu',
98
+ zmid=0
99
+ ))
100
+ fig.update_layout(title="🌍 Thematic Regime Heatmap", height=500)
101
+ return fig
102
 
103
+ def plot_thematic_pulse(start_date, end_date):
104
+ df = get_processed_data()
105
+ df = df[(df.index >= pd.to_datetime(start_date)) & (df.index <= pd.to_datetime(end_date))]
106
+
107
+ z_cols = [f"{name}_Z" for name in [
108
+ "AI & Datacenters", "Defense & Security", "Nuclear Renaissance",
109
+ "China Stress", "Commodity Inflation", "Gold & Safe Havens",
110
+ "Early Cycle", "Late Cycle", "Credit Stress", "Liquidity Conditions"
111
+ ] if f"{name}_Z" in df.columns]
112
+
113
+ if not z_cols:
114
+ return go.Figure()
115
+
116
+ latest = df[z_cols].iloc[-1].dropna()
117
+ clean_names = [col.replace('_Z', '').replace('_', ' ') for col in latest.index]
118
+ latest.index = clean_names
119
+
120
+ colors = ['red' if x < -1.5 else 'green' if x > 1.5 else 'lightgray' for x in latest]
121
+ fig = go.Figure(go.Bar(x=latest.values, y=latest.index, orientation='h', marker_color=colors))
122
+ fig.update_layout(title="🔥 Thematic Pulse", height=500)
123
+ return fig
124
 
125
+ def plot_divergence(start_date, end_date):
126
+ df = get_processed_data()
127
+ df = df[(df.index >= pd.to_datetime(start_date)) & (df.index <= pd.to_datetime(end_date))]
128
+
129
+ if 'KWEB' in df.columns and 'SMH' in df.columns:
130
+ ratio = df['KWEB'] / df['SMH']
131
+ ratio_norm = (ratio - ratio.rolling(200).mean()) / ratio.rolling(200).std()
132
  fig = go.Figure()
133
+ fig.add_trace(go.Scatter(x=ratio.index, y=ratio, name='KWEB/SMH'))
134
+ fig.add_trace(go.Scatter(x=ratio_norm.index, y=ratio_norm, name='Z-Score', yaxis='y2'))
135
+ fig.update_layout(
136
+ title="🔄 China Tech vs Global Semis",
137
+ yaxis2=dict(overlaying='y', side='right')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  )
 
 
 
 
 
 
 
 
 
 
 
 
139
  return fig
140
+ return go.Figure()
141
 
142
+ def plot_shock_response(event_name, custom_date=None):
143
+ df = get_processed_data()
144
+
145
+ if event_name != "Custom Date":
146
+ event_date = pd.to_datetime(SHOCK_EVENTS[event_name])
147
+ else:
148
+ if not custom_date:
149
+ return go.Figure()
150
+ event_date = pd.to_datetime(custom_date)
151
+
152
+ window = 30
153
+ start = event_date - timedelta(days=window)
154
+ end = event_date + timedelta(days=window)
155
+ df_win = df[(df.index >= start) & (df.index <= end)]
156
+
157
+ if df_win.empty:
158
+ return go.Figure()
159
+
160
+ assets = ['SP500', 'Gold', 'TLT', 'VIX', 'Oil', 'KWEB', 'SMH', 'ITA']
161
  fig = go.Figure()
162
+ event_idx = df_win.index.get_indexer([event_date], method='nearest')[0]
163
+
164
+ for asset in assets:
165
+ if asset in df_win.columns:
166
+ prices = df_win[asset].dropna()
167
+ if len(prices) > 5:
168
+ norm = (prices / prices.iloc[event_idx]) * 100
169
+ fig.add_trace(go.Scatter(x=norm.index, y=norm, mode='lines', name=asset))
170
+
171
+ fig.add_vline(x=event_date, line_dash="dash", line_color="red")
172
+ fig.update_layout(title=f"📅 Shock: {event_name}", yaxis_title="Normalized to 100")
173
  return fig
174
 
175
+ # ======================
176
+ # GRADIO UI
177
+ # ======================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
 
179
+ with gr.Blocks(title="Macro-Thematic Intelligence") as demo:
180
+ gr.Markdown("## 🌐 Top-Down Thematic Intelligence Platform")
181
+
182
  with gr.Tabs():
183
+ with gr.Tab("🌍 Regime Dashboard"):
184
+ s1 = gr.Textbox("2022-01-01", label="Start")
185
+ e1 = gr.Textbox(datetime.today().strftime('%Y-%m-%d'), label="End")
186
+ p1 = gr.Plot()
187
+ gr.Button("Update").click(plot_regime_dashboard, [s1, e1], p1)
188
+
189
+ with gr.Tab("🔥 Thematic Pulse"):
190
+ s2 = gr.Textbox("2022-01-01", label="Start")
191
+ e2 = gr.Textbox(datetime.today().strftime('%Y-%m-%d'), label="End")
192
+ p2 = gr.Plot()
193
+ gr.Button("Update").click(plot_thematic_pulse, [s2, e2], p2)
194
+
195
+ with gr.Tab("🔄 Divergence"):
196
+ s3 = gr.Textbox("2022-01-01", label="Start")
197
+ e3 = gr.Textbox(datetime.today().strftime('%Y-%m-%d'), label="End")
198
+ p3 = gr.Plot()
199
+ gr.Button("Update").click(plot_divergence, [s3, e3], p3)
200
+
201
+ with gr.Tab("📅 Shock Explorer"):
202
+ evt = gr.Dropdown(list(SHOCK_EVENTS.keys()) + ["Custom Date"], value=list(SHOCK_EVENTS.keys())[0])
203
+ cdt = gr.Textbox("", visible=False)
204
+ evt.change(lambda x: gr.update(visible=x=="Custom Date"), evt, cdt)
205
+ p4 = gr.Plot()
206
+ gr.Button("Plot").click(plot_shock_response, [evt, cdt], p4)
 
 
 
 
 
207
 
208
  if __name__ == "__main__":
209
  demo.launch()