Jitendra12421 commited on
Commit
34efb38
Β·
verified Β·
1 Parent(s): 3ba06b5

Upload 11 files

Browse files
Files changed (4) hide show
  1. app.py +54 -34
  2. data/scraper.py +122 -37
  3. engine/analytics.py +214 -24
  4. requirements.txt +1 -0
app.py CHANGED
@@ -6,7 +6,7 @@ from datetime import datetime
6
  from data.scraper import NewsScraper
7
  from engine.analytics import AnalyticsEngine
8
 
9
- scraper = NewsScraper()
10
  engine = AnalyticsEngine()
11
 
12
  # Simple persistent cache
@@ -17,32 +17,33 @@ async def run_pipeline(ticker, date_str, progress=gr.Progress()):
17
  print(f"\n--- Request: {cache_key} ---")
18
 
19
  if cache_key in persistent_cache:
20
- progress(1.0, "Result loaded from Pulse Memory.")
21
  return persistent_cache[cache_key]
22
 
23
  start_total = time.time()
24
  try:
25
- # Validate inputs
26
- if not ticker: return "Error: No ticker provided", pd.DataFrame()
27
 
28
- # 0. Initialize
29
- progress(0.01, "Neural Cluster: Online")
30
- await asyncio.sleep(0.2) # Async sync for stream stability
 
31
 
32
  try:
33
- # Parse date but strip time/tz for the scraper to be more lenient
34
  dt = datetime.strptime(date_str, "%Y-%m-%d")
35
  except Exception as e:
36
  return f"Error: Invalid Date Format ({str(e)})", pd.DataFrame()
37
 
38
- # Status: Cleaning
39
  scraper.cleanup()
40
 
41
- # 1. Scrape Phase
42
- progress(0.05, "Pulse Engine: Starting Aggressive Harvesting...")
43
  s_start = time.time()
44
- # Scale scraper progress to 0.05 -> 0.45
45
- articles = await scraper.scrape(ticker, dt, progress_cb=lambda val, msg: progress(0.05 + (val * 0.4), f"Harvest: {msg}"))
 
 
46
  s_time = time.time() - s_start
47
 
48
  if not articles:
@@ -50,52 +51,71 @@ async def run_pipeline(ticker, date_str, progress=gr.Progress()):
50
 
51
  df = pd.DataFrame(articles)
52
  total_analyzed = len(df)
 
 
 
 
53
 
54
- # 2. Analyze Phase
55
- progress(0.5, f"Neural Phase: Processing {total_analyzed} headlines...")
56
  a_start = time.time()
57
- # Wrap blocking analysis in a thread to keep progress bar responsive
58
- df = await asyncio.to_thread(engine.analyze, df, ticker, progress_cb=lambda val, msg: progress(0.5 + (val * 0.4), msg))
59
- summary = engine.get_summary(df)
 
 
 
 
 
 
60
  a_time = time.time() - a_start
61
 
62
  total_time = time.time() - start_total
63
 
64
  # 3. Format Output
65
- progress(0.95, "Finalizing Pulse Report...")
66
- report_text = f"πŸš€ {ticker} PULSE AI INTELLIGENCE\n"
 
67
  report_text += f"{'='*30}\n"
68
  report_text += f"PHASE STATS:\n"
69
- report_text += f" > Data Harvest : {s_time:.2f}s\n"
70
- report_text += f" > Neural Compute: {a_time:.2f}s\n"
71
- report_text += f" > TOTAL TIME : {total_time:.2f}s\n"
72
- report_text += f" > ANALYZED : {total_analyzed} articles\n"
73
  report_text += f"{'='*30}\n\n"
74
  report_text += f"QUICK VIBE: {summary['vibe']}/10\n"
75
- report_text += f"AVG POLARITY: {summary['avg_polarity']:.3f}\n\n"
 
 
 
 
 
 
76
  report_text += f"πŸ”₯ HEAVY HITTER SIGNALS:\n"
77
  for a in summary['heavy_hitters']:
78
  report_text += f"- {a['title']}\n"
79
 
80
- result = (report_text, df.head(50))
81
  persistent_cache[cache_key] = result
82
- progress(1.0, "Intelligence Generated.")
83
  return result
84
 
85
  except Exception as e:
86
- print(f"Pipeline Crash: {str(e)}")
87
- return f"CRITICAL ERROR: {str(e)}", pd.DataFrame()
 
 
88
 
89
  def demo():
90
- with gr.Blocks(title="Pulse AI Intelligence") as app:
91
- gr.Markdown("# πŸš€ Pulse AI: Market Intelligence")
92
  with gr.Row():
93
  ticker = gr.Textbox(label="Ticker Symbol", value="TSLA")
94
  date = gr.Textbox(label="Lookback Date (YYYY-MM-DD)", value="2024-01-01")
95
 
96
- btn = gr.Button("Fetch Intelligence")
97
- output = gr.Textbox(label="Pulse Report Summary")
98
- table = gr.Dataframe(label="Neural Dataset")
99
 
100
  btn.click(
101
  fn=run_pipeline,
 
6
  from data.scraper import NewsScraper
7
  from engine.analytics import AnalyticsEngine
8
 
9
+ scraper = NewsScraper(limit=600)
10
  engine = AnalyticsEngine()
11
 
12
  # Simple persistent cache
 
17
  print(f"\n--- Request: {cache_key} ---")
18
 
19
  if cache_key in persistent_cache:
20
+ progress(1.0, "Loaded from cache.")
21
  return persistent_cache[cache_key]
22
 
23
  start_total = time.time()
24
  try:
25
+ if not ticker:
26
+ return "Error: No ticker provided", pd.DataFrame()
27
 
28
+ # 0. Send estimated time immediately
29
+ estimated_seconds = engine.estimate_time(scraper.limit)
30
+ progress(0.01, f"ETA:{estimated_seconds}s | Initializing...")
31
+ await asyncio.sleep(0.2)
32
 
33
  try:
 
34
  dt = datetime.strptime(date_str, "%Y-%m-%d")
35
  except Exception as e:
36
  return f"Error: Invalid Date Format ({str(e)})", pd.DataFrame()
37
 
 
38
  scraper.cleanup()
39
 
40
+ # 1. Scrape Phase (0.02 β†’ 0.30)
41
+ progress(0.02, f"ETA:{estimated_seconds}s | Collecting headlines...")
42
  s_start = time.time()
43
+ articles = await scraper.scrape(
44
+ ticker, dt,
45
+ progress_cb=lambda val, msg: progress(0.02 + (val * 0.28), f"ETA:{estimated_seconds}s | {msg}")
46
+ )
47
  s_time = time.time() - s_start
48
 
49
  if not articles:
 
51
 
52
  df = pd.DataFrame(articles)
53
  total_analyzed = len(df)
54
+
55
+ # Recalculate ETA now that we know article count
56
+ estimated_seconds = engine.estimate_time(total_analyzed)
57
+ remaining = max(0, estimated_seconds - int(time.time() - start_total))
58
 
59
+ # 2. Analyze Phase (0.30 β†’ 0.90)
60
+ progress(0.30, f"ETA:{remaining}s | Running sentiment models on {total_analyzed} articles...")
61
  a_start = time.time()
62
+
63
+ def run_analysis():
64
+ return engine.analyze(
65
+ df, ticker,
66
+ progress_cb=lambda val, msg: progress(0.30 + (val * 0.60), f"ETA:{remaining}s | {msg}")
67
+ )
68
+
69
+ analyzed_df = await asyncio.to_thread(run_analysis)
70
+ summary = engine.get_summary(analyzed_df)
71
  a_time = time.time() - a_start
72
 
73
  total_time = time.time() - start_total
74
 
75
  # 3. Format Output
76
+ progress(0.95, "Generating report...")
77
+
78
+ report_text = f"{ticker} SENTIMENT REPORT\n"
79
  report_text += f"{'='*30}\n"
80
  report_text += f"PHASE STATS:\n"
81
+ report_text += f" > Scraping : {s_time:.2f}s\n"
82
+ report_text += f" > ML Analysis : {a_time:.2f}s\n"
83
+ report_text += f" > TOTAL TIME : {total_time:.2f}s\n"
84
+ report_text += f" > ANALYZED : {total_analyzed} articles\n"
85
  report_text += f"{'='*30}\n\n"
86
  report_text += f"QUICK VIBE: {summary['vibe']}/10\n"
87
+ report_text += f"AVG POLARITY: {summary['avg_polarity']:.3f}\n"
88
+ report_text += f"DIRECTION RATIO: {summary['dir_ratio']:.3f}\n"
89
+ report_text += f"CONVICTION SCORE: {summary['conviction_weighted']:.3f}\n"
90
+ report_text += f"MODEL AGREEMENT: {summary['agreement_rate']:.1%}\n"
91
+ report_text += f"MOMENTUM TREND: {summary['momentum_delta']:+.3f}\n"
92
+ report_text += f"COMPOSITE SCORE: {summary['composite_score']:.3f}\n"
93
+ report_text += f"TAIL RISK: {summary['tail_risk']:.1%}\n\n"
94
  report_text += f"πŸ”₯ HEAVY HITTER SIGNALS:\n"
95
  for a in summary['heavy_hitters']:
96
  report_text += f"- {a['title']}\n"
97
 
98
+ result = (report_text, analyzed_df.head(50))
99
  persistent_cache[cache_key] = result
100
+ progress(1.0, "Done.")
101
  return result
102
 
103
  except Exception as e:
104
+ print(f"Pipeline Error: {str(e)}")
105
+ import traceback
106
+ traceback.print_exc()
107
+ return f"ERROR: {str(e)}", pd.DataFrame()
108
 
109
  def demo():
110
+ with gr.Blocks(title="Sentiment Analyzer") as app:
111
+ gr.Markdown("# Sentiment Analyzer")
112
  with gr.Row():
113
  ticker = gr.Textbox(label="Ticker Symbol", value="TSLA")
114
  date = gr.Textbox(label="Lookback Date (YYYY-MM-DD)", value="2024-01-01")
115
 
116
+ btn = gr.Button("Analyze Sentiment")
117
+ output = gr.Textbox(label="Report")
118
+ table = gr.Dataframe(label="Dataset")
119
 
120
  btn.click(
121
  fn=run_pipeline,
data/scraper.py CHANGED
@@ -1,8 +1,8 @@
1
  import asyncio
2
  import aiohttp
3
  import xml.etree.ElementTree as ET
4
- import sys
5
  import ssl
 
6
  from datetime import datetime
7
  from email.utils import parsedate_to_datetime
8
  import glob
@@ -17,17 +17,18 @@ class NewsScraper:
17
 
18
  async def fetch_feed(self, session, url):
19
  try:
20
- async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as response:
21
  if response.status == 200:
22
  return await response.text()
23
- except: pass
 
24
  return ""
25
 
26
  def parse_feed(self, xml_text, lookback_date):
27
  articles = []
28
- if not xml_text: return articles
 
29
  try:
30
- # RSS Dates are often UTC, so we compare dates (year/month/day) to be safe
31
  lb_date = lookback_date.date()
32
  root = ET.fromstring(xml_text)
33
  for item in root.findall('.//item'):
@@ -37,55 +38,139 @@ class NewsScraper:
37
  if title and link and pub_date_str:
38
  try:
39
  pub_dt = parsedate_to_datetime(pub_date_str)
40
- # More lenient: if it's the same day or later, we keep it
41
  if pub_dt.date() >= lb_date:
42
  articles.append({
43
- 'title': title, 'link': link,
44
  'pub_date': pub_date_str, 'timestamp': pub_dt.isoformat()
45
  })
46
- except: pass
47
- except: pass
 
 
48
  return articles
49
 
50
- async def scrape(self, ticker, lookback_date, progress_cb=None):
51
- # Even more aggressive query set
52
- queries = [
53
- ticker, f"{ticker} stock", f"{ticker} news", f"{ticker} market",
54
- f"{ticker} earnings", f"{ticker} analyst", f"{ticker} forecast",
55
- f"{ticker} price target", f"{ticker} institutional", f"{ticker} hedge fund",
56
- f"{ticker} options", f"{ticker} technical", f"{ticker} dividend",
57
- f"{ticker} industry", f"{ticker} competitor", f"{ticker} share price"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  ]
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  all_articles = []
61
  seen = set()
62
 
63
- connector = aiohttp.TCPConnector(limit=100, ssl=self.ssl_context)
 
64
  async with aiohttp.ClientSession(connector=connector) as session:
65
- for i, q in enumerate(queries):
66
- if len(all_articles) >= self.limit: break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- # Signal activity even if no results found yet
69
- if progress_cb:
70
- progress_cb(len(all_articles) / self.limit, f"Searching: '{q}' ({len(all_articles)} found)")
71
-
72
- url = f"https://news.google.com/rss/search?q={q.replace(' ', '+')}&hl=en-US&gl=US&ceid=US:en"
73
- xml = await self.fetch_feed(session, url)
74
- res = self.parse_feed(xml, lookback_date)
 
 
 
75
 
76
- for a in res:
77
- if a['link'] not in seen:
78
- seen.add(a['link'])
79
- all_articles.append(a)
80
- # Update progress frequently
81
- if len(all_articles) % 10 == 0 and progress_cb:
82
- progress_cb(len(all_articles) / self.limit, f"Harvested {len(all_articles)}/{self.limit} articles...")
83
- if len(all_articles) >= self.limit: break
84
 
 
 
 
 
85
  return all_articles[:self.limit]
86
 
87
  @staticmethod
88
  def cleanup():
89
  for f in glob.glob("*.csv"):
90
- try: os.remove(f)
91
- except: pass
 
 
 
1
  import asyncio
2
  import aiohttp
3
  import xml.etree.ElementTree as ET
 
4
  import ssl
5
+ import re
6
  from datetime import datetime
7
  from email.utils import parsedate_to_datetime
8
  import glob
 
17
 
18
  async def fetch_feed(self, session, url):
19
  try:
20
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=8)) as response:
21
  if response.status == 200:
22
  return await response.text()
23
+ except:
24
+ pass
25
  return ""
26
 
27
  def parse_feed(self, xml_text, lookback_date):
28
  articles = []
29
+ if not xml_text:
30
+ return articles
31
  try:
 
32
  lb_date = lookback_date.date()
33
  root = ET.fromstring(xml_text)
34
  for item in root.findall('.//item'):
 
38
  if title and link and pub_date_str:
39
  try:
40
  pub_dt = parsedate_to_datetime(pub_date_str)
 
41
  if pub_dt.date() >= lb_date:
42
  articles.append({
43
+ 'title': title, 'link': link,
44
  'pub_date': pub_date_str, 'timestamp': pub_dt.isoformat()
45
  })
46
+ except:
47
+ pass
48
+ except:
49
+ pass
50
  return articles
51
 
52
+ def _build_queries(self, ticker):
53
+ """Generate a massive, diverse set of search queries to maximize article yield."""
54
+ t = ticker
55
+ base = [
56
+ t, f"{t} stock", f"{t} news", f"{t} market", f"{t} earnings",
57
+ f"{t} analyst", f"{t} forecast", f"{t} price target",
58
+ f"{t} options", f"{t} technical", f"{t} dividend",
59
+ f"{t} industry", f"{t} competitor", f"{t} share price",
60
+ f"{t} hedge fund", f"{t} institutional",
61
+ ]
62
+
63
+ # Financial action queries
64
+ actions = [
65
+ f"{t} buy sell hold", f"{t} upgrade downgrade", f"{t} outperform underperform",
66
+ f"{t} bullish bearish", f"{t} momentum", f"{t} breakout breakdown",
67
+ f"{t} rally crash", f"{t} surge plunge", f"{t} soar tumble",
68
+ f"{t} gains losses", f"{t} beat miss expectations",
69
+ ]
70
+
71
+ # Corporate event queries
72
+ events = [
73
+ f"{t} CEO news", f"{t} quarterly results", f"{t} revenue profit",
74
+ f"{t} guidance outlook", f"{t} acquisition merger",
75
+ f"{t} lawsuit legal SEC", f"{t} insider trading",
76
+ f"{t} IPO offering", f"{t} buyback repurchase",
77
+ f"{t} partnership deal", f"{t} product launch",
78
+ f"{t} layoffs restructuring", f"{t} expansion growth",
79
+ ]
80
+
81
+ # Analyst and research queries
82
+ research = [
83
+ f"{t} wall street", f"{t} Goldman Sachs", f"{t} Morgan Stanley",
84
+ f"{t} JP Morgan", f"{t} analyst rating", f"{t} price prediction",
85
+ f"{t} short interest", f"{t} short squeeze",
86
+ f"{t} put call ratio", f"{t} unusual activity",
87
+ f"{t} fund holdings", f"{t} 13F filing",
88
+ ]
89
+
90
+ # Sector and macro queries
91
+ macro = [
92
+ f"{t} sector outlook", f"{t} industry trend", f"{t} supply chain",
93
+ f"{t} regulation policy", f"{t} inflation impact",
94
+ f"{t} interest rate", f"{t} trade war tariff",
95
+ f"{t} innovation technology", f"{t} ESG sustainability",
96
  ]
97
 
98
+ # Time-sensitive queries
99
+ time_q = [
100
+ f"{t} today", f"{t} this week", f"{t} latest",
101
+ f"{t} breaking news", f"{t} update",
102
+ f"{t} premarket", f"{t} after hours",
103
+ ]
104
+
105
+ all_queries = base + actions + events + research + macro + time_q
106
+ return all_queries
107
+
108
+ async def scrape(self, ticker, lookback_date, progress_cb=None):
109
+ queries = self._build_queries(ticker)
110
+ total_queries = len(queries)
111
+
112
  all_articles = []
113
  seen = set()
114
 
115
+ # Batch fetch: fire all requests concurrently for speed
116
+ connector = aiohttp.TCPConnector(limit=50, ssl=self.ssl_context)
117
  async with aiohttp.ClientSession(connector=connector) as session:
118
+ # Build all URLs
119
+ urls = []
120
+ for q in queries:
121
+ encoded = q.replace(' ', '+')
122
+ urls.append((q, f"https://news.google.com/rss/search?q={encoded}&hl=en-US&gl=US&ceid=US:en"))
123
+
124
+ # Also add general financial news feeds to pad the count
125
+ general_feeds = [
126
+ "https://news.google.com/rss/headlines/section/topic/BUSINESS?hl=en-US&gl=US&ceid=US:en",
127
+ "https://news.google.com/rss/search?q=stock+market&hl=en-US&gl=US&ceid=US:en",
128
+ "https://news.google.com/rss/search?q=wall+street+today&hl=en-US&gl=US&ceid=US:en",
129
+ "https://news.google.com/rss/search?q=stocks+trading&hl=en-US&gl=US&ceid=US:en",
130
+ "https://news.google.com/rss/search?q=financial+markets&hl=en-US&gl=US&ceid=US:en",
131
+ ]
132
+ for gf in general_feeds:
133
+ urls.append(("General Market", gf))
134
+
135
+ # Fire all requests concurrently in batches of 20
136
+ batch_size = 20
137
+ for batch_start in range(0, len(urls), batch_size):
138
+ if len(all_articles) >= self.limit:
139
+ break
140
+
141
+ batch = urls[batch_start:batch_start + batch_size]
142
+ tasks = [self.fetch_feed(session, url) for _, url in batch]
143
+ results = await asyncio.gather(*tasks, return_exceptions=True)
144
 
145
+ for (query_name, _), xml in zip(batch, results):
146
+ if isinstance(xml, Exception) or not xml:
147
+ continue
148
+ parsed = self.parse_feed(xml, lookback_date)
149
+ for a in parsed:
150
+ if a['link'] not in seen:
151
+ seen.add(a['link'])
152
+ all_articles.append(a)
153
+ if len(all_articles) >= self.limit:
154
+ break
155
 
156
+ # Report progress
157
+ if progress_cb:
158
+ scrape_progress = min(len(all_articles) / self.limit, 1.0)
159
+ progress_cb(
160
+ scrape_progress,
161
+ f"Collecting headlines: {len(all_articles)}/{self.limit}"
162
+ )
 
163
 
164
+ # Small delay between batches to avoid rate limiting
165
+ await asyncio.sleep(0.1)
166
+
167
+ print(f"[Scraper] Total unique articles collected: {len(all_articles)}")
168
  return all_articles[:self.limit]
169
 
170
  @staticmethod
171
  def cleanup():
172
  for f in glob.glob("*.csv"):
173
+ try:
174
+ os.remove(f)
175
+ except:
176
+ pass
engine/analytics.py CHANGED
@@ -3,42 +3,232 @@ import numpy as np
3
  import torch
4
  from transformers import pipeline
5
  from sentence_transformers import CrossEncoder
 
6
 
7
  class AnalyticsEngine:
8
  def __init__(self):
9
  self.device = 0 if torch.cuda.is_available() else -1
10
- self.sentiment_pipe = pipeline("sentiment-analysis", model="ProsusAI/finbert", device=self.device)
11
- self.ranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device='cuda' if torch.cuda.is_available() else 'cpu')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  def analyze(self, df, ticker, progress_cb=None):
14
  titles = df['title'].tolist()
15
  total = len(titles)
16
-
17
- # 1. Sentiment Phase
18
- if progress_cb: progress_cb(0.1, "Neural Phase: Analyzing Sentiment Polarity...")
19
- results = self.sentiment_pipe(titles, batch_size=32)
20
- res_df = pd.DataFrame(results)
21
- df = pd.concat([df, res_df], axis=1)
22
-
23
- # 2. Quant Features
24
- if progress_cb: progress_cb(0.5, "Math Phase: Calculating Quant Features...")
25
- mapping = {'positive': 1.0, 'neutral': 0.0, 'negative': -1.0}
26
- df['pol'] = df['label'].map(mapping)
27
- df['conviction'] = df['score'] * df['pol'].abs()
28
-
29
- # 3. Significance Phase
30
- if progress_cb: progress_cb(0.7, "Insight Phase: Ranking Heavy Hitters...")
31
- query = f"Market moving news for {ticker}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  pairs = [[query, t] for t in titles]
33
- df['significance'] = self.ranker.predict(pairs, batch_size=32)
34
-
35
- if progress_cb: progress_cb(1.0, "Analysis Complete.")
 
36
  return df
37
 
38
  def get_summary(self, df):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  summary = {
40
- "avg_polarity": df['pol'].mean(),
41
- "vibe": int(np.clip(((df['pol'].mean() + 1) * 4.5) + (df['conviction'].mean()), 1, 10)),
42
- "heavy_hitters": df.sort_values(by='significance', ascending=False).head(5).to_dict('records')
 
 
 
 
 
 
 
 
43
  }
44
  return summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import torch
4
  from transformers import pipeline
5
  from sentence_transformers import CrossEncoder
6
+ from scipy import stats as scipy_stats
7
 
8
  class AnalyticsEngine:
9
  def __init__(self):
10
  self.device = 0 if torch.cuda.is_available() else -1
11
+ torch_device = 'cuda' if torch.cuda.is_available() else 'cpu'
12
+
13
+ # Model 1: FinBERT (Financial domain sentiment)
14
+ self.finbert = pipeline(
15
+ "sentiment-analysis",
16
+ model="ProsusAI/finbert",
17
+ device=self.device,
18
+ max_length=512,
19
+ truncation=True
20
+ )
21
+
22
+ # Model 2: DistilRoBERTa (General sentiment, trained on diverse data)
23
+ self.distilroberta = pipeline(
24
+ "sentiment-analysis",
25
+ model="distilbert/distilbert-base-uncased-finetuned-sst-2-english",
26
+ device=self.device,
27
+ max_length=512,
28
+ truncation=True
29
+ )
30
+
31
+ # Model 3: Cross-Encoder for significance ranking
32
+ self.ranker = CrossEncoder(
33
+ "cross-encoder/ms-marco-MiniLM-L-6-v2",
34
+ device=torch_device
35
+ )
36
+
37
+ def _map_finbert(self, label, score):
38
+ """FinBERT: positive/negative/neutral β†’ polarity."""
39
+ mapping = {'positive': 1.0, 'neutral': 0.0, 'negative': -1.0}
40
+ return mapping.get(label, 0.0) * score
41
+
42
+ def _map_distilroberta(self, label, score):
43
+ """DistilRoBERTa: POSITIVE/NEGATIVE β†’ polarity."""
44
+ if label == "POSITIVE":
45
+ return score
46
+ elif label == "NEGATIVE":
47
+ return -score
48
+ return 0.0
49
 
50
  def analyze(self, df, ticker, progress_cb=None):
51
  titles = df['title'].tolist()
52
  total = len(titles)
53
+ batch_size = 32
54
+
55
+ # ──────────────────────────────────────────────
56
+ # Phase 1: FinBERT Sentiment (financial domain)
57
+ # ──────────────────────────────────────────────
58
+ if progress_cb:
59
+ progress_cb(0.05, f"Model 1/2: FinBERT analyzing {total} headlines...")
60
+
61
+ finbert_results = []
62
+ for i in range(0, total, batch_size):
63
+ batch = titles[i:i + batch_size]
64
+ finbert_results.extend(self.finbert(batch))
65
+ if progress_cb:
66
+ progress_cb(0.05 + (i / total) * 0.25, f"FinBERT: {min(i + batch_size, total)}/{total}")
67
+
68
+ df['finbert_label'] = [r['label'] for r in finbert_results]
69
+ df['finbert_score'] = [r['score'] for r in finbert_results]
70
+ df['finbert_pol'] = df.apply(
71
+ lambda row: self._map_finbert(row['finbert_label'], row['finbert_score']), axis=1
72
+ )
73
+
74
+ # ──────────────────────────────────────────────
75
+ # Phase 2: DistilRoBERTa Sentiment (general)
76
+ # ──────────────────────────────────────────────
77
+ if progress_cb:
78
+ progress_cb(0.35, f"Model 2/2: DistilRoBERTa analyzing {total} headlines...")
79
+
80
+ roberta_results = []
81
+ for i in range(0, total, batch_size):
82
+ batch = titles[i:i + batch_size]
83
+ roberta_results.extend(self.distilroberta(batch))
84
+ if progress_cb:
85
+ progress_cb(0.35 + (i / total) * 0.25, f"DistilRoBERTa: {min(i + batch_size, total)}/{total}")
86
+
87
+ df['roberta_label'] = [r['label'] for r in roberta_results]
88
+ df['roberta_score'] = [r['score'] for r in roberta_results]
89
+ df['roberta_pol'] = df.apply(
90
+ lambda row: self._map_distilroberta(row['roberta_label'], row['roberta_score']), axis=1
91
+ )
92
+
93
+ # ──────────────��───────────────────────────────
94
+ # Phase 3: Ensemble Fusion
95
+ # ──────────────────────────────────────────────
96
+ if progress_cb:
97
+ progress_cb(0.65, "Computing ensemble sentiment fusion...")
98
+
99
+ # Weighted ensemble: FinBERT gets 0.6 weight (domain expert), RoBERTa gets 0.4
100
+ FINBERT_WEIGHT = 0.6
101
+ ROBERTA_WEIGHT = 0.4
102
+ df['ensemble_pol'] = (df['finbert_pol'] * FINBERT_WEIGHT) + (df['roberta_pol'] * ROBERTA_WEIGHT)
103
+
104
+ # Conviction: how confident BOTH models are (geometric mean of confidences)
105
+ df['conviction'] = np.sqrt(df['finbert_score'] * df['roberta_score']) * df['ensemble_pol'].abs()
106
+
107
+ # Agreement score: do both models agree on direction?
108
+ df['agreement'] = (np.sign(df['finbert_pol']) == np.sign(df['roberta_pol'])).astype(float)
109
+
110
+ # Statistical features
111
+ df['z_score'] = scipy_stats.zscore(df['ensemble_pol'], nan_policy='omit')
112
+ df['momentum'] = df['ensemble_pol'].rolling(window=max(10, total // 20), min_periods=1).mean()
113
+
114
+ # For backward compat with the rest of the pipeline
115
+ df['label'] = df['finbert_label']
116
+ df['score'] = df['finbert_score']
117
+ df['pol'] = df['ensemble_pol']
118
+
119
+ # ──────────────────────────────────────────────
120
+ # Phase 4: Significance Ranking
121
+ # ──────────────────────────────────────────────
122
+ if progress_cb:
123
+ progress_cb(0.75, "Ranking headline significance...")
124
+
125
+ query = f"Major market moving news for {ticker} stock"
126
  pairs = [[query, t] for t in titles]
127
+ df['significance'] = self.ranker.predict(pairs, batch_size=batch_size)
128
+
129
+ if progress_cb:
130
+ progress_cb(1.0, "Analysis complete.")
131
  return df
132
 
133
  def get_summary(self, df):
134
+ """
135
+ Advanced scoring that avoids the neutral trap.
136
+
137
+ The old formula: vibe = ((mean_pol + 1) * 4.5) + conviction
138
+ Problem: mean_pol ~ 0 for most tickers β†’ vibe ~ 4.5 always.
139
+
140
+ New approach: Multi-signal composite score using:
141
+ 1. Ensemble polarity (weighted mean of 2 models)
142
+ 2. Directional ratio (what % of articles are positive vs negative)
143
+ 3. Conviction-weighted polarity (strong signals count more)
144
+ 4. Agreement factor (when both models agree, amplify the signal)
145
+ 5. Momentum trend (is sentiment accelerating?)
146
+ """
147
+ n = len(df)
148
+
149
+ # Signal 1: Raw ensemble polarity [-1, 1]
150
+ mean_pol = df['ensemble_pol'].mean()
151
+
152
+ # Signal 2: Directional ratio [-1, 1]
153
+ # Instead of treating neutral as 0, count the RATIO of positive to negative
154
+ pos_count = (df['ensemble_pol'] > 0.1).sum()
155
+ neg_count = (df['ensemble_pol'] < -0.1).sum()
156
+ total_directional = pos_count + neg_count
157
+ if total_directional > 0:
158
+ dir_ratio = (pos_count - neg_count) / total_directional
159
+ else:
160
+ dir_ratio = 0.0
161
+
162
+ # Signal 3: Conviction-weighted polarity [-1, 1]
163
+ if df['conviction'].sum() > 0:
164
+ conv_weighted = (df['ensemble_pol'] * df['conviction']).sum() / df['conviction'].sum()
165
+ else:
166
+ conv_weighted = 0.0
167
+
168
+ # Signal 4: Agreement-amplified signal
169
+ agreed = df[df['agreement'] == 1.0]
170
+ if len(agreed) > 0:
171
+ agreed_pol = agreed['ensemble_pol'].mean()
172
+ else:
173
+ agreed_pol = mean_pol
174
+
175
+ # Signal 5: Momentum (is sentiment trending?)
176
+ if len(df) >= 10:
177
+ recent = df['ensemble_pol'].tail(n // 3).mean()
178
+ older = df['ensemble_pol'].head(n // 3).mean()
179
+ momentum_delta = recent - older
180
+ else:
181
+ momentum_delta = 0.0
182
+
183
+ # Composite score: weighted combination
184
+ composite = (
185
+ mean_pol * 0.20 + # Raw polarity
186
+ dir_ratio * 0.25 + # Directional strength
187
+ conv_weighted * 0.25 + # Conviction-weighted
188
+ agreed_pol * 0.20 + # Agreement signal
189
+ momentum_delta * 0.10 # Momentum trend
190
+ )
191
+
192
+ # Map composite [-1, 1] β†’ vibe [1, 10]
193
+ # Using a sigmoid-like scaling to push away from center
194
+ stretched = np.sign(composite) * (abs(composite) ** 0.7)
195
+ vibe = int(np.clip(round((stretched + 1) * 4.5 + 0.5), 1, 10))
196
+
197
+ # Additional quant metrics for the report
198
+ avg_conviction = df['conviction'].mean()
199
+ tail_risk = (df['ensemble_pol'] < -0.5).sum() / n
200
+ entropy = df['ensemble_pol'].value_counts(normalize=True).std()
201
+ quant_confidence = avg_conviction * (1 - entropy) if entropy < 1 else avg_conviction
202
+
203
  summary = {
204
+ "avg_polarity": float(mean_pol),
205
+ "vibe": vibe,
206
+ "dir_ratio": float(dir_ratio),
207
+ "conviction_weighted": float(conv_weighted),
208
+ "agreement_rate": float(df['agreement'].mean()),
209
+ "momentum_delta": float(momentum_delta),
210
+ "composite_score": float(composite),
211
+ "avg_conviction": float(avg_conviction),
212
+ "tail_risk": float(tail_risk),
213
+ "quant_confidence": float(quant_confidence),
214
+ "heavy_hitters": df.sort_values(by='significance', ascending=False).head(8).to_dict('records')
215
  }
216
  return summary
217
+
218
+ def estimate_time(self, article_count):
219
+ """
220
+ Estimate processing time based on article count.
221
+ Benchmarks (approximate, on HF Spaces free tier):
222
+ - FinBERT: ~0.15s per batch of 32
223
+ - DistilRoBERTa: ~0.10s per batch of 32
224
+ - CrossEncoder: ~0.20s per batch of 32
225
+ - Scraping: ~8-15s for 600 articles
226
+ """
227
+ batches = (article_count + 31) // 32
228
+ scrape_time = 12 # average scrape time
229
+ finbert_time = batches * 0.15
230
+ roberta_time = batches * 0.10
231
+ ranker_time = batches * 0.20
232
+ overhead = 3
233
+ total = scrape_time + finbert_time + roberta_time + ranker_time + overhead
234
+ return round(total)
requirements.txt CHANGED
@@ -6,4 +6,5 @@ gradio==4.44.1
6
  huggingface_hub==0.24.7
7
  sentence-transformers
8
  aiohttp
 
9
  pytest
 
6
  huggingface_hub==0.24.7
7
  sentence-transformers
8
  aiohttp
9
+ scipy
10
  pytest