malikparth05 commited on
Commit
49b3fff
·
1 Parent(s): fd5a63e

V5 Deploy: Three-Phase Hybrid Scraper + 24/7 Live Operation

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ *.db filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Install system dependencies
4
+ RUN apt-get update && apt-get install -y sqlite3 git && rm -rf /var/lib/apt/lists/*
5
+
6
+ # Set up user for Hugging Face Spaces
7
+ RUN useradd -m -u 1000 user
8
+ USER user
9
+ ENV HOME=/home/user \
10
+ PATH=/home/user/.local/bin:$PATH
11
+
12
+ WORKDIR $HOME/app
13
+
14
+ # Copy and install Python dependencies
15
+ COPY --chown=user requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ # Copy all the project files into the container
19
+ COPY --chown=user . .
20
+
21
+ # Make startup script executable
22
+ RUN chmod +x start.sh
23
+
24
+ # Hugging Face Spaces expects the app to run on port 7860
25
+ EXPOSE 7860
26
+ ENV FLASK_APP=app.py
27
+
28
+ # Run both Flask dashboard + Live Scraper
29
+ CMD ["bash", "start.sh"]
README.md CHANGED
@@ -1,10 +1,18 @@
1
  ---
2
  title: Alpha Sentiment Engine
3
- emoji: 🐠
4
- colorFrom: yellow
5
- colorTo: purple
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Alpha Sentiment Engine
3
+ emoji:
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ pinned: true
8
  ---
9
 
10
+ # Alpha Sentiment Engine
11
+
12
+ AI-powered Indian stock market sentiment analysis using a custom-trained FinBERT model (86.39% accuracy).
13
+
14
+ - 🧠 Custom FinBERT model fine-tuned on financial news
15
+ - 📡 Live scraping from Google News + Yahoo Finance RSS
16
+ - 📊 V5 Three-Phase Hybrid Architecture (sector + direct + hybrid scoring)
17
+ - 🎯 561+ Indian companies scored with confidence levels
18
+ - 📈 Real-time stock price correlation
app.py ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import json
3
+ from flask import Flask, render_template, jsonify
4
+ from datetime import datetime, timezone, timedelta
5
+ import threading
6
+ from live_scraper import run_sync_loop
7
+
8
+ app = Flask(__name__)
9
+ DB_PATH = "sentiment_data.db"
10
+ STOCKS_FILE = "data/indian_stocks.json"
11
+
12
+ # Load stock names for display
13
+ def load_stock_names():
14
+ try:
15
+ with open(STOCKS_FILE, "r") as f:
16
+ data = json.load(f)
17
+ return {s["symbol"]: s["name"] for s in data["stocks"]}
18
+ except Exception:
19
+ return {}
20
+
21
+ STOCK_NAMES = load_stock_names()
22
+
23
+ def get_db_connection():
24
+ conn = sqlite3.connect(DB_PATH)
25
+ conn.row_factory = sqlite3.Row
26
+ return conn
27
+
28
+ @app.route("/api/debug")
29
+ def debug_sync():
30
+ import os, json, traceback
31
+ from services.database import create_tables
32
+ logs = []
33
+ try:
34
+ logs.append("Starting debug sync...")
35
+ create_tables()
36
+ logs.append("Tables initialized.")
37
+
38
+ if os.path.exists("sentiment_data.json"):
39
+ logs.append("Found sentiment_data.json")
40
+ conn = get_db_connection()
41
+ c = conn.cursor()
42
+ c.execute("DELETE FROM sentiment_averages")
43
+ c.execute("DELETE FROM sentiment_scores")
44
+
45
+ with open("sentiment_data.json", "r") as f:
46
+ data = json.load(f)
47
+ logs.append(f"Loaded {len(data)} averages from JSON.")
48
+ for r in data:
49
+ c.execute("INSERT INTO sentiment_averages (ticker, average_score, num_headlines, confidence, price_change, score_type, scraped_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
50
+ (r['ticker'], r['average_score'], r['num_headlines'], r.get('confidence', 'LOW'), r.get('price_change'), r.get('score_type', 'DIRECT'), r['scraped_at']))
51
+
52
+ if os.path.exists("sentiment_headlines.json"):
53
+ logs.append("Found sentiment_headlines.json")
54
+ with open("sentiment_headlines.json", "r") as f:
55
+ data2 = json.load(f)
56
+ logs.append(f"Loaded {len(data2)} headlines from JSON.")
57
+ for r in data2:
58
+ c.execute("INSERT INTO sentiment_scores (ticker, headline, score, source, validated, scraped_at) VALUES (?, ?, ?, ?, ?, ?)",
59
+ (r['ticker'], r['headline'], r['score'], r['source'], r.get('validated', 1), r['scraped_at']))
60
+
61
+ conn.commit()
62
+
63
+ c.execute("SELECT COUNT(*) FROM sentiment_scores")
64
+ total = c.fetchone()[0]
65
+ logs.append(f"Sync complete. DB sentiment_scores count is now: {total}")
66
+ conn.close()
67
+ else:
68
+ logs.append("sentiment_data.json DOES NOT EXIST in this container.")
69
+ except Exception as e:
70
+ logs.append(f"FATAL Exception: {str(e)}")
71
+ logs.append(traceback.format_exc())
72
+
73
+ return jsonify({"debug_logs": logs})
74
+
75
+ @app.route("/")
76
+ def landing():
77
+ return render_template("landing.html")
78
+
79
+ @app.route("/dashboard")
80
+ def index():
81
+ return render_template("index.html")
82
+
83
+ @app.route("/api/stats")
84
+ def api_stats():
85
+ """Get high-level statistics for the top cards."""
86
+ conn = get_db_connection()
87
+ c = conn.cursor()
88
+
89
+ c.execute("SELECT COUNT(*) FROM sentiment_scores WHERE validated = 1")
90
+ total_headlines = c.fetchone()[0]
91
+
92
+ # Get number of unique stocks scored (excluding sector entries)
93
+ c.execute("SELECT COUNT(DISTINCT ticker) FROM sentiment_averages WHERE ticker NOT LIKE 'SECTOR_%'")
94
+ stocks_scored = c.fetchone()[0]
95
+
96
+ conn.close()
97
+
98
+ return jsonify({
99
+ "total_headlines": total_headlines,
100
+ "stocks_scored": stocks_scored,
101
+ "ai_accuracy": "86.39%"
102
+ })
103
+
104
+ @app.route("/api/overview")
105
+ def api_overview():
106
+ """Get the latest sentiment score for all companies (for the bar chart)."""
107
+ conn = get_db_connection()
108
+ c = conn.cursor()
109
+
110
+ # Get the latest average score for each ticker
111
+ query = """
112
+ SELECT ticker, average_score as score, confidence, price_change, score_type
113
+ FROM sentiment_averages
114
+ WHERE id IN (
115
+ SELECT MAX(id)
116
+ FROM sentiment_averages
117
+ GROUP BY ticker
118
+ )
119
+ ORDER BY average_score DESC
120
+ """
121
+
122
+ c.execute(query)
123
+ results = [dict(row) for row in c.fetchall()]
124
+ conn.close()
125
+
126
+ # Filter out SECTOR_ entries from display
127
+ results = [r for r in results if not r['ticker'].startswith('SECTOR_')]
128
+
129
+ # Prioritize DIRECT and HYBRID scores over SECTOR-only
130
+ direct_hybrid = [r for r in results if r.get('score_type') in ('DIRECT', 'HYBRID')]
131
+ sector_only = [r for r in results if r.get('score_type') == 'SECTOR']
132
+
133
+ # Bullish: DIRECT/HYBRID first, then fill with SECTOR if needed
134
+ dh_bullish = [r for r in direct_hybrid if r['score'] > 0.1]
135
+ s_bullish = [r for r in sector_only if r['score'] > 0.1]
136
+ bullish = (dh_bullish + s_bullish)[:15]
137
+
138
+ # Bearish: same priority
139
+ dh_bearish = sorted([r for r in direct_hybrid if r['score'] < -0.1], key=lambda x: x['score'])
140
+ s_bearish = sorted([r for r in sector_only if r['score'] < -0.1], key=lambda x: x['score'])
141
+ bearish = (dh_bearish + s_bearish)[:15]
142
+
143
+ # Add company names
144
+ for items in [bullish, bearish]:
145
+ for item in items:
146
+ item["name"] = STOCK_NAMES.get(item["ticker"], item["ticker"])
147
+
148
+ return jsonify({
149
+ "bullish": bullish,
150
+ "bearish": bearish
151
+ })
152
+
153
+ @app.route("/api/headlines")
154
+ def api_headlines():
155
+ """Get the 50 most recently scored headlines (for the live feed)."""
156
+ conn = get_db_connection()
157
+ c = conn.cursor()
158
+
159
+ c.execute('''
160
+ SELECT ticker, headline, score, source, scraped_at
161
+ FROM sentiment_scores
162
+ ORDER BY id DESC LIMIT 50
163
+ ''')
164
+
165
+ results = [dict(row) for row in c.fetchall()]
166
+ conn.close()
167
+
168
+ # Format time and add full name
169
+ for r in results:
170
+ try:
171
+ dt = datetime.fromisoformat(r['scraped_at'].replace('Z', '+00:00'))
172
+ r['time_ago'] = dt.strftime("%H:%M")
173
+ except:
174
+ r['time_ago'] = ""
175
+
176
+ r['name'] = STOCK_NAMES.get(r['ticker'].replace('SECTOR_', ''), r['ticker'])
177
+
178
+ return jsonify(results)
179
+
180
+ @app.route("/api/search")
181
+ def api_search():
182
+ """Search for a specific company's sentiment data."""
183
+ from flask import request
184
+ query = request.args.get('q', '').upper()
185
+ if not query:
186
+ return jsonify({"error": "No query provided"}), 400
187
+
188
+ conn = get_db_connection()
189
+ c = conn.cursor()
190
+
191
+ # Find matching tickers from STOCK_NAMES
192
+ matches = []
193
+ for ticker, name in STOCK_NAMES.items():
194
+ if query in ticker or query in name.upper():
195
+ matches.append(ticker)
196
+
197
+ if not matches:
198
+ conn.close()
199
+ return jsonify({"results": []})
200
+
201
+ # Get latest score for top 10 matches
202
+ results = []
203
+ for match_ticker in matches[:10]:
204
+ c.execute('''
205
+ SELECT average_score as score, confidence, price_change, score_type, scraped_at
206
+ FROM sentiment_averages
207
+ WHERE ticker = ?
208
+ ORDER BY id DESC LIMIT 1
209
+ ''', (match_ticker,))
210
+
211
+ score_row = c.fetchone()
212
+ if score_row:
213
+ result = dict(score_row)
214
+ result['ticker'] = match_ticker
215
+ result['name'] = STOCK_NAMES[match_ticker]
216
+ results.append(result)
217
+
218
+ conn.close()
219
+ return jsonify({"results": results})
220
+
221
+ @app.route("/api/company/<ticker>")
222
+ def api_company(ticker):
223
+ """Get detailed data for a single company (trend + headlines)."""
224
+ conn = get_db_connection()
225
+ c = conn.cursor()
226
+
227
+ # 1. Get recent trend (last 10 averages) — direct ticker only
228
+ c.execute('''
229
+ SELECT average_score as score, confidence, price_change, score_type, scraped_at
230
+ FROM sentiment_averages
231
+ WHERE ticker = ?
232
+ ORDER BY id DESC LIMIT 10
233
+ ''', (ticker,))
234
+
235
+ trend_rows = [dict(row) for row in c.fetchall()]
236
+
237
+ trend = []
238
+ for r in trend_rows[::-1]:
239
+ try:
240
+ dt = datetime.fromisoformat(r['scraped_at'].replace('Z', '+00:00'))
241
+ r['time_label'] = dt.strftime("%H:%M")
242
+ except:
243
+ r['time_label'] = ""
244
+ trend.append(r)
245
+
246
+ # 2. Get headlines for this company (direct only, entity-validated)
247
+ try:
248
+ with open(STOCKS_FILE, "r") as f:
249
+ full_data = json.load(f)
250
+ stock_obj = next((s for s in full_data["stocks"] if s["symbol"] == ticker), None)
251
+ sector = stock_obj.get("sector", "General") if stock_obj else "General"
252
+ except:
253
+ sector = "General"
254
+
255
+ c.execute('''
256
+ SELECT headline, score, source, scraped_at
257
+ FROM sentiment_scores
258
+ WHERE ticker = ?
259
+ ORDER BY id DESC LIMIT 15
260
+ ''', (ticker,))
261
+
262
+ headlines_rows = [dict(row) for row in c.fetchall()]
263
+ headlines = []
264
+ for r in headlines_rows:
265
+ try:
266
+ dt = datetime.fromisoformat(r['scraped_at'].replace('Z', '+00:00'))
267
+ r['time_ago'] = dt.strftime("%b %d, %H:%M")
268
+ except:
269
+ r['time_ago'] = ""
270
+ headlines.append(r)
271
+
272
+ conn.close()
273
+
274
+ current_score = trend[-1]['score'] if trend else 0.0
275
+ latest_confidence = trend[-1].get('confidence', 'LOW') if trend else 'LOW'
276
+ latest_price = trend[-1].get('price_change') if trend else None
277
+
278
+ return jsonify({
279
+ "ticker": ticker,
280
+ "name": STOCK_NAMES.get(ticker, ticker),
281
+ "sector": sector,
282
+ "current_score": current_score,
283
+ "confidence": latest_confidence,
284
+ "price_change": latest_price,
285
+ "trend": trend,
286
+ "headlines": headlines
287
+ })
288
+
289
+ if __name__ == "__main__":
290
+ import os
291
+ from services.database import create_tables
292
+
293
+ # Initialize SQLite schema if new deployment
294
+ create_tables()
295
+
296
+ # Start the background scraper immediately on launch
297
+ def start_sync_worker():
298
+ """Starts the live_scraper logic in a background thread."""
299
+ print("🛰️ Initializing Sentix Background Sync Engine...")
300
+ worker = threading.Thread(target=run_sync_loop, daemon=True)
301
+ worker.start()
302
+
303
+ start_sync_worker()
304
+
305
+ port = int(os.environ.get("PORT", 7860))
306
+ app.run(host="0.0.0.0", debug=False, port=port)
data/fetch_stocks.py ADDED
@@ -0,0 +1,411 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Download the full list of NSE + BSE listed companies with their sectors.
4
+ Saves the result as data/indian_stocks.json
5
+
6
+ This script fetches data from:
7
+ 1. NSE India (all equity securities)
8
+ 2. Groups them by industry/sector
9
+ """
10
+
11
+ import requests
12
+ import json
13
+ import csv
14
+ import io
15
+ import time
16
+
17
+ OUTPUT_FILE = "data/indian_stocks.json"
18
+
19
+
20
+ def fetch_nse_stocks() -> list[dict]:
21
+ """
22
+ Fetch all listed equities from NSE India.
23
+ NSE provides a CSV at their website.
24
+ """
25
+ print("📥 Fetching NSE equity list...")
26
+
27
+ url = "https://nsearchives.nseindia.com/content/equities/EQUITY_L.csv"
28
+
29
+ headers = {
30
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
31
+ "Accept": "text/csv,text/html,application/xhtml+xml",
32
+ }
33
+
34
+ try:
35
+ response = requests.get(url, headers=headers, timeout=30)
36
+ response.raise_for_status()
37
+
38
+ reader = csv.DictReader(io.StringIO(response.text))
39
+ stocks = []
40
+ for row in reader:
41
+ symbol = row.get("SYMBOL", "").strip()
42
+ name = row.get("NAME OF COMPANY", "").strip()
43
+
44
+ if symbol and name:
45
+ stocks.append({
46
+ "symbol": symbol,
47
+ "name": name,
48
+ "exchange": "NSE",
49
+ "yahoo_ticker": f"{symbol}.NS",
50
+ })
51
+
52
+ print(f" ✅ Found {len(stocks)} NSE stocks")
53
+ return stocks
54
+
55
+ except Exception as e:
56
+ print(f" ⚠️ NSE download failed: {e}")
57
+ print(" Trying alternative source...")
58
+ return []
59
+
60
+
61
+ def fetch_nse_stocks_alternative() -> list[dict]:
62
+ """
63
+ Alternative: Fetch from NSE's JSON API.
64
+ """
65
+ print("📥 Trying NSE JSON API...")
66
+
67
+ session = requests.Session()
68
+ session.headers.update({
69
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
70
+ "Accept": "application/json",
71
+ })
72
+
73
+ # First hit the main page to get cookies
74
+ try:
75
+ session.get("https://www.nseindia.com", timeout=10)
76
+ time.sleep(1)
77
+
78
+ # Then fetch the stock listing
79
+ url = "https://www.nseindia.com/api/equity-stockIndices?index=SECURITIES%20IN%20F%26O"
80
+ response = session.get(url, timeout=10)
81
+
82
+ if response.status_code == 200:
83
+ data = response.json()
84
+ stocks = []
85
+ for item in data.get("data", []):
86
+ symbol = item.get("symbol", "")
87
+ if symbol:
88
+ stocks.append({
89
+ "symbol": symbol,
90
+ "name": item.get("meta", {}).get("companyName", symbol),
91
+ "exchange": "NSE",
92
+ "yahoo_ticker": f"{symbol}.NS",
93
+ "industry": item.get("meta", {}).get("industry", ""),
94
+ })
95
+ print(f" ✅ Found {len(stocks)} stocks from F&O list")
96
+ return stocks
97
+ except Exception as e:
98
+ print(f" ⚠️ NSE JSON API failed: {e}")
99
+
100
+ return []
101
+
102
+
103
+ def fetch_industry_mapping() -> dict[str, str]:
104
+ """
105
+ Try to get industry/sector mapping for NSE stocks.
106
+ Uses NSE's industry listing page.
107
+ """
108
+ print("📥 Fetching industry mapping...")
109
+
110
+ session = requests.Session()
111
+ session.headers.update({
112
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
113
+ })
114
+
115
+ industries = {}
116
+
117
+ # NSE sector indices - we can use these to map stocks to sectors
118
+ sector_indices = [
119
+ "NIFTY BANK", "NIFTY IT", "NIFTY PHARMA", "NIFTY AUTO",
120
+ "NIFTY FINANCIAL SERVICES", "NIFTY FMCG", "NIFTY METAL",
121
+ "NIFTY REALTY", "NIFTY ENERGY", "NIFTY INFRASTRUCTURE",
122
+ "NIFTY PSE", "NIFTY MEDIA", "NIFTY PRIVATE BANK",
123
+ "NIFTY COMMODITIES", "NIFTY HEALTHCARE INDEX",
124
+ "NIFTY CONSUMER DURABLES", "NIFTY OIL & GAS",
125
+ ]
126
+
127
+ try:
128
+ session.get("https://www.nseindia.com", timeout=10)
129
+ time.sleep(1)
130
+
131
+ for index_name in sector_indices:
132
+ try:
133
+ encoded = requests.utils.quote(index_name)
134
+ url = f"https://www.nseindia.com/api/equity-stockIndices?index={encoded}"
135
+ response = session.get(url, timeout=10)
136
+
137
+ if response.status_code == 200:
138
+ data = response.json()
139
+ sector = index_name.replace("NIFTY ", "").title()
140
+ for item in data.get("data", []):
141
+ symbol = item.get("symbol", "")
142
+ if symbol and symbol != "NIFTY BANK":
143
+ industries[symbol] = sector
144
+
145
+ time.sleep(0.5) # Be nice to the API
146
+ except Exception:
147
+ pass
148
+
149
+ print(f" ✅ Got sector mapping for {len(industries)} stocks")
150
+ except Exception as e:
151
+ print(f" ⚠️ Industry mapping failed: {e}")
152
+
153
+ return industries
154
+
155
+
156
+ # Manual overrides for top companies that keyword matching gets wrong
157
+ COMPANY_SECTOR_OVERRIDES = {
158
+ # Conglomerates / Holding
159
+ "RELIANCE": "Energy", "ADANIENT": "Infrastructure", "ADANIPORTS": "Logistics",
160
+ "ADANIGREEN": "Energy", "ADANIPOWER": "Energy", "ADANITRANS": "Energy",
161
+ "LT": "Infrastructure", "GRASIM": "Chemicals", "GODREJCP": "FMCG",
162
+ "GODREJPROP": "Real Estate", "GODREJIND": "Chemicals",
163
+ # Banking that keyword might miss
164
+ "BAJFINANCE": "Banking", "BAJAJFINSV": "Banking", "CHOLAFIN": "Banking",
165
+ "SHRIRAMFIN": "Banking", "MUTHOOTFIN": "Banking", "MANAPPURAM": "Banking",
166
+ "PEL": "Banking", "LICHSGFIN": "Banking", "CANFINHOME": "Banking",
167
+ "IDFCFIRSTB": "Banking", "INDUSINDBK": "Banking",
168
+ # IT companies with non-obvious names
169
+ "INFY": "IT", "WIPRO": "IT", "TCS": "IT", "HCLTECH": "IT", "TECHM": "IT",
170
+ "LTIM": "IT", "LTTS": "IT", "PERSISTENT": "IT", "COFORGE": "IT",
171
+ "MPHASIS": "IT", "TATAELXSI": "IT", "OFSS": "IT", "NAUKRI": "IT",
172
+ "ROUTE": "IT", "HAPPSTMNDS": "IT", "MASTEK": "IT", "SONATA": "IT",
173
+ # Pharma companies
174
+ "CIPLA": "Pharma", "DIVISLAB": "Pharma", "DRREDDY": "Pharma",
175
+ "SUNPHARMA": "Pharma", "LUPIN": "Pharma", "TORNTPHARM": "Pharma",
176
+ "AUROPHARMA": "Pharma", "BIOCON": "Pharma", "ALKEM": "Pharma",
177
+ "MAXHEALTH": "Pharma", "APOLLOHOSP": "Pharma", "LALPATHLAB": "Pharma",
178
+ # Consumer / FMCG
179
+ "ITC": "FMCG", "HINDUNILVR": "FMCG", "NESTLEIND": "FMCG",
180
+ "BRITANNIA": "FMCG", "TATACONSUM": "FMCG", "DABUR": "FMCG",
181
+ "MARICO": "FMCG", "COLPAL": "FMCG", "EMAMILTD": "FMCG",
182
+ "PATANJALI": "FMCG", "PAGEIND": "Textiles", "TITAN": "Retail",
183
+ "DMART": "Retail", "TRENT": "Retail", "PVRINOX": "Media",
184
+ # Auto
185
+ "TATAMOTORS": "Auto", "M&M": "Auto", "MARUTI": "Auto",
186
+ "EICHERMOT": "Auto", "BAJAJ-AUTO": "Auto", "HEROMOTOCO": "Auto",
187
+ "ASHOKLEY": "Auto", "TVSMOTORS": "Auto", "MOTHERSON": "Auto",
188
+ "BOSCHLTD": "Auto", "EXIDEIND": "Auto", "AMARARAJA": "Auto",
189
+ # Defence / Aerospace
190
+ "HAL": "Defence", "BEL": "Defence", "MAZDOCK": "Defence",
191
+ "COCHINSHIP": "Defence", "GRSE": "Defence",
192
+ # Metals & Mining
193
+ "TATASTEEL": "Metal", "JSWSTEEL": "Metal", "SAIL": "Metal",
194
+ "HINDALCO": "Metal", "VEDL": "Metal", "NMDC": "Metal",
195
+ "JINDALSTEL": "Metal", "NATIONALUM": "Metal", "COALINDIA": "Metal",
196
+ # Energy / Oil & Gas
197
+ "ONGC": "Energy", "BPCL": "Energy", "IOC": "Energy", "GAIL": "Energy",
198
+ "NTPC": "Energy", "POWERGRID": "Energy", "TATAPOWER": "Energy",
199
+ "NHPC": "Energy", "IRFC": "Infrastructure", "RECLTD": "Energy",
200
+ # Telecom
201
+ "BHARTIARTL": "Telecom", "IDEA": "Telecom",
202
+ # Cement
203
+ "ULTRACEMCO": "Real Estate", "AMBUJACEM": "Real Estate",
204
+ "SHREECEM": "Real Estate", "ACC": "Real Estate",
205
+ # Insurance
206
+ "SBILIFE": "Insurance", "HDFCLIFE": "Insurance", "ICICIPRULI": "Insurance",
207
+ "POLICYBZR": "Insurance",
208
+ # Fintech
209
+ "PAYTM": "Banking", "NYKAA": "Retail", "ZOMATO": "FMCG",
210
+ # Transport
211
+ "IRCTC": "Logistics", "INDIGO": "Logistics",
212
+ # Real Estate
213
+ "DLF": "Real Estate", "OBEROIRLTY": "Real Estate",
214
+ # Chemicals
215
+ "PIDILITIND": "Chemicals", "SRF": "Chemicals", "BERGEPAINT": "Chemicals",
216
+ "ASIANPAINT": "Chemicals",
217
+ # Electronics / Consumer Durables
218
+ "HAVELLS": "Consumer Durables", "VOLTAS": "Consumer Durables",
219
+ "BLUESTARLT": "Consumer Durables", "CROMPTON": "Consumer Durables",
220
+ "DIXON": "Consumer Durables",
221
+ }
222
+
223
+
224
+ def get_sector_from_name(company_name: str, symbol: str = "") -> str:
225
+ """
226
+ Guess the sector from the company name using keywords.
227
+ First checks manual overrides, then uses expanded keyword matching.
228
+ """
229
+ # Check manual overrides first
230
+ if symbol and symbol in COMPANY_SECTOR_OVERRIDES:
231
+ return COMPANY_SECTOR_OVERRIDES[symbol]
232
+
233
+ name_lower = company_name.lower()
234
+
235
+ sector_keywords = {
236
+ "Banking": ["bank", "finance", "financial", "credit", "lending", "capital", "invest",
237
+ "fund", "wealth", "asset", "nidhi", "microfinance", "nbfc", "housing fin"],
238
+ "IT": ["tech", "software", "computer", "info", "digital", "cyber", "data", "cloud",
239
+ "system", "solution", "consult", "internet", "e-comm", "online"],
240
+ "Pharma": ["pharma", "drug", "med", "health", "hospital", "bio", "life science",
241
+ "therapeutic", "diagnos", "laborator", "path lab", "clinic", "care"],
242
+ "Auto": ["motor", "auto", "vehicle", "car", "tyre", "tire", "tractor", "scooter",
243
+ "bike", "two wheel", "three wheel"],
244
+ "FMCG": ["consumer", "food", "beverage", "dairy", "biscuit", "tea", "coffee", "soap",
245
+ "personal care", "nutrition", "snack", "spice", "edible", "flour", "rice"],
246
+ "Energy": ["power", "energy", "electric", "solar", "wind", "oil", "gas", "petro",
247
+ "coal", "renewable", "thermal", "hydro", "nuclear", "refiner"],
248
+ "Metal": ["steel", "iron", "metal", "alumin", "copper", "zinc", "mining", "ore",
249
+ "alloy", "foundry", "smelt", "casting", "forg"],
250
+ "Telecom": ["telecom", "communication", "mobile", "wireless", "network", "broadband",
251
+ "fibre", "tower", "satellite"],
252
+ "Real Estate": ["realty", "estate", "housing", "property", "construction", "infra",
253
+ "build", "cement", "concrete", "ceramics", "tile", "sanitary",
254
+ "marble", "granite"],
255
+ "Chemicals": ["chem", "fertilizer", "pesticide", "paint", "dye", "pigment", "adhesive",
256
+ "polymer", "plastic", "resin", "specialty chem", "agrochem", "coating"],
257
+ "Textiles": ["textile", "fabric", "cotton", "garment", "apparel", "silk", "wool",
258
+ "yarn", "weaving", "spinning", "denim", "fashion"],
259
+ "Media": ["media", "entertainment", "film", "broadcast", "publish", "news", "print",
260
+ "advertising", "digital media", "content", "animation", "gaming"],
261
+ "Insurance": ["insurance", "assurance", "life insur", "general insur"],
262
+ "Agriculture": ["agri", "seed", "crop", "plantation", "sugar", "farm", "fertili",
263
+ "irrigation", "horticulture"],
264
+ "Logistics": ["logistics", "transport", "shipping", "warehouse", "cargo", "port",
265
+ "courier", "express", "supply chain", "aviation", "airline", "railway"],
266
+ "Retail": ["retail", "mart", "store", "shop", "mall", "e-commerce", "jewel",
267
+ "gold", "diamond", "gem", "ornament", "watch", "luxury"],
268
+ "Hotels": ["hotel", "hospitality", "tourism", "travel", "restaurant", "resort",
269
+ "catering", "food service"],
270
+ "Paper": ["paper", "packaging", "carton", "pulp", "corrugat", "box", "container"],
271
+ "Defence": ["defence", "defense", "weapon", "ammunition", "aerospace", "shipbuild",
272
+ "naval", "ordnance", "missile"],
273
+ "Consumer Durables": ["appliance", "electronic", "electrical", "lamp", "light",
274
+ "fan", "air condition", "refrig", "washing", "kitchen"],
275
+ "Education": ["education", "school", "university", "learning", "coaching", "academy",
276
+ "training", "skill"],
277
+ }
278
+
279
+ for sector, keywords in sector_keywords.items():
280
+ for keyword in keywords:
281
+ if keyword in name_lower:
282
+ return sector
283
+
284
+ return "General"
285
+
286
+
287
+ def build_full_stock_list():
288
+ """
289
+ Build the complete stock list with sectors.
290
+ """
291
+ # Step 1: Fetch all NSE stocks
292
+ nse_stocks = fetch_nse_stocks()
293
+
294
+ if not nse_stocks:
295
+ nse_stocks = fetch_nse_stocks_alternative()
296
+
297
+ if not nse_stocks:
298
+ print("\n❌ Could not fetch stock list from NSE. Using backup approach...")
299
+ # Create a comprehensive list from yfinance
300
+ print("📥 Building stock list from known indices...")
301
+ nse_stocks = build_from_known_lists()
302
+
303
+ # Step 2: Try to get industry mapping
304
+ industry_map = fetch_industry_mapping()
305
+
306
+ # Step 3: Assign industries to all stocks
307
+ for stock in nse_stocks:
308
+ symbol = stock["symbol"]
309
+ if symbol in industry_map:
310
+ stock["sector"] = industry_map[symbol]
311
+ elif "industry" in stock and stock["industry"]:
312
+ stock["sector"] = stock["industry"]
313
+ else:
314
+ stock["sector"] = get_sector_from_name(stock["name"], symbol)
315
+
316
+ # Step 4: Build sector summary
317
+ sectors = {}
318
+ for stock in nse_stocks:
319
+ sector = stock["sector"]
320
+ if sector not in sectors:
321
+ sectors[sector] = []
322
+ sectors[sector].append(stock["symbol"])
323
+
324
+ # Step 5: Save
325
+ output = {
326
+ "metadata": {
327
+ "total_stocks": len(nse_stocks),
328
+ "total_sectors": len(sectors),
329
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()),
330
+ "exchanges": ["NSE", "BSE"],
331
+ },
332
+ "stocks": nse_stocks,
333
+ "sectors": {sector: symbols for sector, symbols in sorted(sectors.items())},
334
+ }
335
+
336
+ import os
337
+ os.makedirs("data", exist_ok=True)
338
+
339
+ with open(OUTPUT_FILE, "w") as f:
340
+ json.dump(output, f, indent=2)
341
+
342
+ print(f"\n{'=' * 60}")
343
+ print(f" ✅ Saved {len(nse_stocks)} stocks to {OUTPUT_FILE}")
344
+ print(f" 📊 {len(sectors)} unique sectors found:")
345
+ for sector, symbols in sorted(sectors.items(), key=lambda x: -len(x[1])):
346
+ print(f" {sector:<20} → {len(symbols)} companies")
347
+ print(f"{'=' * 60}")
348
+
349
+
350
+ def build_from_known_lists() -> list[dict]:
351
+ """
352
+ Fallback: Build a comprehensive list by downloading from yfinance
353
+ all tickers that end with .NS or .BO
354
+ """
355
+ import yfinance as yf
356
+
357
+ # Get all Nifty indices to cover as many stocks as possible
358
+ indices = [
359
+ "^NSEI", # Nifty 50
360
+ "^NSMIDCP", # Nifty Midcap
361
+ "^CNXSC", # Nifty Smallcap
362
+ ]
363
+
364
+ # Known comprehensive list of NSE tickers from major indices
365
+ # This covers Nifty 50 + Next 50 + Midcap 150 + Smallcap 250 = ~500 stocks
366
+ print(" Using known index constituents...")
367
+
368
+ # We'll fetch the actual list from BSE's website which is more accessible
369
+ url = "https://api.bseindia.com/BseIndiaAPI/api/ListofScripData/w?Group=&Atea=&Status=Active"
370
+ headers = {
371
+ "User-Agent": "Mozilla/5.0",
372
+ "Accept": "application/json",
373
+ "Referer": "https://www.bseindia.com/",
374
+ }
375
+
376
+ stocks = []
377
+ try:
378
+ response = requests.get(url, headers=headers, timeout=30)
379
+ if response.status_code == 200:
380
+ data = response.json()
381
+ for item in data:
382
+ code = item.get("SCRIP_CD", "")
383
+ name = item.get("LONG_NAME", "") or item.get("scrip_name", "")
384
+ nse_symbol = item.get("NSE_SYMBOL", "")
385
+
386
+ if name:
387
+ stock = {
388
+ "symbol": nse_symbol if nse_symbol else str(code),
389
+ "name": name,
390
+ "exchange": "BSE" if not nse_symbol else "NSE+BSE",
391
+ "bse_code": str(code),
392
+ }
393
+ if nse_symbol:
394
+ stock["yahoo_ticker"] = f"{nse_symbol}.NS"
395
+ else:
396
+ stock["yahoo_ticker"] = f"{code}.BO"
397
+ stocks.append(stock)
398
+
399
+ print(f" ✅ Got {len(stocks)} stocks from BSE")
400
+ return stocks
401
+ except Exception as e:
402
+ print(f" ⚠️ BSE API failed: {e}")
403
+
404
+ return stocks
405
+
406
+
407
+ if __name__ == "__main__":
408
+ print("=" * 60)
409
+ print(" 📊 INDIAN STOCK LIST DOWNLOADER")
410
+ print("=" * 60)
411
+ build_full_stock_list()
data/indian_stocks.json ADDED
The diff for this file is too large to render. See raw diff
 
domain/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # This file makes "domain" a Python package.
2
+ # Python needs this to treat the folder as a package you can import from.
domain/models.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ===========================================
2
+ # Alpha Sentiment Engine — Data Models
3
+ # ===========================================
4
+ # These define the SHAPE of our data.
5
+ # Think of them as templates / forms:
6
+ # - NewsItem: the ORDER (what goes into the queue)
7
+ # - SentimentResult: the FINISHED DISH (what comes out)
8
+ #
9
+ # Both sides (scraper + AI worker) use these same templates
10
+ # so they agree on what the data looks like. That's the
11
+ # "contract" between them.
12
+ # ===========================================
13
+
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+
17
+
18
+ @dataclass
19
+ class NewsItem:
20
+ """
21
+ The INPUT — a headline we want to score.
22
+
23
+ Example:
24
+ item = NewsItem(ticker="AAPL", headline="Apple beats earnings")
25
+ """
26
+ ticker: str # stock symbol, e.g. "AAPL"
27
+ headline: str # the news headline text
28
+
29
+ def to_dict(self) -> dict:
30
+ """Convert to a plain dictionary so we can send it as JSON."""
31
+ return {
32
+ "ticker": self.ticker,
33
+ "headline": self.headline,
34
+ }
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict) -> "NewsItem":
38
+ """Create a NewsItem from a plain dictionary."""
39
+ return cls(
40
+ ticker=data["ticker"],
41
+ headline=data["headline"],
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class SentimentResult:
47
+ """
48
+ The OUTPUT — a scored headline.
49
+
50
+ Example:
51
+ result = SentimentResult(
52
+ ticker="AAPL",
53
+ sentiment_score=0.89,
54
+ headline="Apple beats earnings",
55
+ timestamp="2026-02-22T15:00:00+00:00"
56
+ )
57
+ """
58
+ ticker: str # stock symbol
59
+ sentiment_score: float # -1.0 (very bad) to +1.0 (very good)
60
+ headline: str # the original headline
61
+ timestamp: str # when the analysis happened (ISO 8601)
62
+
63
+ def to_dict(self) -> dict:
64
+ """Convert to a plain dictionary (JSON-ready)."""
65
+ return {
66
+ "ticker": self.ticker,
67
+ "sentiment_score": self.sentiment_score,
68
+ "headline": self.headline,
69
+ "timestamp": self.timestamp,
70
+ }
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: dict) -> "SentimentResult":
74
+ """Create a SentimentResult from a plain dictionary."""
75
+ return cls(
76
+ ticker=data["ticker"],
77
+ sentiment_score=data["sentiment_score"],
78
+ headline=data["headline"],
79
+ timestamp=data["timestamp"],
80
+ )
live_scraper.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # ===========================================
3
+ # Alpha Sentiment Engine — Live Scraper V5
4
+ # ===========================================
5
+ # THREE-PHASE HYBRID ARCHITECTURE:
6
+ # Phase A — Sector news scan (20 sectors)
7
+ # Phase B — Direct company news (top 200)
8
+ # Phase C — Hybrid merge (1000+ companies)
9
+ #
10
+ # FEATURES:
11
+ # - Entity validation on direct news
12
+ # - Headline deduplication
13
+ # - Stock price overlay via yfinance
14
+ # - Confidence levels (HIGH / MEDIUM / LOW)
15
+ # - Score types (DIRECT / HYBRID / SECTOR)
16
+ # - Multi-source: Google News + Yahoo Finance
17
+ # ===========================================
18
+
19
+ import time
20
+ import json
21
+ import hashlib
22
+ import feedparser
23
+ import requests
24
+ import yfinance as yf
25
+ from urllib.parse import quote
26
+ from datetime import datetime, timezone
27
+ from concurrent.futures import ThreadPoolExecutor, as_completed
28
+ from colorama import Fore, Style, init
29
+
30
+ from services.sentiment_service import SentimentService
31
+ from services.database import save_score, save_average, get_stats
32
+ from domain.models import NewsItem
33
+
34
+ init(autoreset=True)
35
+
36
+
37
+ # ───────────────────────────────────────────
38
+ # Settings
39
+ # ───────────────────────────────────────────
40
+ SCRAPE_INTERVAL: int = 600 # 10 minutes (Improved from 30 mins)
41
+ HEADLINES_PER_SEARCH: int = 8
42
+ STOCKS_FILE = "data/indian_stocks.json"
43
+
44
+ # How many top companies get direct news search
45
+ DIRECT_SCAN_COUNT = 200
46
+
47
+ # How many total companies to score (via hybrid merge)
48
+ TOTAL_COMPANY_TARGET = 1000
49
+
50
+ # Sector news search queries
51
+ SECTOR_QUERIES = {
52
+ "Banking": "Indian banking sector news stock market",
53
+ "IT": "Indian IT sector technology stocks news",
54
+ "Pharma": "Indian pharma healthcare stocks news",
55
+ "Auto": "Indian auto sector automobile stocks news",
56
+ "FMCG": "Indian FMCG consumer goods stocks news",
57
+ "Energy": "Indian energy power oil gas stocks news",
58
+ "Metal": "Indian metal steel mining stocks news",
59
+ "Telecom": "Indian telecom sector stocks news",
60
+ "Real Estate": "Indian real estate construction cement stocks news",
61
+ "Chemicals": "Indian chemicals paints sector stocks news",
62
+ "Textiles": "Indian textile apparel sector stocks news",
63
+ "Media": "Indian media entertainment sector stocks news",
64
+ "Insurance": "Indian insurance sector stocks news",
65
+ "Agriculture": "Indian agriculture sugar fertilizer stocks news",
66
+ "Logistics": "Indian logistics transport aviation stocks news",
67
+ "Retail": "Indian retail e-commerce stocks news",
68
+ "Defence": "Indian defence aerospace shipbuilding stocks news",
69
+ "Consumer Durables": "Indian consumer durables electronics stocks news",
70
+ "Infrastructure": "Indian infrastructure stocks news",
71
+ "Hotels": "Indian hotel hospitality tourism stocks news",
72
+ }
73
+
74
+ # Top 200 companies for direct news scanning (Nifty 50 + Next 50 + Midcap 100)
75
+ TOP_COMPANIES = [
76
+ # Nifty 50
77
+ "RELIANCE", "TCS", "HDFCBANK", "ICICIBANK", "INFY", "HINDUNILVR",
78
+ "ITC", "SBIN", "BHARTIARTL", "KOTAKBANK", "LT", "AXISBANK",
79
+ "BAJFINANCE", "MARUTI", "HCLTECH", "ASIANPAINT", "SUNPHARMA",
80
+ "TITAN", "WIPRO", "ONGC", "BAJAJFINSV", "ULTRACEMCO", "NTPC",
81
+ "TATAMOTORS", "POWERGRID", "NESTLEIND", "JSWSTEEL", "TATASTEEL",
82
+ "ADANIPORTS", "ADANIENT", "TECHM", "M&M", "COALINDIA",
83
+ "INDUSINDBK", "HINDALCO", "SBILIFE", "GRASIM", "HDFCLIFE",
84
+ "CIPLA", "DIVISLAB", "DRREDDY", "EICHERMOT", "BPCL",
85
+ "APOLLOHOSP", "HEROMOTOCO", "BAJAJ-AUTO", "TATACONSUM", "BRITANNIA",
86
+ "SHRIRAMFIN", "LTIM",
87
+ # Nifty Next 50
88
+ "ADANIGREEN", "ADANIPOWER", "AMBUJACEM", "BANKBARODA", "BERGEPAINT",
89
+ "BOSCHLTD", "CANBK", "CHOLAFIN", "COLPAL", "DABUR",
90
+ "DLF", "GAIL", "GODREJCP", "HAVELLS", "ICICIPRULI",
91
+ "IDFCFIRSTB", "INDIGO", "IOC", "IRCTC", "JINDALSTEL",
92
+ "LUPIN", "MAXHEALTH", "MOTHERSON", "NAUKRI", "OBEROIRLTY",
93
+ "OFSS", "PAGEIND", "PEL", "PIDILITIND", "PNB",
94
+ "SAIL", "SRF", "TATAPOWER", "TORNTPHARM", "TRENT",
95
+ "UNIONBANK", "VEDL", "YESBANK", "ZOMATO", "PAYTM",
96
+ # Midcap 100 extras
97
+ "DMART", "POLICYBZR", "NYKAA", "PB", "IRFC",
98
+ "IDEA", "RECLTD", "NHPC", "HAL", "BEL",
99
+ "TATAELXSI", "PERSISTENT", "COFORGE", "MPHASIS", "LTTS",
100
+ "PVRINOX", "PATANJALI", "MAZDOCK", "COCHINSHIP", "GRSE",
101
+ "BIOCON", "AUROPHARMA", "ALKEM", "LALPATHLAB", "MARICO",
102
+ "ASHOKLEY", "TVSMOTORS", "EXIDEIND", "AMARARAJA", "MUTHOOTFIN",
103
+ "MANAPPURAM", "VOLTAS", "CROMPTON", "DIXON", "NMDC",
104
+ "NATIONALUM", "LICHSGFIN", "CANFINHOME", "SHREECEM", "ACC",
105
+ "GODREJPROP", "EMAMILTD", "HAPPSTMNDS", "MASTEK", "SONATA",
106
+ "ABCAPITAL", "IIFL", "FEDERALBNK", "RBLBANK", "BANDHANBNK",
107
+ "JUBLFOOD", "MCDOWELL-N", "CONCOR", "SUNTV", "NETWORK18",
108
+ "DEEPAKNTR", "ATUL", "PIIND", "ASTRAL", "SUPREMEIND",
109
+ "APLAPOLLO", "KENNAMET", "CUMMINSIND", "SIEMENS", "ABB",
110
+ "HONAUT", "BHARATFORG", "SUNDRMFAST", "MFSL", "NAM-INDIA",
111
+ "IPCALAB", "NATCOPHARMA", "GLENMARK", "LAURUSLABS", "SYNGENE",
112
+ "GODREJIND", "UBL", "TATACOMM", "STARHEALTH", "KPITTECH",
113
+ ]
114
+
115
+
116
+ # ───────────────────────────────────────────
117
+ # Load stock universe
118
+ # ───────────────────────────────────────────
119
+ def load_stocks():
120
+ print("📥 Loading Indian stock database...")
121
+ try:
122
+ with open(STOCKS_FILE, "r") as f:
123
+ data = json.load(f)
124
+ return data["stocks"], data.get("sectors", {})
125
+ except Exception as e:
126
+ print(f"❌ Could not load {STOCKS_FILE}. {e}")
127
+ return [], {}
128
+
129
+
130
+ # ───────────────────────────────────────────
131
+ # News Sources
132
+ # ───────────────────────────────────────────
133
+ def fetch_google_news(query: str, count: int = 8) -> list[dict]:
134
+ """Fetch headlines from Google News RSS."""
135
+ encoded_query = quote(query)
136
+ url = f"https://news.google.com/rss/search?q={encoded_query}&hl=en-IN&gl=IN&ceid=IN:en"
137
+ try:
138
+ feed = feedparser.parse(url)
139
+ results = []
140
+ for entry in feed.entries[:count]:
141
+ title = entry.get("title", "").split(" - ")[0]
142
+ source = entry.get("source", {}).get("title", "News") if hasattr(entry, "source") else "News"
143
+ if title:
144
+ results.append({"title": title, "source": f"📰 {source}"})
145
+ return results
146
+ except Exception:
147
+ return []
148
+
149
+
150
+ def fetch_yahoo_finance_news(query: str, count: int = 5) -> list[dict]:
151
+ """Fetch headlines from Yahoo Finance RSS as a secondary source."""
152
+ encoded_query = quote(query)
153
+ url = f"https://news.google.com/rss/search?q={encoded_query}+share+price+NSE+BSE&hl=en-IN&gl=IN&ceid=IN:en"
154
+ try:
155
+ feed = feedparser.parse(url)
156
+ results = []
157
+ for entry in feed.entries[:count]:
158
+ title = entry.get("title", "").split(" - ")[0]
159
+ if title:
160
+ results.append({"title": title, "source": "📈 Finance"})
161
+ return results
162
+ except Exception:
163
+ return []
164
+
165
+
166
+ # ───────────────────────────────────────────
167
+ # Entity Validation
168
+ # ───────────────────────────────────────────
169
+ def validate_headline(headline: str, company_name: str, ticker: str) -> bool:
170
+ """Check if a headline is ACTUALLY about this specific company."""
171
+ headline_lower = headline.lower()
172
+
173
+ clean_name = company_name.lower()
174
+ for suffix in [" limited", " ltd", " ltd.", " corporation", " corp", " inc"]:
175
+ clean_name = clean_name.replace(suffix, "").strip()
176
+
177
+ name_parts = clean_name.split()
178
+
179
+ if ticker.lower() in headline_lower:
180
+ return True
181
+
182
+ if len(clean_name) > 3 and clean_name in headline_lower:
183
+ return True
184
+
185
+ if len(name_parts) >= 1 and len(name_parts[0]) > 4:
186
+ if name_parts[0] in headline_lower:
187
+ return True
188
+
189
+ if len(name_parts) >= 2:
190
+ two_word = f"{name_parts[0]} {name_parts[1]}"
191
+ if two_word in headline_lower:
192
+ return True
193
+
194
+ return False
195
+
196
+
197
+ # ───────────────────────────────────────────
198
+ # Headline Deduplication
199
+ # ───────────────────────────────────────────
200
+ def headline_hash(text: str) -> str:
201
+ """Create a short hash of a headline for dedup."""
202
+ return hashlib.md5(text.lower().strip().encode()).hexdigest()[:12]
203
+
204
+
205
+ # ───────────────────────────────────────────
206
+ # Stock Price Fetcher (yfinance)
207
+ # ───────────────────────────────────────────
208
+ def get_price_change(yahoo_ticker: str) -> float | None:
209
+ """Get today's stock price % change using yfinance."""
210
+ try:
211
+ stock = yf.Ticker(yahoo_ticker)
212
+ hist = stock.history(period="2d")
213
+ if len(hist) >= 2:
214
+ prev_close = hist["Close"].iloc[-2]
215
+ curr_close = hist["Close"].iloc[-1]
216
+ pct = round(((curr_close - prev_close) / prev_close) * 100, 2)
217
+ return pct
218
+ except Exception:
219
+ pass
220
+ return None
221
+
222
+
223
+ # ───────────────────────────────────────────
224
+ # Scoring Helpers
225
+ # ───────────────────────────────────────────
226
+ def score_headline(service: SentimentService, headline: str) -> float:
227
+ item = NewsItem(ticker="DUMMY", headline=headline)
228
+ result = service.analyze(item)
229
+ return result.sentiment_score
230
+
231
+
232
+ def get_sentiment_color(score: float) -> str:
233
+ if score > 0.3: return Fore.GREEN
234
+ elif score < -0.3: return Fore.RED
235
+ else: return Fore.YELLOW
236
+
237
+
238
+ def get_sentiment_label(score: float) -> str:
239
+ if score > 0.5: return "🟢 V.Bullish"
240
+ elif score > 0.3: return "🟢 Bullish"
241
+ elif score > -0.3: return "🟡 Neutral"
242
+ elif score > -0.5: return "🔴 Bearish"
243
+ else: return "🔴 V.Bearish"
244
+
245
+
246
+ def get_confidence(num_headlines: int, score_type: str) -> str:
247
+ if score_type == "SECTOR":
248
+ return "LOW"
249
+ if num_headlines >= 3:
250
+ return "HIGH"
251
+ elif num_headlines >= 1:
252
+ return "MEDIUM"
253
+ return "LOW"
254
+
255
+
256
+ # ───────────────────────────────────────────
257
+ # MAIN: Three-Phase Scrape Cycle (V5)
258
+ # ───────────────────────────────────────────
259
+ def run_scrape_cycle(service: SentimentService, stocks: list[dict], sectors_map: dict) -> None:
260
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
261
+ start_time = time.time()
262
+
263
+ # Build lookup dictionaries
264
+ stock_by_symbol = {s["symbol"]: s for s in stocks}
265
+ seen_headlines = set() # For deduplication
266
+
267
+ print("\n" + "=" * 80)
268
+ print(f" 📡 LIVE SCRAPE V5 — {now}")
269
+ print(f" 🧠 Three-Phase Hybrid Architecture")
270
+ print(f" 📊 Phase A: {len(SECTOR_QUERIES)} sectors | Phase B: {len(TOP_COMPANIES)} direct | Phase C: {TOTAL_COMPANY_TARGET}+ hybrid")
271
+ print("=" * 80)
272
+
273
+ # ══════════════════════════════════════════
274
+ # PHASE A: Sector News Scan
275
+ # ══════════════════════════════════════════
276
+ print(f"\n ═══ PHASE A: Scanning {len(SECTOR_QUERIES)} Sector News Feeds ═══")
277
+ sector_scores = {}
278
+
279
+ for sector_name, query in SECTOR_QUERIES.items():
280
+ headlines = fetch_google_news(query, HEADLINES_PER_SEARCH)
281
+ if not headlines:
282
+ continue
283
+
284
+ scores = []
285
+ for article in headlines:
286
+ h = headline_hash(article["title"])
287
+ if h in seen_headlines:
288
+ continue
289
+ seen_headlines.add(h)
290
+
291
+ score = score_headline(service, article["title"])
292
+ scores.append(score)
293
+ save_score(f"SECTOR_{sector_name}", article["title"], score, article["source"])
294
+
295
+ if scores:
296
+ avg = sum(scores) / len(scores)
297
+ sector_scores[sector_name] = {"score": avg, "headlines": len(scores)}
298
+ color = get_sentiment_color(avg)
299
+ print(f" {sector_name:<22} {color}{avg:+.4f}{Style.RESET_ALL} ({len(scores)} headlines) {get_sentiment_label(avg)}")
300
+
301
+ print(f" ✅ Phase A complete: {len(sector_scores)} sectors scored")
302
+
303
+ # ══════════════════════════════════════════
304
+ # PHASE B: Direct Company News Scan
305
+ # ══════════════════════════════════════════
306
+ print(f"\n ═══ PHASE B: Direct News for {len(TOP_COMPANIES)} Companies ═══")
307
+ direct_scores = {}
308
+ total_headlines_found = 0
309
+ total_validated = 0
310
+ total_rejected = 0
311
+
312
+ for i, symbol in enumerate(TOP_COMPANIES):
313
+ stock_obj = stock_by_symbol.get(symbol)
314
+ if not stock_obj:
315
+ continue
316
+
317
+ company_name = stock_obj["name"]
318
+
319
+ # Multi-source: Google News + Yahoo Finance queries
320
+ headlines = fetch_google_news(company_name, HEADLINES_PER_SEARCH)
321
+
322
+ if not headlines:
323
+ continue
324
+
325
+ validated_scores = []
326
+ for article in headlines:
327
+ total_headlines_found += 1
328
+ title = article["title"]
329
+
330
+ # Dedup check
331
+ h = headline_hash(title)
332
+ if h in seen_headlines:
333
+ continue
334
+ seen_headlines.add(h)
335
+
336
+ # Entity validation
337
+ is_valid = validate_headline(title, company_name, symbol)
338
+ if is_valid:
339
+ score = score_headline(service, title)
340
+ validated_scores.append(score)
341
+ save_score(symbol, title, score, article["source"], validated=True)
342
+ total_validated += 1
343
+ else:
344
+ total_rejected += 1
345
+
346
+ if validated_scores:
347
+ avg_score = sum(validated_scores) / len(validated_scores)
348
+ direct_scores[symbol] = {
349
+ "score": avg_score,
350
+ "headlines": len(validated_scores),
351
+ "name": company_name,
352
+ }
353
+
354
+ color = get_sentiment_color(avg_score)
355
+ print(f" {symbol:<15} {color}{avg_score:+.4f}{Style.RESET_ALL} "
356
+ f"({len(validated_scores)}/{len(headlines)} validated) "
357
+ f"{get_sentiment_label(avg_score)}")
358
+
359
+ # Rate limiting
360
+ if (i + 1) % 25 == 0:
361
+ print(f" ... {i + 1}/{len(TOP_COMPANIES)} companies scanned ...")
362
+ time.sleep(1)
363
+
364
+ print(f" ✅ Phase B complete: {len(direct_scores)} companies with direct news")
365
+ print(f" Headlines: {total_headlines_found} found | {total_validated} validated | {total_rejected} rejected")
366
+
367
+ # ══════════════════════════════════════════
368
+ # PHASE C: Hybrid Merge + Price Overlay
369
+ # ══════════════════════════════════════════
370
+ print(f"\n ═══ PHASE C: Hybrid Merge + Price Overlay ═══")
371
+
372
+ # Determine which stocks to score (up to TOTAL_COMPANY_TARGET)
373
+ all_symbols = list(stock_by_symbol.keys())[:TOTAL_COMPANY_TARGET]
374
+
375
+ # Fetch prices for direct-scored companies in parallel
376
+ print(f" 📈 Fetching stock prices for {len(direct_scores)} companies...")
377
+ price_data = {}
378
+
379
+ def fetch_price(symbol, yahoo_ticker):
380
+ return symbol, get_price_change(yahoo_ticker)
381
+
382
+ with ThreadPoolExecutor(max_workers=5) as executor:
383
+ futures = {}
384
+ for sym in direct_scores:
385
+ stock_obj = stock_by_symbol.get(sym)
386
+ if stock_obj:
387
+ yahoo_ticker = stock_obj.get("yahoo_ticker", f"{sym}.NS")
388
+ futures[executor.submit(fetch_price, sym, yahoo_ticker)] = sym
389
+
390
+ for future in as_completed(futures):
391
+ try:
392
+ sym, pct = future.result()
393
+ if pct is not None:
394
+ price_data[sym] = pct
395
+ except Exception:
396
+ pass
397
+
398
+ print(f" ✅ Got price data for {len(price_data)} companies")
399
+
400
+ # Merge and save
401
+ final_scored = {}
402
+ direct_count = 0
403
+ hybrid_count = 0
404
+ sector_only_count = 0
405
+
406
+ for symbol in all_symbols:
407
+ stock_obj = stock_by_symbol.get(symbol)
408
+ if not stock_obj:
409
+ continue
410
+
411
+ sector = stock_obj.get("sector", "General")
412
+ sector_score_data = sector_scores.get(sector)
413
+ direct_data = direct_scores.get(symbol)
414
+ price_pct = price_data.get(symbol)
415
+
416
+ if direct_data and sector_score_data:
417
+ # HYBRID: 70% direct + 30% sector
418
+ final_score = 0.7 * direct_data["score"] + 0.3 * sector_score_data["score"]
419
+ num_headlines = direct_data["headlines"]
420
+ score_type = "HYBRID"
421
+ confidence = get_confidence(num_headlines, score_type)
422
+ hybrid_count += 1
423
+ elif direct_data:
424
+ # DIRECT only (sector has no score)
425
+ final_score = direct_data["score"]
426
+ num_headlines = direct_data["headlines"]
427
+ score_type = "DIRECT"
428
+ confidence = get_confidence(num_headlines, score_type)
429
+ direct_count += 1
430
+ elif sector_score_data:
431
+ # SECTOR only (no direct news for this company)
432
+ final_score = sector_score_data["score"]
433
+ num_headlines = sector_score_data["headlines"]
434
+ score_type = "SECTOR"
435
+ confidence = "LOW"
436
+ sector_only_count += 1
437
+ else:
438
+ # No data at all (sector is "General" with no news)
439
+ continue
440
+
441
+ save_average(
442
+ ticker=symbol,
443
+ average_score=round(final_score, 6),
444
+ num_headlines=num_headlines,
445
+ confidence=confidence,
446
+ price_change=price_pct,
447
+ score_type=score_type,
448
+ )
449
+ final_scored[symbol] = {
450
+ "score": final_score,
451
+ "type": score_type,
452
+ "confidence": confidence,
453
+ "price": price_pct,
454
+ "name": stock_obj["name"],
455
+ }
456
+
457
+ elapsed = time.time() - start_time
458
+
459
+ # ─── SUMMARY ───
460
+ print("\n" + "=" * 80)
461
+ print(" 📊 V5 CYCLE COMPLETE: Three-Phase Hybrid Scoring")
462
+ print("=" * 80)
463
+ print(f" ✅ Total companies scored: {len(final_scored)}")
464
+ print(f" 🎯 DIRECT: {direct_count} (company-specific news)")
465
+ print(f" 📊 HYBRID: {hybrid_count} (direct + sector combined)")
466
+ print(f" 🏷️ SECTOR: {sector_only_count} (sector news only)")
467
+ print(f" 📰 Total unique headlines: {len(seen_headlines)}")
468
+ print(f" 📈 Price data: {len(price_data)} companies")
469
+
470
+ # Top movers
471
+ top_bullish = sorted(
472
+ [(s, d) for s, d in final_scored.items() if d["score"] > 0.3],
473
+ key=lambda x: -x[1]["score"]
474
+ )[:10]
475
+ top_bearish = sorted(
476
+ [(s, d) for s, d in final_scored.items() if d["score"] < -0.3],
477
+ key=lambda x: x[1]["score"]
478
+ )[:10]
479
+
480
+ print("\n 🟢 TOP BULLISH:")
481
+ for symbol, data in top_bullish:
482
+ price_str = f" 📈 {data['price']:+.2f}%" if data["price"] is not None else ""
483
+ type_icon = "🎯" if data["type"] == "DIRECT" else ("📊" if data["type"] == "HYBRID" else "🏷️")
484
+ print(f" {symbol:<12} {Fore.GREEN}{data['score']:+.4f}{Style.RESET_ALL} "
485
+ f"[{data['confidence']}] {type_icon}{price_str} {data['name'][:30]}")
486
+
487
+ print("\n 🔴 TOP BEARISH:")
488
+ for symbol, data in top_bearish:
489
+ price_str = f" 📉 {data['price']:+.2f}%" if data["price"] is not None else ""
490
+ type_icon = "🎯" if data["type"] == "DIRECT" else ("📊" if data["type"] == "HYBRID" else "🏷️")
491
+ print(f" {symbol:<12} {Fore.RED}{data['score']:+.4f}{Style.RESET_ALL} "
492
+ f"[{data['confidence']}] {type_icon}{price_str} {data['name'][:30]}")
493
+
494
+ print("\n" + "=" * 80)
495
+ stats = get_stats()
496
+ print(f" 💾 DB Totals: {stats['total_scores']} headlines | {stats['total_averages']} averages")
497
+ print(f" ⏱️ Cycle completed in {elapsed:.1f} seconds")
498
+ print("=" * 80)
499
+
500
+
501
+ def run_sync_loop(service=None):
502
+ """
503
+ Run the scraper in a continuous loop.
504
+ Can be called from a background thread in app.py.
505
+ """
506
+ print("\n" + "=" * 80)
507
+ print(" 🚀 SENTIX BACKGROUND SYNC ENGINE — ACTIVE")
508
+ print("=" * 80)
509
+
510
+ stocks, sectors = load_stocks()
511
+ if not stocks:
512
+ return
513
+
514
+ if service is None:
515
+ print("⏳ Loading FinBERT model for background worker...")
516
+ service = SentimentService()
517
+ print("✅ AI loaded and ready!")
518
+
519
+ while True:
520
+ try:
521
+ print(f"\n[{datetime.now().strftime('%H:%M:%S')}] 🚀 Scrape Cycle Started ({len(stocks)} symbols)")
522
+ run_scrape_cycle(service, stocks, sectors)
523
+ print(f"\n💤 Cycle complete. Sleeping for {SCRAPE_INTERVAL // 60} minutes...")
524
+ except Exception as e:
525
+ print(f"❌ FATAL ERROR IN SYNC CYCLE: {str(e)}")
526
+ import traceback
527
+ traceback.print_exc()
528
+
529
+ time.sleep(SCRAPE_INTERVAL)
530
+
531
+ def main():
532
+ print("=" * 80)
533
+ print(" 🚀 ALPHA SENTIMENT ENGINE — V5 (1000+ Companies, Hybrid Scoring)")
534
+ print("=" * 80)
535
+
536
+ service = SentimentService()
537
+ run_sync_loop(service)
538
+
539
+ if __name__ == "__main__":
540
+ main()
models/my_finbert/config.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_cross_attention": false,
3
+ "architectures": [
4
+ "BertForSequenceClassification"
5
+ ],
6
+ "attention_probs_dropout_prob": 0.1,
7
+ "bos_token_id": null,
8
+ "classifier_dropout": null,
9
+ "dtype": "float32",
10
+ "eos_token_id": null,
11
+ "gradient_checkpointing": false,
12
+ "hidden_act": "gelu",
13
+ "hidden_dropout_prob": 0.1,
14
+ "hidden_size": 768,
15
+ "id2label": {
16
+ "0": "positive",
17
+ "1": "negative",
18
+ "2": "neutral"
19
+ },
20
+ "initializer_range": 0.02,
21
+ "intermediate_size": 3072,
22
+ "is_decoder": false,
23
+ "label2id": {
24
+ "negative": 1,
25
+ "neutral": 2,
26
+ "positive": 0
27
+ },
28
+ "layer_norm_eps": 1e-12,
29
+ "max_position_embeddings": 512,
30
+ "model_type": "bert",
31
+ "num_attention_heads": 12,
32
+ "num_hidden_layers": 12,
33
+ "pad_token_id": 0,
34
+ "position_embedding_type": "absolute",
35
+ "problem_type": "single_label_classification",
36
+ "tie_word_embeddings": true,
37
+ "transformers_version": "5.2.0",
38
+ "type_vocab_size": 2,
39
+ "use_cache": false,
40
+ "vocab_size": 30522
41
+ }
models/my_finbert/model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:70db0f54f3f63df7a0a5c07d0007d8345852f58320a241772afb23594dd18321
3
+ size 437961700
models/my_finbert/tokenizer.json ADDED
The diff for this file is too large to render. See raw diff
 
models/my_finbert/tokenizer_config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "cls_token": "[CLS]",
4
+ "do_lower_case": true,
5
+ "is_local": false,
6
+ "mask_token": "[MASK]",
7
+ "model_max_length": 512,
8
+ "pad_token": "[PAD]",
9
+ "sep_token": "[SEP]",
10
+ "strip_accents": null,
11
+ "tokenize_chinese_chars": true,
12
+ "tokenizer_class": "BertTokenizer",
13
+ "unk_token": "[UNK]"
14
+ }
models/my_finbert/training_args.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:914f545b07e8bec5245e1a41ece77f5615c086eefab6bbb3afbd9b337edb930a
3
+ size 5201
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Alpha Sentiment Engine — Dependencies
2
+ # Install with: pip install -r requirements.txt
3
+
4
+ requests # Lets your code talk to websites (fetch stock prices & news)
5
+ pandas # Organizes data into clean tables (like Excel for code)
6
+ transformers # HuggingFace — downloads & loads the FinBERT AI model
7
+ torch # PyTorch — the math engine that runs the AI
8
+ flask # Web server for dashboard
9
+ feedparser # RSS parser for news scraper
10
+ yfinance # Yahoo Finance library
11
+ colorama # Terminal colors
12
+ huggingface_hub # HF API for deployment
13
+
sentiment_data.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:86870f31e157196a5b000659a00e87c30c317951b7fc224c453ed8f49c60e8c4
3
+ size 16379904
services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This file makes "services" a Python package.
services/database.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # ===========================================
3
+ # Alpha Sentiment Engine — Database
4
+ # ===========================================
5
+ # This file manages the SQLite database.
6
+ # SQLite is a simple database that lives as
7
+ # a single file on your hard drive. No setup
8
+ # needed, no Docker, no passwords.
9
+ #
10
+ # It stores every single sentiment score so
11
+ # we can track trends over time.
12
+ # ===========================================
13
+
14
+ import sqlite3
15
+ from datetime import datetime, timezone
16
+
17
+
18
+ # The database file (created automatically)
19
+ DB_FILE = "sentiment_data.db"
20
+
21
+
22
+ def get_connection() -> sqlite3.Connection:
23
+ """
24
+ Open a connection to the database.
25
+ If the database file doesn't exist yet, SQLite creates it automatically.
26
+ """
27
+ conn = sqlite3.connect(DB_FILE)
28
+ conn.row_factory = sqlite3.Row # So we can access columns by name
29
+ return conn
30
+
31
+
32
+ def create_tables() -> None:
33
+ """
34
+ Create the database tables if they don't already exist.
35
+ This is safe to call multiple times — it won't delete existing data.
36
+ """
37
+ conn = get_connection()
38
+ cursor = conn.cursor()
39
+
40
+ # The main table: stores every scored headline
41
+ cursor.execute("""
42
+ CREATE TABLE IF NOT EXISTS sentiment_scores (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ ticker TEXT NOT NULL,
45
+ headline TEXT NOT NULL,
46
+ score REAL NOT NULL,
47
+ source TEXT,
48
+ validated INTEGER DEFAULT 1,
49
+ scraped_at TEXT NOT NULL
50
+ )
51
+ """)
52
+
53
+ # A summary table: stores the average per stock per scrape cycle
54
+ cursor.execute("""
55
+ CREATE TABLE IF NOT EXISTS sentiment_averages (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ ticker TEXT NOT NULL,
58
+ average_score REAL NOT NULL,
59
+ num_headlines INTEGER NOT NULL,
60
+ confidence TEXT DEFAULT 'LOW',
61
+ price_change REAL,
62
+ score_type TEXT DEFAULT 'DIRECT',
63
+ scraped_at TEXT NOT NULL
64
+ )
65
+ """)
66
+
67
+ # Migrate: add new columns to existing tables if they don't exist
68
+ try:
69
+ cursor.execute("ALTER TABLE sentiment_scores ADD COLUMN validated INTEGER DEFAULT 1")
70
+ except sqlite3.OperationalError:
71
+ pass
72
+ try:
73
+ cursor.execute("ALTER TABLE sentiment_averages ADD COLUMN confidence TEXT DEFAULT 'LOW'")
74
+ except sqlite3.OperationalError:
75
+ pass
76
+ try:
77
+ cursor.execute("ALTER TABLE sentiment_averages ADD COLUMN price_change REAL")
78
+ except sqlite3.OperationalError:
79
+ pass
80
+ try:
81
+ cursor.execute("ALTER TABLE sentiment_averages ADD COLUMN score_type TEXT DEFAULT 'DIRECT'")
82
+ except sqlite3.OperationalError:
83
+ pass
84
+
85
+ conn.commit()
86
+ conn.close()
87
+
88
+
89
+ def save_score(ticker: str, headline: str, score: float, source: str, validated: bool = True) -> None:
90
+ """
91
+ Save one scored headline to the database.
92
+ Called once per headline after the AI scores it.
93
+ """
94
+ conn = get_connection()
95
+ cursor = conn.cursor()
96
+
97
+ now = datetime.now(timezone.utc).isoformat()
98
+
99
+ cursor.execute(
100
+ "INSERT INTO sentiment_scores (ticker, headline, score, source, validated, scraped_at) VALUES (?, ?, ?, ?, ?, ?)",
101
+ (ticker, headline, score, source, 1 if validated else 0, now),
102
+ )
103
+
104
+ conn.commit()
105
+ conn.close()
106
+
107
+
108
+ def save_average(ticker: str, average_score: float, num_headlines: int,
109
+ confidence: str = "LOW", price_change: float = None,
110
+ score_type: str = "DIRECT") -> None:
111
+ """
112
+ Save the average sentiment for a stock after a scrape cycle.
113
+ This is what the dashboard will use to draw trend charts.
114
+ """
115
+ conn = get_connection()
116
+ cursor = conn.cursor()
117
+
118
+ now = datetime.now(timezone.utc).isoformat()
119
+
120
+ cursor.execute(
121
+ "INSERT INTO sentiment_averages (ticker, average_score, num_headlines, confidence, price_change, score_type, scraped_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
122
+ (ticker, average_score, num_headlines, confidence, price_change, score_type, now),
123
+ )
124
+
125
+ conn.commit()
126
+ conn.close()
127
+
128
+
129
+ def get_recent_scores(ticker: str = None, limit: int = 50) -> list[dict]:
130
+ """
131
+ Get the most recent scored headlines from the database.
132
+ If ticker is provided, filter by that stock.
133
+ """
134
+ conn = get_connection()
135
+ cursor = conn.cursor()
136
+
137
+ if ticker:
138
+ cursor.execute(
139
+ "SELECT * FROM sentiment_scores WHERE ticker = ? ORDER BY scraped_at DESC LIMIT ?",
140
+ (ticker, limit),
141
+ )
142
+ else:
143
+ cursor.execute(
144
+ "SELECT * FROM sentiment_scores ORDER BY scraped_at DESC LIMIT ?",
145
+ (limit,),
146
+ )
147
+
148
+ rows = cursor.fetchall()
149
+ conn.close()
150
+
151
+ return [dict(row) for row in rows]
152
+
153
+
154
+ def get_recent_averages(ticker: str = None, limit: int = 100) -> list[dict]:
155
+ """
156
+ Get the most recent average scores (for trend charts).
157
+ If ticker is provided, filter by that stock.
158
+ """
159
+ conn = get_connection()
160
+ cursor = conn.cursor()
161
+
162
+ if ticker:
163
+ cursor.execute(
164
+ "SELECT * FROM sentiment_averages WHERE ticker = ? ORDER BY scraped_at DESC LIMIT ?",
165
+ (ticker, limit),
166
+ )
167
+ else:
168
+ cursor.execute(
169
+ "SELECT * FROM sentiment_averages ORDER BY scraped_at DESC LIMIT ?",
170
+ (limit,),
171
+ )
172
+
173
+ rows = cursor.fetchall()
174
+ conn.close()
175
+
176
+ return [dict(row) for row in rows]
177
+
178
+
179
+ def get_all_tickers() -> list[str]:
180
+ """
181
+ Get a list of all unique ticker symbols in the database.
182
+ """
183
+ conn = get_connection()
184
+ cursor = conn.cursor()
185
+
186
+ cursor.execute("SELECT DISTINCT ticker FROM sentiment_averages ORDER BY ticker")
187
+ rows = cursor.fetchall()
188
+ conn.close()
189
+
190
+ return [row["ticker"] for row in rows]
191
+
192
+
193
+ def get_stats() -> dict:
194
+ """
195
+ Get overall database statistics.
196
+ """
197
+ conn = get_connection()
198
+ cursor = conn.cursor()
199
+
200
+ cursor.execute("SELECT COUNT(*) as count FROM sentiment_scores")
201
+ total_scores = cursor.fetchone()["count"]
202
+
203
+ cursor.execute("SELECT COUNT(*) as count FROM sentiment_averages")
204
+ total_averages = cursor.fetchone()["count"]
205
+
206
+ cursor.execute("SELECT COUNT(DISTINCT ticker) as count FROM sentiment_scores")
207
+ unique_tickers = cursor.fetchone()["count"]
208
+
209
+ conn.close()
210
+
211
+ return {
212
+ "total_scores": total_scores,
213
+ "total_averages": total_averages,
214
+ "unique_tickers": unique_tickers,
215
+ }
216
+
217
+
218
+ # Create the tables when this module is first imported
219
+ create_tables()
services/sentiment_service.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ===========================================
2
+ # Alpha Sentiment Engine — Sentiment Service
3
+ # ===========================================
4
+ # This is the "recipe book" — it knows HOW to score a headline.
5
+ #
6
+ # It's the same AI logic from your prototype.py, but now
7
+ # it lives in its own file so the worker can use it
8
+ # without knowing anything about APIs or scraping.
9
+ #
10
+ # HOW IT WORKS (same 4 steps as before):
11
+ # 1. Tokenize: headline text → numbers
12
+ # 2. Run the model: numbers → raw scores
13
+ # 3. Get probabilities: [positive, negative, neutral]
14
+ # 4. Calculate: score = positive - negative (-1 to +1)
15
+ # ===========================================
16
+
17
+ from datetime import datetime, timezone
18
+ from transformers import AutoTokenizer, AutoModelForSequenceClassification # type: ignore
19
+ import torch
20
+
21
+ from domain.models import NewsItem, SentimentResult
22
+
23
+
24
+ class SentimentService:
25
+ """
26
+ Loads the FinBERT model and scores headlines.
27
+
28
+ Usage:
29
+ service = SentimentService() # loads model (slow, do once)
30
+ result = service.analyze(news_item) # scores headline (fast)
31
+ """
32
+
33
+ def __init__(self) -> None:
34
+ """Load the FinBERT model. This is slow (~5 sec) — only do it once."""
35
+
36
+ # YOUR custom-trained model (86.39% accuracy!)
37
+ # Trained on 12,228 AI-verified financial texts
38
+ model_name: str = "models/my_finbert"
39
+
40
+ # Load the tokenizer (text → numbers translator)
41
+ self.tokenizer = AutoTokenizer.from_pretrained(model_name)
42
+
43
+ # Load the AI model (the brain)
44
+ self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
45
+
46
+ # Tell PyTorch we're scoring, not training
47
+ self.model.eval()
48
+
49
+ def analyze(self, news_item: NewsItem) -> SentimentResult:
50
+ """
51
+ Score a single headline.
52
+
53
+ Args:
54
+ news_item: A NewsItem with ticker and headline.
55
+
56
+ Returns:
57
+ A SentimentResult with the sentiment score (-1 to +1).
58
+ """
59
+
60
+ # Step 1: Turn headline → numbers
61
+ inputs = self.tokenizer(
62
+ news_item.headline,
63
+ return_tensors="pt",
64
+ padding=True,
65
+ truncation=True,
66
+ max_length=512,
67
+ )
68
+
69
+ # Step 2: Run the AI (no_grad = save memory)
70
+ with torch.no_grad():
71
+ outputs = self.model(**inputs)
72
+
73
+ # Step 3: Raw scores → probabilities
74
+ probabilities = torch.softmax(outputs.logits, dim=1)
75
+ positive_prob: float = probabilities[0][0].item()
76
+ negative_prob: float = probabilities[0][1].item()
77
+
78
+ # Step 4: Single score from -1 to +1
79
+ sentiment_score: float = round(positive_prob - negative_prob, 4)
80
+
81
+ # Build and return the result
82
+ return SentimentResult(
83
+ ticker=news_item.ticker,
84
+ sentiment_score=sentiment_score,
85
+ headline=news_item.headline,
86
+ timestamp=datetime.now(timezone.utc).isoformat(),
87
+ )
start.sh ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # ==========================================
3
+ # Sentix Alpha — Unified Container Startup
4
+ # Launches the Web UI + Background Sync Engine
5
+ # ==========================================
6
+
7
+ echo "🚀 SENTIX ALPHA — INITIALIZING..."
8
+
9
+ # Ensure the database is ready or clean for fresh deployment
10
+ if [ -f "sentiment_data.db" ]; then
11
+ echo "💾 Seed database found."
12
+ fi
13
+
14
+ # Starting the unified engine
15
+ # The scraper now runs as a daemon thread inside app.py
16
+ echo "🌐 Launching Unified Intelligence Engine on port 7860..."
17
+ python app.py
static/app.js ADDED
@@ -0,0 +1,831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Alpha Sentiment Engine — Dashboard Logic v10
3
+ * Editorial Cream/Gold Theme — Connected to Live API
4
+ */
5
+
6
+ const REFRESH_MS = 30000;
7
+ const mono = "'DM Mono',monospace";
8
+ const gridColor = 'rgba(28,26,23,0.05)';
9
+ const tickColor = '#8A837A';
10
+ const fScore = (n) => { n = parseFloat(n); return (n > 0 ? '+' : '') + n.toFixed(4); };
11
+
12
+ // Chart instances
13
+ let trendChart = null;
14
+ let moodChart = null;
15
+ let volChart = null;
16
+ let modalTrendChart = null;
17
+
18
+ // Chart.js defaults
19
+ Chart.defaults.color = '#8A837A';
20
+ Chart.defaults.font.family = "'DM Sans', sans-serif";
21
+ Chart.defaults.borderColor = 'rgba(28,26,23,0.05)';
22
+
23
+ // ========================================
24
+ // Boot
25
+ // ========================================
26
+ document.addEventListener('DOMContentLoaded', () => {
27
+ startClock();
28
+ initGlobe();
29
+ initSpatialFX(); // Sentix 2.0 Spatial Layer
30
+ initConstellation(); // Data Background
31
+ updateDashboard();
32
+ setInterval(updateDashboard, REFRESH_MS);
33
+ initSearch();
34
+ initModal();
35
+ initNavPills();
36
+ });
37
+
38
+ // ========================================
39
+ // Nav Pill Switching
40
+ // ========================================
41
+ function initNavPills() {
42
+ const pills = document.querySelectorAll('.npill');
43
+ const sectionMap = {
44
+ 'Overview': null, // scroll to top
45
+ 'News Feed': '#news-feed',
46
+ 'Markets': '#bullish-list',
47
+ };
48
+
49
+ pills.forEach(pill => {
50
+ pill.addEventListener('click', () => {
51
+ // Update active state
52
+ pills.forEach(p => p.classList.remove('active'));
53
+ pill.classList.add('active');
54
+
55
+ // Scroll to section
56
+ const target = sectionMap[pill.textContent.trim()];
57
+ if (!target) {
58
+ window.scrollTo({ top: 0, behavior: 'smooth' });
59
+ } else {
60
+ const el = document.querySelector(target);
61
+ if (el) {
62
+ const navH = document.getElementById('main-nav').offsetHeight + 40;
63
+ const y = el.getBoundingClientRect().top + window.pageYOffset - navH;
64
+ window.scrollTo({ top: y, behavior: 'smooth' });
65
+ }
66
+ }
67
+ });
68
+ });
69
+ }
70
+
71
+ async function updateDashboard() {
72
+ try {
73
+ await Promise.all([fetchStats(), fetchOverview(), fetchHeadlines()]);
74
+ console.log('✦ Synced:', new Date().toLocaleTimeString());
75
+ } catch (err) {
76
+ console.error('Sync error:', err);
77
+ }
78
+ }
79
+
80
+ // ========================================
81
+ // Clock
82
+ // ========================================
83
+ function startClock() {
84
+ const el = document.getElementById('clock');
85
+ const tick = () => { el.textContent = new Date().toTimeString().slice(0, 8); };
86
+ setInterval(tick, 1000);
87
+ tick();
88
+ }
89
+
90
+ // ========================================
91
+ // 1. Stats (top KPIs + hero card)
92
+ // ========================================
93
+ async function fetchStats() {
94
+ const res = await fetch('/api/stats');
95
+ const d = await res.json();
96
+ // These go into the hero card subtitle
97
+ document.getElementById('hero-desc').textContent =
98
+ `Tracking ${d.stocks_scored.toLocaleString()} stocks across ${d.total_headlines.toLocaleString()} headlines. FinBERT model accuracy: ${d.ai_accuracy}.`;
99
+
100
+ // Hero Quick Metrics
101
+ const hmScanned = document.getElementById('hm-scanned');
102
+ if (hmScanned) hmScanned.textContent = d.stocks_scored.toLocaleString();
103
+
104
+ const hmNews = document.getElementById('hm-news');
105
+ if (hmNews) hmNews.textContent = d.total_headlines.toLocaleString();
106
+ }
107
+
108
+ // ========================================
109
+ // 2. Overview — Charts, Movers, Hero Score
110
+ // ========================================
111
+ async function fetchOverview() {
112
+ const res = await fetch('/api/overview');
113
+ const d = await res.json();
114
+
115
+ const bullish = d.bullish || [];
116
+ const bearish = d.bearish || [];
117
+
118
+ // Hero score
119
+ const total = bullish.length + bearish.length;
120
+ const optPct = total > 0 ? Math.round((bullish.length / total) * 100) : 50;
121
+ const pesPct = 100 - optPct;
122
+
123
+ const heroScore = document.getElementById('hero-score');
124
+ heroScore.innerHTML = optPct + '<sup>%</sup>';
125
+
126
+ // Quick metrics update
127
+ const hmBullish = document.getElementById('hm-bullish');
128
+ if (hmBullish) hmBullish.textContent = bullish.length.toLocaleString();
129
+
130
+ const hmBearish = document.getElementById('hm-bearish');
131
+ if (hmBearish) hmBearish.textContent = bearish.length.toLocaleString();
132
+
133
+ const verdict = document.getElementById('hero-verdict');
134
+ if (optPct > 55) {
135
+ verdict.textContent = '↑ Bullish Bias Detected';
136
+ verdict.className = 'sh-verdict bullish';
137
+ } else if (optPct < 45) {
138
+ verdict.textContent = '↓ Bearish Bias Detected';
139
+ verdict.className = 'sh-verdict bearish';
140
+ } else {
141
+ verdict.textContent = '→ Neutral Market';
142
+ verdict.className = 'sh-verdict neutral';
143
+ }
144
+
145
+ // Signal breakdown
146
+ renderSignals(bullish, bearish);
147
+
148
+ // Trend chart (top 15 bullish)
149
+ renderTrendChart(bullish);
150
+
151
+ // Movers (buy/sell lists)
152
+ renderMovers('bullish-list', bullish, true);
153
+ renderMovers('bearish-list', bearish, false);
154
+
155
+ // Watchlist (top 5 combined)
156
+ renderWatchlist(bullish, bearish);
157
+
158
+ // Volume chart
159
+ renderVolChart(bullish, bearish);
160
+
161
+ // Market mood doughnut
162
+ renderMood(bullish, bearish);
163
+
164
+ // Right panel stats
165
+ updateRightPanel(bullish, bearish, optPct, pesPct);
166
+
167
+ // Ticker tape
168
+ renderTicker(bullish, bearish);
169
+
170
+ // Badges
171
+ document.getElementById('badge-sources').textContent = total + ' scored';
172
+ document.getElementById('badge-movers').textContent = Math.min(bullish.length, 5) + ' tracked';
173
+ }
174
+
175
+ // ---- Signal Breakdown Bars ---- //
176
+ function renderSignals(bulls, bears) {
177
+ const total = bulls.length + bears.length || 1;
178
+ const bullPct = Math.round((bulls.length / total) * 100);
179
+ const bearPct = 100 - bullPct;
180
+
181
+ // Calculate avg scores
182
+ const avgBull = bulls.length > 0 ? bulls.reduce((s, b) => s + b.score, 0) / bulls.length : 0;
183
+ const avgBear = bears.length > 0 ? Math.abs(bears.reduce((s, b) => s + b.score, 0) / bears.length) : 0;
184
+
185
+ const directCount = [...bulls, ...bears].filter(i => i.score_type === 'DIRECT').length;
186
+ const hybridCount = [...bulls, ...bears].filter(i => i.score_type === 'HYBRID').length;
187
+ const sectorCount = [...bulls, ...bears].filter(i => i.score_type === 'SECTOR').length;
188
+ const directPct = total > 0 ? Math.round((directCount / total) * 100) : 0;
189
+ const hybridPct = total > 0 ? Math.round((hybridCount / total) * 100) : 0;
190
+
191
+ const sigs = [
192
+ { n: 'Bullish Ratio', v: bullPct, color: 'linear-gradient(90deg,#2D6A4F,#40916C)' },
193
+ { n: 'Bearish Ratio', v: bearPct, color: 'linear-gradient(90deg,#C0392B,#E07070)' },
194
+ { n: 'Direct Scores', v: directPct, color: 'linear-gradient(90deg,#B8924A,#D4A85A)' },
195
+ { n: 'Hybrid Scores', v: hybridPct, color: 'linear-gradient(90deg,#8A837A,#4A4540)' },
196
+ ];
197
+
198
+ document.getElementById('signals').innerHTML = sigs.map(s => `
199
+ <div class="sig">
200
+ <div class="sig-row"><span class="sig-name">${s.n}</span><span class="sig-num">${s.v}%</span></div>
201
+ <div class="sig-track"><div class="sig-fill" style="width:${s.v}%;background:${s.color};"></div></div>
202
+ </div>`).join('');
203
+ }
204
+
205
+ // ---- Sentiment Trend Line Chart ---- //
206
+ function renderTrendChart(bullish) {
207
+ const ctx = document.getElementById('trendChart');
208
+ const data = [...bullish.slice(0, 15)].sort((a, b) => b.score - a.score);
209
+ const labels = data.map(d => d.ticker);
210
+ const scores = data.map(d => d.score);
211
+
212
+ if (trendChart) {
213
+ trendChart.data.labels = labels;
214
+ trendChart.data.datasets[0].data = scores;
215
+ trendChart.update();
216
+ return;
217
+ }
218
+
219
+ trendChart = new Chart(ctx, {
220
+ type: 'line',
221
+ data: {
222
+ labels,
223
+ datasets: [{
224
+ data: scores,
225
+ borderColor: '#B8924A',
226
+ borderWidth: 2,
227
+ pointBackgroundColor: '#B8924A',
228
+ pointRadius: 3,
229
+ fill: true,
230
+ backgroundColor: (c) => {
231
+ const g = c.chart.ctx.createLinearGradient(0, 0, 0, 150);
232
+ g.addColorStop(0, 'rgba(184,146,74,0.18)');
233
+ g.addColorStop(1, 'rgba(184,146,74,0)');
234
+ return g;
235
+ },
236
+ tension: .45
237
+ }]
238
+ },
239
+ options: {
240
+ responsive: true, maintainAspectRatio: false,
241
+ plugins: {
242
+ legend: { display: false },
243
+ tooltip: {
244
+ backgroundColor: '#1C1A17', titleColor: '#F5F0E8', bodyColor: '#B8924A',
245
+ titleFont: { family: mono, size: 12 }, bodyFont: { family: mono, size: 12 },
246
+ padding: 10, cornerRadius: 6,
247
+ callbacks: { label: (c) => 'Score: ' + fScore(c.raw) }
248
+ }
249
+ },
250
+ scales: {
251
+ x: { grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 10 }, maxRotation: 45 } },
252
+ y: { grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 10 }, callback: v => v.toFixed(1) }, min: 0, max: 1 }
253
+ }
254
+ }
255
+ });
256
+ }
257
+
258
+ // ---- Buy / Sell Mover Lists ---- //
259
+ function renderMovers(elId, items, isBull) {
260
+ const el = document.getElementById(elId);
261
+ if (!items || items.length === 0) {
262
+ el.innerHTML = '<div class="loading-text">Awaiting data...</div>';
263
+ return;
264
+ }
265
+
266
+ el.innerHTML = items.slice(0, 5).map(item => {
267
+ const scoreClass = isBull ? 'up' : 'dn';
268
+ const sentClass = isBull ? 's-b' : 's-s';
269
+ const sentLabel = isBull ? 'BULL' : 'BEAR';
270
+ const priceHtml = item.price_change != null ?
271
+ `<div class="wr-chg ${item.price_change >= 0 ? 'u' : 'd'}">${item.price_change >= 0 ? '▲' : '▼'} ${Math.abs(item.price_change).toFixed(2)}%</div>` : '';
272
+
273
+ return `
274
+ <div class="wrow stagger-in" onclick="openCompanyModal('${item.ticker}')">
275
+ <div><div class="wr-sym">${item.ticker}</div><div class="wr-name">${item.name}</div></div>
276
+ <div><div class="wr-score ${scoreClass}">${fScore(item.score)}</div>${priceHtml}</div>
277
+ <div class="wr-sent ${sentClass}">${sentLabel}</div>
278
+ </div>`;
279
+ }).join('');
280
+
281
+ // Apply staggered delays
282
+ el.querySelectorAll('.stagger-in').forEach((node, i) => {
283
+ node.style.animationDelay = (i * 0.05) + 's';
284
+ });
285
+ }
286
+
287
+ // ---- Watchlist (top 5 combined) ---- //
288
+ function renderWatchlist(bulls, bears) {
289
+ const combined = [...bulls.slice(0, 3), ...bears.slice(0, 2)];
290
+ const el = document.getElementById('watchlist');
291
+
292
+ el.innerHTML = combined.map(item => {
293
+ const isBull = item.score > 0;
294
+ const sentClass = isBull ? 's-b' : (item.score < -0.1 ? 's-s' : 's-n');
295
+ const sentLabel = isBull ? 'BULL' : (item.score < -0.1 ? 'BEAR' : 'NEUT');
296
+
297
+ return `
298
+ <div class="wrow" onclick="openCompanyModal('${item.ticker}')">
299
+ <div><div class="wr-sym">${item.ticker}</div><div class="wr-name">${item.name}</div></div>
300
+ <div class="wr-sent ${sentClass}">${sentLabel}</div>
301
+ </div>`;
302
+ }).join('');
303
+ }
304
+
305
+ // ---- Volume Bar Chart ---- //
306
+ function renderVolChart(bulls, bears) {
307
+ const ctx = document.getElementById('volChart');
308
+
309
+ // Create pseudo-time distribution from score ranges
310
+ const ranges = ['0.0-0.2', '0.2-0.4', '0.4-0.6', '0.6-0.8', '0.8-1.0'];
311
+ const bullDist = ranges.map((_, i) => bulls.filter(b => b.score >= i * 0.2 && b.score < (i + 1) * 0.2).length);
312
+ const bearDist = ranges.map((_, i) => bears.filter(b => Math.abs(b.score) >= i * 0.2 && Math.abs(b.score) < (i + 1) * 0.2).length);
313
+
314
+ if (volChart) {
315
+ volChart.data.datasets[0].data = bullDist;
316
+ volChart.data.datasets[1].data = bearDist;
317
+ volChart.update();
318
+ return;
319
+ }
320
+
321
+ volChart = new Chart(ctx, {
322
+ type: 'bar',
323
+ data: {
324
+ labels: ranges,
325
+ datasets: [
326
+ { data: bullDist, backgroundColor: 'rgba(45,106,79,0.65)', borderRadius: 4, borderSkipped: false },
327
+ { data: bearDist, backgroundColor: 'rgba(192,57,43,0.55)', borderRadius: 4, borderSkipped: false },
328
+ ]
329
+ },
330
+ options: {
331
+ responsive: true, maintainAspectRatio: false,
332
+ plugins: { legend: { display: false } },
333
+ scales: {
334
+ x: { stacked: true, grid: { display: false }, ticks: { color: tickColor, font: { family: mono, size: 10 } } },
335
+ y: { stacked: true, grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 10 }, maxTicksLimit: 4 } }
336
+ }
337
+ }
338
+ });
339
+ }
340
+
341
+ // ---- Market Mood Doughnut ---- //
342
+ function renderMood(bulls, bears) {
343
+ const ctx = document.getElementById('moodChart');
344
+ const neutral = Math.max(5, 50 - bulls.length - bears.length);
345
+
346
+ if (moodChart) {
347
+ moodChart.data.datasets[0].data = [bulls.length, neutral, bears.length];
348
+ moodChart.update();
349
+ return;
350
+ }
351
+
352
+ moodChart = new Chart(ctx, {
353
+ type: 'doughnut',
354
+ data: {
355
+ labels: ['Bullish', 'Neutral', 'Bearish'],
356
+ datasets: [{
357
+ data: [bulls.length, neutral, bears.length],
358
+ backgroundColor: ['#2D6A4F', '#D8D1C4', '#C0392B'],
359
+ borderWidth: 4,
360
+ borderColor: '#F5F0E8',
361
+ hoverOffset: 4
362
+ }]
363
+ },
364
+ options: {
365
+ responsive: true, maintainAspectRatio: false,
366
+ cutout: '68%',
367
+ plugins: {
368
+ legend: { display: false },
369
+ tooltip: { backgroundColor: '#1C1A17', titleColor: '#F5F0E8', bodyColor: '#D4A85A', padding: 8, cornerRadius: 6 }
370
+ }
371
+ }
372
+ });
373
+ }
374
+
375
+ // ---- Right Panel Stats ---- //
376
+ function updateRightPanel(bulls, bears, optPct, pesPct) {
377
+ const total = bulls.length + bears.length;
378
+ document.getElementById('rp-total').textContent = total;
379
+ document.getElementById('rp-bulls').textContent = bulls.length;
380
+ document.getElementById('rp-bears').textContent = bears.length;
381
+ document.getElementById('rp-opt-pct').textContent = optPct + '%';
382
+ document.getElementById('rp-pes-pct').textContent = pesPct + '%';
383
+ document.getElementById('rp-opt-bar').style.width = optPct + '%';
384
+ document.getElementById('rp-pes-bar').style.width = pesPct + '%';
385
+ }
386
+
387
+ // ---- Ticker Tape ---- //
388
+ function renderTicker(bulls, bears) {
389
+ const el = document.getElementById('ticker-track');
390
+ const items = [...bulls.slice(0, 6), ...bears.slice(0, 4)];
391
+ const doubled = [...items, ...items]; // duplicate for seamless loop
392
+
393
+ el.innerHTML = doubled.map(t => {
394
+ const isUp = t.score > 0;
395
+ const cls = isUp ? 'ti-up' : 'ti-dn';
396
+ const sign = isUp ? '+' : '';
397
+ const priceStr = t.price_change != null ? `${t.price_change >= 0 ? '+' : ''}${t.price_change.toFixed(2)}%` : sign + t.score.toFixed(2);
398
+ return `<span class="ti"><span class="ti-sym">${t.ticker}</span>${t.name ? t.name.slice(0, 18) : ''}<span class="${cls}">${priceStr}</span></span>`;
399
+ }).join('');
400
+ }
401
+
402
+ // ========================================
403
+ // 3. Headlines (News Feed)
404
+ // ========================================
405
+ async function fetchHeadlines() {
406
+ const res = await fetch('/api/headlines');
407
+ const headlines = await res.json();
408
+ const el = document.getElementById('news-feed');
409
+
410
+ document.getElementById('badge-news').textContent = headlines.length + ' signals';
411
+
412
+ el.innerHTML = headlines.slice(0, 12).map(n => {
413
+ const isBull = n.score > 0.2;
414
+ const isBear = n.score < -0.2;
415
+ const sentClass = isBull ? 'bull' : (isBear ? 'bear' : '');
416
+ const sentLabel = isBull ? 'Bullish' : (isBear ? 'Bearish' : 'Neutral');
417
+ const source = (n.source || '').replace('📰', '').replace('����', '').trim().toUpperCase() || 'NEWS';
418
+
419
+ return `
420
+ <div class="ni stagger-in" onclick="openCompanyModal('${n.ticker}')">
421
+ <div class="ni-meta">
422
+ <span class="ni-src">${source}</span>
423
+ <span class="ni-time">${n.time_ago || ''}</span>
424
+ </div>
425
+ <div class="ni-title">${n.headline}</div>
426
+ <div class="ni-tags">
427
+ <span class="tag">${n.ticker}</span>
428
+ <span class="tag ${sentClass}">${sentLabel}</span>
429
+ </div>
430
+ </div>`;
431
+ }).join('');
432
+
433
+ // Apply staggered delays
434
+ el.querySelectorAll('.stagger-in').forEach((node, i) => {
435
+ node.style.animationDelay = (i * 0.04) + 's';
436
+ });
437
+ }
438
+
439
+ // ========================================
440
+ // 4. Search
441
+ // ========================================
442
+ function initSearch() {
443
+ const input = document.getElementById('company-search');
444
+ const results = document.getElementById('search-results');
445
+ let timeout;
446
+
447
+ input.addEventListener('input', (e) => {
448
+ clearTimeout(timeout);
449
+ const q = e.target.value.trim();
450
+ if (q.length < 2) { results.style.display = 'none'; return; }
451
+
452
+ timeout = setTimeout(async () => {
453
+ try {
454
+ const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
455
+ const data = await res.json();
456
+ results.innerHTML = '';
457
+
458
+ if (data.results && data.results.length > 0) {
459
+ data.results.forEach(item => {
460
+ const cls = item.score > 0.1 ? 'up' : (item.score < -0.1 ? 'dn' : '');
461
+ results.innerHTML += `
462
+ <div class="search-result-item" onclick="openCompanyModal('${item.ticker}')">
463
+ <div><div class="wr-sym">${item.ticker}</div><div class="wr-name">${item.name}</div></div>
464
+ <div class="wr-score ${cls}">${fScore(item.score)}</div>
465
+ </div>`;
466
+ });
467
+ } else {
468
+ results.innerHTML = '<div class="search-result-item"><span style="color:#8A837A">No results found</span></div>';
469
+ }
470
+
471
+ const rect = input.getBoundingClientRect();
472
+ results.style.top = (rect.bottom + 8) + 'px';
473
+ results.style.left = rect.left + 'px';
474
+ results.style.width = rect.width + 'px';
475
+ results.style.display = 'block';
476
+ } catch (err) { console.error('Search failed:', err); }
477
+ }, 400);
478
+ });
479
+
480
+ document.addEventListener('click', (e) => {
481
+ if (!input.contains(e.target) && !results.contains(e.target)) results.style.display = 'none';
482
+ });
483
+ }
484
+
485
+ // ========================================
486
+ // 5. Company Modal
487
+ // ========================================
488
+ function initModal() {
489
+ const modal = document.getElementById('company-modal');
490
+ const closeBtn = document.getElementById('modal-close');
491
+ closeBtn.addEventListener('click', () => modal.style.display = 'none');
492
+ modal.addEventListener('click', (e) => { if (e.target === modal) modal.style.display = 'none'; });
493
+ }
494
+
495
+ async function openCompanyModal(ticker) {
496
+ document.getElementById('search-results').style.display = 'none';
497
+
498
+ try {
499
+ const res = await fetch(`/api/company/${encodeURIComponent(ticker)}`);
500
+ const d = await res.json();
501
+
502
+ document.getElementById('modal-ticker').textContent = d.ticker;
503
+ document.getElementById('modal-name').textContent = d.name;
504
+ document.getElementById('modal-sector').textContent = d.sector;
505
+
506
+ const scoreEl = document.getElementById('modal-score');
507
+ scoreEl.textContent = fScore(d.current_score);
508
+ scoreEl.className = 'modal-score-val ' + (d.current_score > 0.1 ? 'up' : (d.current_score < -0.1 ? 'dn' : 'nt'));
509
+
510
+ const confEl = document.getElementById('modal-confidence');
511
+ confEl.textContent = d.confidence || 'LOW';
512
+ confEl.className = 'tag ' + (d.confidence === 'HIGH' ? 'bull' : (d.confidence === 'MEDIUM' ? 'unusual' : ''));
513
+
514
+ const priceEl = document.getElementById('modal-price');
515
+ if (d.price_change != null) {
516
+ const isUp = d.price_change >= 0;
517
+ priceEl.textContent = `${isUp ? '▲' : '▼'} ${Math.abs(d.price_change).toFixed(2)}%`;
518
+ priceEl.className = 'modal-price ' + (isUp ? 'price-up' : 'price-down');
519
+ priceEl.style.display = 'inline';
520
+ } else {
521
+ priceEl.style.display = 'none';
522
+ }
523
+
524
+ // Trend chart
525
+ renderModalChart(d.trend);
526
+
527
+ // News list
528
+ const newsList = document.getElementById('modal-news-list');
529
+ if (d.headlines && d.headlines.length > 0) {
530
+ newsList.innerHTML = d.headlines.map(n => {
531
+ const isBull = n.score > 0.2;
532
+ const isBear = n.score < -0.2;
533
+ const sentClass = isBull ? 'bull' : (isBear ? 'bear' : '');
534
+ const sentLabel = isBull ? 'Bullish' : (isBear ? 'Bearish' : 'Neutral');
535
+ const source = (n.source || '').replace('📰', '').replace('💬', '').trim().toUpperCase() || 'NEWS';
536
+
537
+ return `
538
+ <div class="ni">
539
+ <div class="ni-meta"><span class="ni-src">${source}</span><span class="ni-time">${n.time_ago || ''}</span></div>
540
+ <div class="ni-title">${n.headline}</div>
541
+ <div class="ni-tags"><span class="tag ${sentClass}">${sentLabel}</span></div>
542
+ </div>`;
543
+ }).join('');
544
+ } else {
545
+ newsList.innerHTML = '<div class="loading-text">No verified news found.</div>';
546
+ }
547
+
548
+ document.getElementById('company-modal').style.display = 'flex';
549
+ } catch (err) {
550
+ console.error('Modal load failed:', err);
551
+ }
552
+ }
553
+
554
+ function renderModalChart(trendData) {
555
+ const ctx = document.getElementById('modalTrendChart');
556
+ const labels = trendData.map(d => d.time_label);
557
+ const scores = trendData.map(d => d.score);
558
+ const isPos = scores.length > 0 && scores[scores.length - 1] >= 0;
559
+ const lineColor = isPos ? '#2D6A4F' : '#C0392B';
560
+ const bgColor = isPos ? 'rgba(45,106,79,0.15)' : 'rgba(192,57,43,0.12)';
561
+
562
+ if (modalTrendChart) {
563
+ modalTrendChart.data.labels = labels;
564
+ modalTrendChart.data.datasets[0].data = scores;
565
+ modalTrendChart.data.datasets[0].borderColor = lineColor;
566
+ modalTrendChart.data.datasets[0].backgroundColor = bgColor;
567
+ modalTrendChart.update();
568
+ return;
569
+ }
570
+
571
+ modalTrendChart = new Chart(ctx, {
572
+ type: 'line',
573
+ data: {
574
+ labels,
575
+ datasets: [{
576
+ data: scores,
577
+ borderColor: lineColor,
578
+ backgroundColor: bgColor,
579
+ borderWidth: 2,
580
+ fill: true,
581
+ tension: .35,
582
+ pointRadius: 3,
583
+ pointBackgroundColor: '#F5F0E8'
584
+ }]
585
+ },
586
+ options: {
587
+ responsive: true, maintainAspectRatio: false,
588
+ plugins: {
589
+ legend: { display: false },
590
+ tooltip: {
591
+ backgroundColor: '#1C1A17', titleColor: '#F5F0E8', bodyColor: '#D4A85A',
592
+ padding: 8, cornerRadius: 6,
593
+ callbacks: { label: (c) => 'Score: ' + fScore(c.raw) }
594
+ }
595
+ },
596
+ scales: {
597
+ y: { min: -1, max: 1, grid: { color: gridColor }, ticks: { color: tickColor, font: { family: mono, size: 9 } } },
598
+ x: { grid: { display: false }, ticks: { color: tickColor, font: { family: mono, size: 9 } } }
599
+ }
600
+ }
601
+ });
602
+ }
603
+
604
+ // ========================================
605
+ // 6. 3D Globe (Canvas Animation)
606
+ // ========================================
607
+ function initGlobe() {
608
+ const canvas = document.getElementById('globe-canvas');
609
+ if (!canvas) return;
610
+ const ctx = canvas.getContext('2d');
611
+ const W = 180, H = 180, R = 82, cx = 90, cy = 90;
612
+ let t = 0;
613
+
614
+ const dots = [];
615
+ for (let i = 0; i < 110; i++) {
616
+ dots.push({ lat: (Math.random() - .5) * Math.PI, lon: Math.random() * Math.PI * 2, size: Math.random() * 1.4 + 0.4 });
617
+ }
618
+ const arcs = [];
619
+ for (let i = 0; i < 10; i++) {
620
+ arcs.push({ lat: (Math.random() - .5) * Math.PI, lon: Math.random() * Math.PI * 2 });
621
+ }
622
+
623
+ function project(lat, lon, rot) {
624
+ const x = Math.cos(lat) * Math.sin(lon + rot);
625
+ const y = Math.sin(lat);
626
+ const z = Math.cos(lat) * Math.cos(lon + rot);
627
+ return { x: cx + x * R, y: cy - y * R, z, visible: z > -0.05 };
628
+ }
629
+
630
+ function draw() {
631
+ ctx.clearRect(0, 0, W, H);
632
+
633
+ // Globe fill
634
+ const grd = ctx.createRadialGradient(cx - 20, cy - 20, 0, cx, cy, R);
635
+ grd.addColorStop(0, 'rgba(226,219,208,0.55)');
636
+ grd.addColorStop(0.7, 'rgba(213,200,180,0.3)');
637
+ grd.addColorStop(1, 'rgba(245,240,232,0)');
638
+ ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
639
+ ctx.fillStyle = grd; ctx.fill();
640
+
641
+ // Latitude lines
642
+ for (let la = -60; la <= 60; la += 30) {
643
+ const latR = la * Math.PI / 180;
644
+ ctx.beginPath(); let first = true;
645
+ for (let lo = 0; lo <= 360; lo += 5) {
646
+ const p = project(latR, lo * Math.PI / 180, t);
647
+ if (p.visible) { if (first) { ctx.moveTo(p.x, p.y); first = false; } else ctx.lineTo(p.x, p.y); }
648
+ else first = true;
649
+ }
650
+ ctx.strokeStyle = `rgba(184,146,74,${0.08 + Math.max(0, Math.cos(latR)) * 0.08})`;
651
+ ctx.lineWidth = 0.6; ctx.stroke();
652
+ }
653
+
654
+ // Longitude lines
655
+ for (let lo = 0; lo < 360; lo += 30) {
656
+ const lonR = lo * Math.PI / 180;
657
+ ctx.beginPath(); let first = true;
658
+ for (let la = -90; la <= 90; la += 5) {
659
+ const p = project(la * Math.PI / 180, lonR, t);
660
+ if (p.visible) { if (first) { ctx.moveTo(p.x, p.y); first = false; } else ctx.lineTo(p.x, p.y); }
661
+ else first = true;
662
+ }
663
+ ctx.strokeStyle = 'rgba(184,146,74,0.06)';
664
+ ctx.lineWidth = 0.5; ctx.stroke();
665
+ }
666
+
667
+ // Data dots
668
+ dots.forEach(d => {
669
+ const p = project(d.lat, d.lon, t);
670
+ if (!p.visible) return;
671
+ const alpha = 0.25 + p.z * 0.55;
672
+ ctx.beginPath(); ctx.arc(p.x, p.y, d.size * Math.max(0.3, p.z), 0, Math.PI * 2);
673
+ ctx.fillStyle = `rgba(184,146,74,${alpha})`; ctx.fill();
674
+ });
675
+
676
+ // Connection arcs
677
+ for (let i = 0; i < arcs.length - 1; i += 2) {
678
+ const a = project(arcs[i].lat, arcs[i].lon, t);
679
+ const b = project(arcs[i + 1].lat, arcs[i + 1].lon, t);
680
+ if (a.visible && b.visible && a.z > 0.25 && b.z > 0.25) {
681
+ ctx.beginPath();
682
+ ctx.moveTo(a.x, a.y);
683
+ ctx.quadraticCurveTo((a.x + b.x) / 2, (a.y + b.y) / 2 - 18, b.x, b.y);
684
+ ctx.strokeStyle = 'rgba(45,106,79,0.45)'; ctx.lineWidth = 1; ctx.stroke();
685
+ ctx.beginPath(); ctx.arc(a.x, a.y, 2.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(64,145,108,0.8)'; ctx.fill();
686
+ ctx.beginPath(); ctx.arc(b.x, b.y, 2.5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(64,145,108,0.8)'; ctx.fill();
687
+ }
688
+ }
689
+
690
+ // Rim highlight
691
+ const rimGrd = ctx.createRadialGradient(cx - 28, cy - 28, R * 0.55, cx, cy, R);
692
+ rimGrd.addColorStop(0, 'rgba(245,240,232,0)');
693
+ rimGrd.addColorStop(0.82, 'rgba(245,240,232,0)');
694
+ rimGrd.addColorStop(1, 'rgba(245,240,232,0.35)');
695
+ ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2);
696
+ ctx.fillStyle = rimGrd; ctx.fill();
697
+
698
+ t += 0.004;
699
+ requestAnimationFrame(draw);
700
+ }
701
+ draw();
702
+ }
703
+
704
+ // ========================================
705
+ // Sentix 2.0 — Spatial UI (3D Tilt)
706
+ // ========================================
707
+ function initSpatialFX() {
708
+ const cards = document.querySelectorAll('.glass, .score-hero');
709
+
710
+ const handleMove = (e) => {
711
+ // Background Parallax (Subtle window effect)
712
+ const px = (e.clientX / window.innerWidth - 0.5) * -40;
713
+ const py = (e.clientY / window.innerHeight - 0.5) * -40;
714
+ const canvas = document.getElementById('particle-canvas');
715
+ if (canvas) canvas.style.transform = `translate(${px}px, ${py}px) scale(1.1)`;
716
+
717
+ const card = e.currentTarget;
718
+ const rect = card.getBoundingClientRect();
719
+ const x = e.clientX - rect.left;
720
+ const y = e.clientY - rect.top;
721
+
722
+ const centerX = rect.width / 2;
723
+ const centerY = rect.height / 2;
724
+
725
+ // Reduce tilt to a subtle 1.5 degrees for premium feel
726
+ const rotateX = ((y - centerY) / centerY) * -1.5;
727
+ const rotateY = ((x - centerX) / centerX) * 1.5;
728
+
729
+ card.style.transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) translateZ(8px)`;
730
+ card.classList.add('tilt-active');
731
+
732
+ // Dynamic Refractive Sheen vars
733
+ const mx = (x / rect.width) * 100;
734
+ const my = (y / rect.height) * 100;
735
+ card.style.setProperty('--mx', `${mx}%`);
736
+ card.style.setProperty('--my', `${my}%`);
737
+ };
738
+
739
+ const handleLeave = (e) => {
740
+ const card = e.currentTarget;
741
+ card.style.transform = 'perspective(1000px) rotateX(0) rotateY(0) translateZ(0)';
742
+ card.classList.remove('tilt-active');
743
+ };
744
+
745
+ cards.forEach(card => {
746
+ card.addEventListener('mousemove', handleMove);
747
+ card.addEventListener('mouseleave', handleLeave);
748
+ });
749
+ }
750
+
751
+ // ========================================
752
+ // Sentix 2.0 — Data Constellation Background
753
+ // ========================================
754
+ function initConstellation() {
755
+ const container = document.getElementById('particle-canvas');
756
+ if(!container) return;
757
+
758
+ const canvas = document.createElement('canvas');
759
+ container.appendChild(canvas);
760
+ const ctx = canvas.getContext('2d');
761
+
762
+ let w, h, particles = [];
763
+
764
+ const resize = () => {
765
+ w = canvas.width = window.innerWidth;
766
+ h = canvas.height = window.innerHeight;
767
+ };
768
+
769
+ window.addEventListener('resize', resize);
770
+ resize();
771
+
772
+ class Particle {
773
+ constructor() {
774
+ this.reset();
775
+ }
776
+ reset() {
777
+ this.x = Math.random() * w;
778
+ this.y = Math.random() * h;
779
+ this.z = Math.random() * 1.5;
780
+ this.vx = (Math.random() - 0.5) * 0.2;
781
+ this.vy = (Math.random() - 0.5) * 0.2;
782
+ this.size = Math.random() * 1.5 + 0.5;
783
+ this.alpha = 0;
784
+ this.targetAlpha = Math.random() * 0.3 + 0.1;
785
+ }
786
+ update() {
787
+ this.x += this.vx;
788
+ this.y += this.vy;
789
+ if(this.alpha < this.targetAlpha) this.alpha += 0.005;
790
+
791
+ if(this.x < 0 || this.x > w || this.y < 0 || this.y > h) this.reset();
792
+ }
793
+ draw() {
794
+ ctx.beginPath();
795
+ ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
796
+ ctx.fillStyle = `rgba(184, 146, 74, ${this.alpha * (1 - this.y/h)})`;
797
+ ctx.fill();
798
+ }
799
+ }
800
+
801
+ for(let i=0; i<80; i++) particles.push(new Particle());
802
+
803
+ function animate() {
804
+ ctx.clearRect(0,0,w,h);
805
+ particles.forEach(p => {
806
+ p.update();
807
+ p.draw();
808
+ });
809
+ // Draw subtle lines between near particles
810
+ ctx.lineWidth = 0.5;
811
+ for(let i=0; i<particles.length; i++) {
812
+ for(let j=i+1; j<particles.length; j++) {
813
+ const dx = particles[i].x - particles[j].x;
814
+ const dy = particles[i].y - particles[j].y;
815
+ const dist = Math.sqrt(dx*dx + dy*dy);
816
+ if(dist < 120) {
817
+ ctx.beginPath();
818
+ ctx.moveTo(particles[i].x, particles[i].y);
819
+ ctx.lineTo(particles[j].x, particles[j].y);
820
+ ctx.strokeStyle = `rgba(184, 146, 74, ${ (1 - dist/120) * 0.08 })`;
821
+ ctx.stroke();
822
+ }
823
+ }
824
+ }
825
+ requestAnimationFrame(animate);
826
+ }
827
+ animate();
828
+ }
829
+
830
+ // Make openCompanyModal globally accessible
831
+ window.openCompanyModal = openCompanyModal;
static/landing.css ADDED
@@ -0,0 +1,342 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --cream: #F5F0E8;
3
+ --cream2: #EDE8DD;
4
+ --cream3: #E2DBD0;
5
+ --ink: #1C1A17;
6
+ --gold: #A88B5A;
7
+ --gold-glow: rgba(184, 146, 74, 0.2);
8
+ --border: rgba(216, 209, 196, 0.4);
9
+ }
10
+
11
+ * { margin: 0; padding: 0; box-sizing: border-box; }
12
+
13
+ body {
14
+ background: var(--cream);
15
+ color: var(--ink);
16
+ font-family: 'DM Sans', sans-serif;
17
+ overflow-x: hidden;
18
+ min-height: 100vh;
19
+ }
20
+
21
+ .landing-noise {
22
+ position: fixed;
23
+ inset: 0;
24
+ background: url('https://grainy-gradients.vercel.app/noise.svg');
25
+ opacity: 0.15;
26
+ pointer-events: none;
27
+ z-index: 50;
28
+ }
29
+
30
+ /* ---- Splatter Background (The Theme) ---- */
31
+ .splatter-bg {
32
+ position: fixed;
33
+ inset: 0;
34
+ z-index: 0;
35
+ pointer-events: none;
36
+ overflow: hidden;
37
+ }
38
+
39
+ .splat {
40
+ position: absolute;
41
+ filter: blur(80px);
42
+ border-radius: 50%;
43
+ opacity: 0.25;
44
+ }
45
+
46
+ .splat-1 {
47
+ width: 800px; height: 800px;
48
+ background: radial-gradient(circle, var(--gold) 0%, transparent 70%);
49
+ top: -200px; right: -200px;
50
+ animation: float 25s infinite alternate;
51
+ }
52
+
53
+ .splat-2 {
54
+ width: 600px; height: 600px;
55
+ background: radial-gradient(circle, #D8D1C4 0%, transparent 60%);
56
+ bottom: -100px; left: -100px;
57
+ }
58
+
59
+ .splat-3 {
60
+ width: 400px; height: 400px;
61
+ background: var(--gold-glow);
62
+ top: 40%; left: 10%;
63
+ filter: blur(120px);
64
+ }
65
+
66
+ @keyframes float {
67
+ 0% { transform: translate(0, 0) scale(1); }
68
+ 100% { transform: translate(100px, 150px) scale(1.1); }
69
+ }
70
+
71
+ /* ---- Navigation ---- */
72
+ .glass-nav {
73
+ position: fixed;
74
+ top: 0; left: 0; width: 100%;
75
+ padding: 30px 40px;
76
+ z-index: 100;
77
+ backdrop-filter: blur(10px);
78
+ -webkit-backdrop-filter: blur(10px);
79
+ border-bottom: 1px solid var(--border);
80
+ }
81
+
82
+ .nav-content {
83
+ display: flex;
84
+ justify-content: space-between;
85
+ align-items: center;
86
+ max-width: 1400px;
87
+ margin: 0 auto;
88
+ }
89
+
90
+ .logo {
91
+ font-family: 'Playfair Display', serif;
92
+ font-size: 24px;
93
+ font-weight: 700;
94
+ letter-spacing: -1px;
95
+ }
96
+
97
+ .logo span {
98
+ font-weight: 400; color: var(--gold);
99
+ margin-left: 4px;
100
+ }
101
+
102
+ .nav-links {
103
+ display: flex; gap: 30px; align-items: center;
104
+ }
105
+
106
+ .nav-links a {
107
+ text-decoration: none; color: var(--ink);
108
+ font-size: 14px; text-transform: uppercase;
109
+ letter-spacing: 2px; transition: color 0.3s;
110
+ }
111
+
112
+ .cta-mini {
113
+ background: var(--ink); color: var(--cream) !important;
114
+ padding: 10px 24px; border-radius: 40px;
115
+ font-weight: 700; letter-spacing: 1px;
116
+ }
117
+
118
+ /* ---- Hero Section (Couture Impact) ---- */
119
+ .hero {
120
+ min-height: 100vh;
121
+ display: flex;
122
+ align-items: center;
123
+ padding: 120px 80px 40px;
124
+ max-width: 1400px;
125
+ margin: 0 auto;
126
+ position: relative;
127
+ z-index: 10;
128
+ }
129
+
130
+ .hero-content { flex: 1; position: relative; }
131
+
132
+ .badge-reveal {
133
+ font-family: 'DM Mono', monospace;
134
+ font-size: 11px;
135
+ text-transform: uppercase;
136
+ letter-spacing: 4px;
137
+ color: var(--gold);
138
+ margin-bottom: 24px;
139
+ overflow: hidden;
140
+ }
141
+
142
+ .badge-reveal span {
143
+ display: block;
144
+ animation: reveal-up 1s cubic-bezier(0.2, 0.8, 0.2, 1);
145
+ }
146
+
147
+ h1 {
148
+ font-family: 'Playfair Display', serif;
149
+ font-size: 15vw;
150
+ line-height: 0.8;
151
+ margin-left: -0.5vw;
152
+ letter-spacing: -4px;
153
+ font-weight: 900;
154
+ color: var(--ink);
155
+ text-shadow: 0 10px 30px rgba(28,26,23,0.05);
156
+ animation: reveal-up 1.2s cubic-bezier(0.2, 0.8, 0.2, 1);
157
+ }
158
+
159
+ .hero-tagline {
160
+ font-family: 'DM Sans', sans-serif;
161
+ font-size: 42px;
162
+ font-weight: 300;
163
+ margin: 30px 0;
164
+ color: #4A4540;
165
+ }
166
+
167
+ .accent { font-family: 'Playfair Display', serif; font-style: italic; color: var(--gold); }
168
+
169
+ .hero-desc {
170
+ font-size: 18px; line-height: 1.6;
171
+ max-width: 500px; color: #8A837A;
172
+ margin-bottom: 40px;
173
+ }
174
+
175
+ .hero-actions { display: flex; gap: 20px; }
176
+
177
+ .btn-primary {
178
+ background: var(--ink); color: var(--cream);
179
+ padding: 18px 40px; border-radius: 50px;
180
+ text-decoration: none; font-weight: 700;
181
+ box-shadow: 0 10px 30px rgba(28,26,23,0.15);
182
+ transition: transform 0.3s;
183
+ }
184
+
185
+ .btn-secondary {
186
+ border: 1px solid var(--border);
187
+ padding: 18px 40px; border-radius: 50px;
188
+ text-decoration: none; font-weight: 700; color: var(--ink);
189
+ transition: background 0.3s;
190
+ }
191
+
192
+ .btn-primary:hover, .btn-secondary:hover { transform: translateY(-5px); }
193
+
194
+ /* ---- Features Grid (Bento) ---- */
195
+ .features { padding: 120px 80px; max-width: 1400px; margin: 0 auto; position: relative; }
196
+
197
+ .section-header { margin-bottom: 60px; }
198
+ .section-header h3 { font-family: 'DM Mono', monospace; font-size: 13px; letter-spacing: 6px; color: var(--gold); margin-bottom: 10px; }
199
+ .section-header p { font-size: 32px; font-weight: 300; }
200
+
201
+ .feature-grid { display: grid; grid-template-columns: 2fr 1fr; gap: 30px; margin-bottom: 30px;}
202
+
203
+ .f-card {
204
+ padding: 50px 40px;
205
+ border-radius: 32px;
206
+ background: rgba(255,255,255,0.4);
207
+ border: 1px solid var(--border);
208
+ transition: transform 0.4s, border-color 0.4s;
209
+ position: relative;
210
+ overflow: hidden;
211
+ }
212
+
213
+ .f-card:hover { transform: translateY(-10px); background: #FFF; border-color: var(--gold); }
214
+
215
+ .f-icon { font-size: 32px; margin-bottom: 30px; color: var(--gold); }
216
+ .f-card h4 { font-size: 22px; margin-bottom: 15px; }
217
+ .f-card p { color: #8A837A; line-height: 1.5; }
218
+
219
+ /* ---- Code Glass (Engine Detail) ---- */
220
+ .code-glass {
221
+ background: #1C1A17;
222
+ color: #EDE8DD;
223
+ padding: 40px;
224
+ border-radius: 24px;
225
+ font-family: 'DM Mono', monospace;
226
+ font-size: 13px;
227
+ line-height: 1.6;
228
+ border: 1px solid rgba(184, 146, 74, 0.3);
229
+ position: relative;
230
+ box-shadow: 0 20px 50px rgba(0,0,0,0.2);
231
+ }
232
+
233
+ .code-header {
234
+ display: flex; gap: 8px; margin-bottom: 20px;
235
+ border-bottom: 1px solid rgba(255,255,255,0.1);
236
+ padding-bottom: 15px;
237
+ }
238
+
239
+ .dot { width: 10px; height: 10px; border-radius: 50%; background: #4A4540; }
240
+ .dot.gold { background: var(--gold); }
241
+
242
+ .code-glass pre { color: #8A837A; }
243
+ .code-glass .gold { color: var(--gold); }
244
+
245
+ /* ---- Intelligence Layer ---- */
246
+ .intelligence {
247
+ background: #FFF;
248
+ padding: 140px 80px;
249
+ position: relative;
250
+ overflow: hidden;
251
+ }
252
+
253
+ .intel-grid {
254
+ display: grid;
255
+ grid-template-columns: 1fr 1.5fr;
256
+ gap: 100px;
257
+ max-width: 1400px;
258
+ margin: 0 auto;
259
+ align-items: center;
260
+ }
261
+
262
+ .intel-content h2 {
263
+ font-family: 'Playfair Display', serif;
264
+ font-size: 56px;
265
+ margin-bottom: 30px;
266
+ line-height: 1;
267
+ }
268
+
269
+ .intel-content p {
270
+ font-size: 18px;
271
+ color: var(--ink2);
272
+ line-height: 1.7;
273
+ margin-bottom: 40px;
274
+ }
275
+
276
+ .signal-flow {
277
+ display: grid;
278
+ grid-template-columns: repeat(2, 1fr);
279
+ gap: 20px;
280
+ }
281
+
282
+ .flow-step {
283
+ padding: 30px;
284
+ border-bottom: 1px solid var(--border);
285
+ }
286
+
287
+ .flow-num { font-family: 'DM Mono', monospace; color: var(--gold); margin-bottom: 10px; font-size: 11px; }
288
+ .flow-step h5 { font-size: 18px; margin-bottom: 10px; }
289
+ .flow-step p { font-size: 14px; color: var(--ink3); }
290
+
291
+ /* ---- Simulator Prototype ---- */
292
+ .sim-box {
293
+ background: var(--cream);
294
+ padding: 40px;
295
+ border-radius: 20px;
296
+ border: 1px solid var(--border);
297
+ }
298
+
299
+ .sim-input {
300
+ width: 100%;
301
+ background: transparent;
302
+ border: none;
303
+ border-bottom: 2px solid var(--ink);
304
+ padding: 15px 0;
305
+ font-family: 'Playfair Display', serif;
306
+ font-size: 24px;
307
+ margin-bottom: 30px;
308
+ outline: none;
309
+ }
310
+
311
+ .sim-result {
312
+ min-height: 60px;
313
+ display: flex;
314
+ align-items: center;
315
+ gap: 20px;
316
+ }
317
+
318
+ .sim-indicator {
319
+ width: 40px; height: 40px;
320
+ border-radius: 50%;
321
+ background: var(--border);
322
+ transition: all 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
323
+ }
324
+
325
+ .sim-indicator.active-bullish { background: #2D6A4F; box-shadow: 0 0 20px rgba(45,106,79,0.3); }
326
+ .sim-indicator.active-bearish { background: #C0392B; box-shadow: 0 0 20px rgba(192,57,43,0.3); }
327
+
328
+ .sim-msg { font-family: 'DM Mono', monospace; font-size: 13px; color: var(--gold); }
329
+
330
+ /* ---- Animations ---- */
331
+ @keyframes reveal-up {
332
+ from { transform: translateY(100%); opacity: 0; }
333
+ to { transform: translateY(0); opacity: 1; }
334
+ }
335
+
336
+ @media (max-width: 1024px) {
337
+ h1 { font-size: 20vw; }
338
+ .feature-grid { grid-template-columns: 1fr; }
339
+ .hero { flex-direction: column; padding: 120px 40px; text-align: center; }
340
+ .hero-desc { margin: 0 auto 40px; }
341
+ .hero-actions { justify-content: center; }
342
+ }
static/landing.js ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Sentix Alpha — Landing Page Interaction v1
3
+ * Spectral Orb + Scroll Reveal
4
+ */
5
+
6
+ document.addEventListener('DOMContentLoaded', () => {
7
+ initSpectralOrb();
8
+ initScrollReveal();
9
+ initNavScroll();
10
+ initSentimentSimulator();
11
+ });
12
+
13
+ // ========================================
14
+ // 1. Spectral Alpha Orb (Hero Visual)
15
+ // ========================================
16
+ function initSpectralOrb() {
17
+ const container = document.getElementById('hero-orb');
18
+ if (!container) return;
19
+
20
+ const canvas = document.createElement('canvas');
21
+ container.appendChild(canvas);
22
+ const ctx = canvas.getContext('2d');
23
+
24
+ let w, h, particles = [];
25
+
26
+ const resize = () => {
27
+ w = canvas.width = container.offsetWidth;
28
+ h = canvas.height = container.offsetHeight;
29
+ };
30
+
31
+ window.addEventListener('resize', resize);
32
+ resize();
33
+
34
+ class Particle {
35
+ constructor() {
36
+ this.reset();
37
+ }
38
+ reset() {
39
+ this.phi = Math.random() * Math.PI * 2;
40
+ this.theta = Math.random() * Math.PI;
41
+ this.radius = 120 + Math.random() * 40;
42
+ this.speed = 0.002 + Math.random() * 0.005;
43
+ this.size = 1 + Math.random() * 2;
44
+ this.color = Math.random() > 0.5 ? '#B8924A' : '#1C1A17';
45
+ this.alpha = 0.1 + Math.random() * 0.4;
46
+ }
47
+ update() {
48
+ this.phi += this.speed;
49
+ }
50
+ draw() {
51
+ // Simple 3D projection
52
+ const x = this.radius * Math.sin(this.theta) * Math.cos(this.phi);
53
+ const y = this.radius * Math.sin(this.theta) * Math.sin(this.phi);
54
+ const z = this.radius * Math.cos(this.theta);
55
+
56
+ // Project to 2D
57
+ const scale = 400 / (400 - z);
58
+ const px = w/2 + x * scale;
59
+ const py = h/2 + y * scale;
60
+
61
+ if (z > -50) { // Only draw visible side for clarity
62
+ ctx.beginPath();
63
+ ctx.arc(px, py, this.size * scale, 0, Math.PI * 2);
64
+ ctx.fillStyle = this.color;
65
+ ctx.globalAlpha = this.alpha * scale;
66
+ ctx.fill();
67
+ }
68
+ }
69
+ }
70
+
71
+ for (let i = 0; i < 200; i++) particles.push(new Particle());
72
+
73
+ function animate() {
74
+ ctx.clearRect(0, 0, w, h);
75
+
76
+ // Draw a soft core glow
77
+ const gradient = ctx.createRadialGradient(w/2, h/2, 0, w/2, h/2, 180);
78
+ gradient.addColorStop(0, 'rgba(184, 146, 74, 0.08)');
79
+ gradient.addColorStop(1, 'rgba(245, 240, 232, 0)');
80
+ ctx.fillStyle = gradient;
81
+ ctx.fillRect(0,0,w,h);
82
+
83
+ particles.forEach(p => {
84
+ p.update();
85
+ p.draw();
86
+ });
87
+ requestAnimationFrame(animate);
88
+ }
89
+ animate();
90
+ }
91
+
92
+ // ========================================
93
+ // 2. Scroll Reveal
94
+ // ========================================
95
+ function initScrollReveal() {
96
+ const observer = new IntersectionObserver((entries) => {
97
+ entries.forEach(entry => {
98
+ if (entry.isIntersecting) {
99
+ entry.target.classList.add('revealed');
100
+ }
101
+ });
102
+ }, { threshold: 0.1 });
103
+
104
+ document.querySelectorAll('.f-card, .section-header').forEach(el => {
105
+ el.style.opacity = '0';
106
+ el.style.transform = 'translateY(30px)';
107
+ el.style.transition = 'all 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)';
108
+ observer.observe(el);
109
+ });
110
+
111
+ // Handle the custom CSS class for revealed state
112
+ const style = document.createElement('style');
113
+ style.innerHTML = `
114
+ .revealed {
115
+ opacity: 1 !important;
116
+ transform: translateY(0) !important;
117
+ }
118
+ `;
119
+ document.head.appendChild(style);
120
+ }
121
+
122
+ // ========================================
123
+ // 3. Smooth Nav Interaction
124
+ // ========================================
125
+ function initNavScroll() {
126
+ const nav = document.querySelector('.glass-nav');
127
+ window.addEventListener('scroll', () => {
128
+ if (window.scrollY > 50) {
129
+ nav.style.padding = '15px 40px';
130
+ nav.style.background = 'rgba(245, 240, 232, 0.8)';
131
+ } else {
132
+ nav.style.padding = '30px 40px';
133
+ nav.style.background = 'transparent';
134
+ }
135
+ });
136
+ }
137
+
138
+ // ========================================
139
+ // 4. Sentiment Simulator (Intelligence)
140
+ // ========================================
141
+ function initSentimentSimulator() {
142
+ const input = document.getElementById('sim-input');
143
+ const indicator = document.getElementById('sim-indicator');
144
+ const msg = document.getElementById('sim-msg');
145
+
146
+ if (!input || !indicator || !msg) return;
147
+
148
+ const keywords = {
149
+ bullish: ['growth', 'profit', 'up', 'surge', 'buy', 'positive', 'gain', 'expansion'],
150
+ bearish: ['drop', 'loss', 'down', 'crash', 'sell', 'negative', 'decline', 'risk']
151
+ };
152
+
153
+ input.addEventListener('input', (e) => {
154
+ const val = e.target.value.toLowerCase();
155
+
156
+ if (val.length === 0) {
157
+ indicator.className = 'sim-indicator';
158
+ msg.textContent = 'Awaiting signal inputs...';
159
+ return;
160
+ }
161
+
162
+ let score = 0;
163
+ keywords.bullish.forEach(word => { if (val.includes(word)) score += 0.2; });
164
+ keywords.bearish.forEach(word => { if (val.includes(word)) score -= 0.2; });
165
+
166
+ indicator.classList.remove('active-bullish', 'active-bearish');
167
+
168
+ if (score > 0) {
169
+ indicator.classList.add('active-bullish');
170
+ msg.textContent = `SENTIMENT: BULLISH (+${Math.abs(score).toFixed(2)})`;
171
+ } else if (score < 0) {
172
+ indicator.classList.add('active-bearish');
173
+ msg.textContent = `SENTIMENT: BEARISH (-${Math.abs(score).toFixed(2)})`;
174
+ } else {
175
+ msg.textContent = 'ANALYZING NEUTRAL VECTOR...';
176
+ }
177
+ });
178
+ }
static/style.css ADDED
@@ -0,0 +1,1169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Alpha Sentiment Engine — Editorial Cream/Gold Theme v10
3
+ ============================================================ */
4
+ @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600&family=DM+Mono:wght@300;400&family=DM+Sans:wght@300;400;500&display=swap');
5
+
6
+ /* ---- Reset & Root ---- */
7
+ * {
8
+ margin: 0;
9
+ padding: 0;
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ body {
14
+ background: #F5F0E8;
15
+ color: #1C1A17;
16
+ font-family: 'DM Sans', sans-serif;
17
+ min-height: 100vh;
18
+ }
19
+
20
+ :root {
21
+ --cream: #F5F0E8;
22
+ --cream2: #EDE8DD;
23
+ --cream3: #E2DBD0;
24
+ --ink: #1C1A17;
25
+ --ink2: #4A4540;
26
+ --ink3: #8A837A;
27
+ --gold: #B8924A;
28
+ --gold2: #D4A85A;
29
+ --border: #D8D1C4;
30
+ --red: #C0392B;
31
+ --green: #2D6A4F;
32
+ --green2: #40916C;
33
+ }
34
+
35
+ /* ---- Ambient Background ---- */
36
+ .bg {
37
+ position: fixed;
38
+ top: 0;
39
+ left: 0;
40
+ width: 100%;
41
+ height: 100%;
42
+ z-index: 0;
43
+ overflow: hidden;
44
+ pointer-events: none;
45
+ }
46
+
47
+ .constellation {
48
+ position: absolute;
49
+ inset: 0;
50
+ z-index: 1;
51
+ }
52
+
53
+ .constellation canvas {
54
+ width: 100%;
55
+ height: 100%;
56
+ }
57
+
58
+ .blob {
59
+ position: absolute;
60
+ border-radius: 50%;
61
+ filter: blur(90px);
62
+ }
63
+
64
+ .b1 {
65
+ width: 500px;
66
+ height: 500px;
67
+ background: radial-gradient(circle, rgba(184, 146, 74, 0.18) 0%, transparent 70%);
68
+ top: -120px;
69
+ left: -80px;
70
+ }
71
+
72
+ .b2 {
73
+ width: 380px;
74
+ height: 380px;
75
+ background: radial-gradient(circle, rgba(45, 106, 79, 0.12) 0%, transparent 70%);
76
+ bottom: 0;
77
+ right: -60px;
78
+ }
79
+
80
+ .b3 {
81
+ width: 280px;
82
+ height: 280px;
83
+ background: radial-gradient(circle, rgba(212, 168, 90, 0.1) 0%, transparent 70%);
84
+ top: 40%;
85
+ left: 30%;
86
+ }
87
+
88
+ .noise {
89
+ position: fixed;
90
+ top: 0;
91
+ left: 0;
92
+ width: 100%;
93
+ height: 100%;
94
+ background-image: url("data:image/svg+xml,%3Csvg width='40' height='40' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M40 0v40M0 40h40' stroke='rgba(28,26,23,0.04)' stroke-width='0.5'/%3E%3C/svg%3E");
95
+ z-index: 0;
96
+ pointer-events: none;
97
+ }
98
+
99
+ .root {
100
+ position: relative;
101
+ z-index: 1;
102
+ min-height: 100vh;
103
+ display: flex;
104
+ flex-direction: column;
105
+ perspective: 1200px; /* 3D depth anchor */
106
+ }
107
+
108
+ /* ---- Navigation ---- */
109
+ .nav {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: space-between;
113
+ padding: 16px 28px;
114
+ border-bottom: 1px solid var(--border);
115
+ background: rgba(245, 240, 232, 0.7);
116
+ backdrop-filter: blur(16px);
117
+ -webkit-backdrop-filter: blur(16px);
118
+ position: sticky;
119
+ top: 0;
120
+ z-index: 100;
121
+ }
122
+
123
+ .logo {
124
+ font-family: 'Playfair Display', serif;
125
+ font-size: 24px;
126
+ font-weight: 600;
127
+ color: var(--ink);
128
+ }
129
+
130
+ .logo span {
131
+ color: var(--gold);
132
+ }
133
+
134
+ .nav-pills {
135
+ display: flex;
136
+ gap: 2px;
137
+ }
138
+
139
+ .npill {
140
+ font-size: 13px;
141
+ font-weight: 500;
142
+ padding: 7px 16px;
143
+ border-radius: 20px;
144
+ color: var(--ink3);
145
+ cursor: pointer;
146
+ letter-spacing: .3px;
147
+ transition: all .2s;
148
+ border: 1px solid transparent;
149
+ }
150
+
151
+ .npill:hover {
152
+ color: var(--ink);
153
+ background: var(--cream2);
154
+ }
155
+
156
+ .npill.active {
157
+ color: var(--ink);
158
+ background: var(--cream3);
159
+ border: 1px solid var(--border);
160
+ }
161
+
162
+ .nav-right {
163
+ display: flex;
164
+ align-items: center;
165
+ gap: 12px;
166
+ }
167
+
168
+ .live-badge {
169
+ display: flex;
170
+ align-items: center;
171
+ gap: 6px;
172
+ font-size: 11px;
173
+ font-weight: 600;
174
+ letter-spacing: 1.5px;
175
+ color: var(--green);
176
+ border: 1px solid rgba(45, 106, 79, 0.3);
177
+ padding: 5px 12px;
178
+ border-radius: 20px;
179
+ }
180
+
181
+ .dot-pulse {
182
+ width: 6px;
183
+ height: 6px;
184
+ border-radius: 50%;
185
+ background: var(--green2);
186
+ animation: pulse 1.5s infinite;
187
+ }
188
+
189
+ @keyframes pulse {
190
+
191
+ 0%,
192
+ 100% {
193
+ opacity: 1;
194
+ transform: scale(1);
195
+ }
196
+
197
+ 50% {
198
+ opacity: .4;
199
+ transform: scale(.8);
200
+ }
201
+ }
202
+
203
+ .nav-time {
204
+ font-family: 'DM Mono', monospace;
205
+ font-size: 12px;
206
+ color: var(--ink3);
207
+ }
208
+
209
+ /* ---- Ticker Tape ---- */
210
+ .ticker {
211
+ background: var(--ink);
212
+ padding: 8px 0;
213
+ overflow: hidden;
214
+ white-space: nowrap;
215
+ }
216
+
217
+ .ticker-track {
218
+ display: inline-flex;
219
+ animation: tickerMove 30s linear infinite;
220
+ }
221
+
222
+ @keyframes tickerMove {
223
+ from {
224
+ transform: translateX(0)
225
+ }
226
+
227
+ to {
228
+ transform: translateX(-50%)
229
+ }
230
+ }
231
+
232
+ .ti {
233
+ display: inline-flex;
234
+ align-items: center;
235
+ gap: 8px;
236
+ padding: 0 24px;
237
+ border-right: 1px solid rgba(255, 255, 255, 0.08);
238
+ font-family: 'DM Mono', monospace;
239
+ font-size: 12px;
240
+ color: rgba(255, 255, 255, 0.4);
241
+ }
242
+
243
+ .ti-sym {
244
+ color: #fff;
245
+ font-size: 13px;
246
+ font-weight: 500;
247
+ font-family: 'DM Sans', sans-serif;
248
+ }
249
+
250
+ .ti-up {
251
+ color: #5DBA8A;
252
+ }
253
+
254
+ .ti-dn {
255
+ color: #E07070;
256
+ }
257
+
258
+ /* ---- Main Grid ---- */
259
+ .main {
260
+ display: grid;
261
+ grid-template-columns: 1fr 340px;
262
+ gap: 18px;
263
+ padding: 22px 28px;
264
+ flex: 1;
265
+ align-items: start;
266
+ }
267
+
268
+ /* ---- Spatial Glass Cards (Sentix 2.0) ---- */
269
+ .glass {
270
+ background: rgba(245, 240, 232, 0.45);
271
+ border: 1px solid rgba(216, 209, 196, 0.5);
272
+ border-radius: 24px;
273
+ backdrop-filter: blur(24px) saturate(120%);
274
+ -webkit-backdrop-filter: blur(24px) saturate(120%);
275
+ overflow: hidden;
276
+ box-shadow:
277
+ 0 4px 30px rgba(184, 146, 74, 0.05),
278
+ 0 1px 2px rgba(28, 26, 23, 0.02);
279
+ transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.3s ease;
280
+ transform-style: preserve-3d;
281
+ position: relative;
282
+ }
283
+
284
+ /* Light-Refraction Sheen Overlay (Premium Dynamic Spot) */
285
+ .glass::after {
286
+ content: '';
287
+ position: absolute;
288
+ inset: 0;
289
+ background: radial-gradient(
290
+ circle at var(--mx, 50%) var(--my, 50%),
291
+ rgba(255, 255, 255, 0.18) 0%,
292
+ transparent 60%
293
+ );
294
+ opacity: 0;
295
+ transition: opacity 0.4s ease;
296
+ pointer-events: none;
297
+ z-index: 10;
298
+ }
299
+
300
+ .glass.tilt-active::after {
301
+ opacity: 1;
302
+ }
303
+
304
+ /* Sentiment Pulse Glows */
305
+ .glass.glow-bullish { border-color: rgba(45, 106, 79, 0.3); animation: pulse-green 4s infinite; }
306
+ .glass.glow-bearish { border-color: rgba(192, 57, 43, 0.25); animation: pulse-red 4s infinite; }
307
+ .glass.glow-gold { border-color: rgba(184, 146, 74, 0.4); animation: pulse-gold 6s infinite; }
308
+
309
+ @keyframes pulse-green { 0%, 100% { box-shadow: 0 0 15px rgba(45,106,79,0.05); } 50% { box-shadow: 0 0 35px rgba(45,106,79,0.15); } }
310
+ @keyframes pulse-red { 0%, 100% { box-shadow: 0 0 15px rgba(192,57,43,0.04); } 50% { box-shadow: 0 0 30px rgba(192,57,43,0.12); } }
311
+ @keyframes pulse-gold { 0%, 100% { box-shadow: 0 0 20px rgba(184,146,74,0.06); } 50% { box-shadow: 0 0 45px rgba(184,146,74,0.18); } }
312
+
313
+ .glass:hover {
314
+ box-shadow:
315
+ 0 15px 45px rgba(184, 146, 74, 0.12),
316
+ 0 5px 15px rgba(28, 26, 23, 0.05);
317
+ }
318
+
319
+ .glass-header {
320
+ padding: 14px 20px;
321
+ border-bottom: 1px solid var(--border);
322
+ display: flex;
323
+ justify-content: space-between;
324
+ align-items: center;
325
+ }
326
+
327
+ .gh-title {
328
+ font-size: 12px;
329
+ font-weight: 500;
330
+ letter-spacing: 1.5px;
331
+ text-transform: uppercase;
332
+ color: var(--ink3);
333
+ }
334
+
335
+ .gh-badge {
336
+ font-size: 11px;
337
+ letter-spacing: .5px;
338
+ padding: 4px 10px;
339
+ border-radius: 8px;
340
+ background: rgba(184, 146, 74, 0.12);
341
+ color: var(--gold);
342
+ border: 1px solid rgba(184, 146, 74, 0.25);
343
+ }
344
+
345
+ .badge-bull {
346
+ background: rgba(45, 106, 79, 0.1);
347
+ color: var(--green);
348
+ border-color: rgba(45, 106, 79, 0.25);
349
+ }
350
+
351
+ .badge-bear {
352
+ background: rgba(192, 57, 43, 0.08);
353
+ color: var(--red);
354
+ border-color: rgba(192, 57, 43, 0.2);
355
+ }
356
+
357
+ /* ---- Layout Helpers ---- */
358
+ .left-col {
359
+ display: flex;
360
+ flex-direction: column;
361
+ gap: 16px;
362
+ }
363
+
364
+ .right-col {
365
+ display: flex;
366
+ flex-direction: column;
367
+ gap: 16px;
368
+ }
369
+
370
+ .two-col-grid {
371
+ display: grid;
372
+ grid-template-columns: 1fr 1fr;
373
+ gap: 16px;
374
+ }
375
+
376
+ /* ---- Score Hero ---- */
377
+ .score-hero {
378
+ background: linear-gradient(135deg, #Fdfbf9 0%, #F5f0e8 50%, #EDE8DD 100%);
379
+ border: 1px solid rgba(184, 146, 74, 0.2);
380
+ border-radius: 32px;
381
+ padding: 40px 36px;
382
+ display: flex;
383
+ flex-direction: column;
384
+ gap: 36px;
385
+ box-shadow:
386
+ 0 10px 40px rgba(184, 146, 74, 0.08),
387
+ 0 1px 10px rgba(28, 26, 23, 0.03);
388
+ position: relative;
389
+ overflow: hidden;
390
+ transform-style: preserve-3d;
391
+ transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
392
+ }
393
+
394
+ .score-hero::after {
395
+ content: '';
396
+ position: absolute;
397
+ top: 0; left: 0; right: 0; bottom: 0;
398
+ background: radial-gradient(circle at var(--sheen-x, 50%) var(--sheen-y, 50%), rgba(255,255,255,0.15) 0%, transparent 60%);
399
+ opacity: 0;
400
+ transition: opacity 0.3s;
401
+ pointer-events: none;
402
+ }
403
+
404
+ .score-hero.tilt-active::after { opacity: 1; }
405
+ .sh-main{
406
+ display:flex;justify-content:space-between;align-items:center;
407
+ }
408
+ /* Quick Metrics inside Hero */
409
+ .hero-metrics{
410
+ display:flex;align-items:center;justify-content:space-around;
411
+ padding-top:24px;border-top:1px solid rgba(184, 146, 74, 0.15);
412
+ margin-top:-8px;
413
+ }
414
+ .hm-item{display:flex;flex-direction:column;gap:6px;align-items:center;}
415
+ .hm-val{font-family:'DM Mono',monospace;font-size:24px;color:var(--ink);font-weight:400;}
416
+ .hm-lbl{font-size:11px;text-transform:uppercase;color:var(--ink3);letter-spacing:1.5px;font-weight:600;}
417
+ .hm-divider { width: 1px; height: 36px; background: rgba(184, 146, 74, 0.15); }
418
+
419
+ /* ---- Staggered Entrance (Sentix 2.0) ---- */
420
+ @keyframes stagger-in {
421
+ from { opacity: 0; transform: translateY(15px) translateZ(0); }
422
+ to { opacity: 1; transform: translateY(0) translateZ(0); }
423
+ }
424
+
425
+ .stagger-in {
426
+ opacity: 0;
427
+ animation: stagger-in 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
428
+ }
429
+
430
+ .score-hero::before {
431
+ content: '';
432
+ position: absolute;
433
+ top: -80px;
434
+ right: 80px;
435
+ width: 220px;
436
+ height: 220px;
437
+ background: radial-gradient(circle, rgba(184, 146, 74, 0.12) 0%, transparent 70%);
438
+ border-radius: 50%;
439
+ pointer-events: none;
440
+ }
441
+
442
+ .sh-label {
443
+ font-size: 12px;
444
+ letter-spacing: 2px;
445
+ color: var(--ink3);
446
+ font-weight: 500;
447
+ margin-bottom: 10px;
448
+ text-transform: uppercase;
449
+ }
450
+
451
+ .sh-score {
452
+ font-family: 'Playfair Display', serif;
453
+ font-size: 96px;
454
+ font-weight: 600;
455
+ line-height: 1;
456
+ color: var(--ink);
457
+ letter-spacing: -3px;
458
+ }
459
+
460
+ .sh-score sup {
461
+ font-size: 36px;
462
+ letter-spacing: 0;
463
+ font-family: 'DM Sans', sans-serif;
464
+ font-weight: 300;
465
+ color: var(--ink3);
466
+ }
467
+
468
+ .sh-verdict {
469
+ display: inline-flex;
470
+ align-items: center;
471
+ gap: 6px;
472
+ margin-top: 12px;
473
+ padding: 6px 14px;
474
+ border-radius: 10px;
475
+ font-size: 13px;
476
+ font-weight: 600;
477
+ letter-spacing: .5px;
478
+ }
479
+
480
+ .sh-verdict.bullish {
481
+ background: rgba(45, 106, 79, 0.1);
482
+ border: 1px solid rgba(45, 106, 79, 0.25);
483
+ color: var(--green);
484
+ }
485
+
486
+ .sh-verdict.bearish {
487
+ background: rgba(192, 57, 43, 0.08);
488
+ border: 1px solid rgba(192, 57, 43, 0.2);
489
+ color: var(--red);
490
+ }
491
+
492
+ .sh-verdict.neutral {
493
+ background: rgba(138, 131, 122, 0.1);
494
+ border: 1px solid rgba(138, 131, 122, 0.25);
495
+ color: var(--ink3);
496
+ }
497
+
498
+ .sh-desc {
499
+ margin-top: 12px;
500
+ font-size: 14px;
501
+ color: var(--ink3);
502
+ line-height: 1.65;
503
+ max-width: 380px;
504
+ font-weight: 300;
505
+ }
506
+
507
+ /* Globe */
508
+ .globe-wrap {
509
+ width: 180px;
510
+ height: 180px;
511
+ border-radius: 50%;
512
+ overflow: hidden;
513
+ position: relative;
514
+ flex-shrink: 0;
515
+ }
516
+
517
+ .globe-wrap::after {
518
+ content: '';
519
+ position: absolute;
520
+ inset: 0;
521
+ border-radius: 50%;
522
+ box-shadow: inset -16px -16px 36px rgba(245, 240, 232, 0.4), 0 0 32px rgba(184, 146, 74, 0.2);
523
+ pointer-events: none;
524
+ }
525
+
526
+ /* ---- Signal Breakdown ---- */
527
+ .signals-body {
528
+ padding: 18px 20px;
529
+ }
530
+
531
+ .sig {
532
+ margin-bottom: 14px;
533
+ }
534
+
535
+ .sig:last-child {
536
+ margin-bottom: 0;
537
+ }
538
+
539
+ .sig-row {
540
+ display: flex;
541
+ justify-content: space-between;
542
+ margin-bottom: 6px;
543
+ }
544
+
545
+ .sig-name {
546
+ font-size: 13px;
547
+ color: var(--ink2);
548
+ }
549
+
550
+ .sig-num {
551
+ font-family: 'DM Mono', monospace;
552
+ font-size: 13px;
553
+ color: var(--ink);
554
+ }
555
+
556
+ .sig-track {
557
+ height: 4px;
558
+ background: var(--cream3);
559
+ border-radius: 4px;
560
+ overflow: hidden;
561
+ }
562
+
563
+ .sig-fill {
564
+ height: 100%;
565
+ border-radius: 4px;
566
+ transition: width .6s ease;
567
+ }
568
+
569
+ /* ---- Chart Wrappers ---- */
570
+ .chart-wrap {
571
+ padding: 16px 18px;
572
+ }
573
+
574
+ .chart-inner {
575
+ position: relative;
576
+ height: 148px;
577
+ }
578
+
579
+ .chart-inner.chart-sm {
580
+ height: 130px;
581
+ }
582
+
583
+ /* ---- News Items ---- */
584
+ .ni {
585
+ padding: 16px 22px;
586
+ border-bottom: 1px solid var(--border);
587
+ cursor: pointer;
588
+ transition: background .12s;
589
+ }
590
+
591
+ .ni:last-child {
592
+ border-bottom: none;
593
+ }
594
+
595
+ .ni:hover {
596
+ background: rgba(226, 219, 208, 0.5);
597
+ }
598
+
599
+ .ni-meta {
600
+ display: flex;
601
+ justify-content: space-between;
602
+ margin-bottom: 5px;
603
+ }
604
+
605
+ .ni-src {
606
+ font-size: 11px;
607
+ letter-spacing: 1.5px;
608
+ color: var(--ink3);
609
+ font-weight: 600;
610
+ text-transform: uppercase;
611
+ }
612
+
613
+ .ni-time {
614
+ font-family: 'DM Mono', monospace;
615
+ font-size: 11px;
616
+ color: var(--ink3);
617
+ }
618
+
619
+ .ni-title {
620
+ font-size: 14px;
621
+ color: var(--ink);
622
+ line-height: 1.55;
623
+ font-weight: 400;
624
+ }
625
+
626
+ .ni-tags {
627
+ display: flex;
628
+ gap: 5px;
629
+ margin-top: 7px;
630
+ flex-wrap: wrap;
631
+ }
632
+
633
+ .tag {
634
+ font-size: 11px;
635
+ padding: 3px 9px;
636
+ border-radius: 6px;
637
+ letter-spacing: .3px;
638
+ border: 1px solid var(--border);
639
+ color: var(--ink3);
640
+ }
641
+
642
+ .tag.bull {
643
+ border-color: rgba(45, 106, 79, 0.35);
644
+ color: var(--green);
645
+ background: rgba(45, 106, 79, 0.07);
646
+ }
647
+
648
+ .tag.bear {
649
+ border-color: rgba(192, 57, 43, 0.3);
650
+ color: var(--red);
651
+ background: rgba(192, 57, 43, 0.07);
652
+ }
653
+
654
+ .tag.unusual {
655
+ border-color: rgba(184, 146, 74, 0.4);
656
+ color: var(--gold);
657
+ background: rgba(184, 146, 74, 0.08);
658
+ }
659
+
660
+ /* ---- Watchlist / Mover Rows ---- */
661
+ .wrow {
662
+ display: flex;
663
+ align-items: center;
664
+ justify-content: space-between;
665
+ padding: 14px 22px;
666
+ border-bottom: 1px solid var(--border);
667
+ cursor: pointer;
668
+ transition: background .12s;
669
+ }
670
+
671
+ .wrow:last-child {
672
+ border-bottom: none;
673
+ }
674
+
675
+ .wrow:hover {
676
+ background: rgba(226, 219, 208, 0.5);
677
+ }
678
+
679
+ .wr-sym {
680
+ font-size: 15px;
681
+ font-weight: 500;
682
+ color: var(--ink);
683
+ letter-spacing: .3px;
684
+ }
685
+
686
+ .wr-name {
687
+ font-size: 12px;
688
+ color: var(--ink3);
689
+ margin-top: 2px;
690
+ }
691
+
692
+ .wr-score {
693
+ font-family: 'DM Mono', monospace;
694
+ font-size: 14px;
695
+ text-align: right;
696
+ }
697
+
698
+ .wr-score.up {
699
+ color: var(--green);
700
+ }
701
+
702
+ .wr-score.dn {
703
+ color: var(--red);
704
+ }
705
+
706
+ .wr-chg {
707
+ font-family: 'DM Mono', monospace;
708
+ font-size: 12px;
709
+ margin-top: 2px;
710
+ text-align: right;
711
+ }
712
+
713
+ .wr-chg.u {
714
+ color: var(--green);
715
+ }
716
+
717
+ .wr-chg.d {
718
+ color: var(--red);
719
+ }
720
+
721
+ .wr-sent {
722
+ font-size: 10px;
723
+ letter-spacing: .8px;
724
+ padding: 4px 9px;
725
+ border-radius: 6px;
726
+ font-weight: 600;
727
+ }
728
+
729
+ .s-b {
730
+ background: rgba(45, 106, 79, 0.1);
731
+ color: var(--green);
732
+ border: 1px solid rgba(45, 106, 79, 0.2);
733
+ }
734
+
735
+ .s-n {
736
+ background: var(--cream2);
737
+ color: var(--ink3);
738
+ border: 1px solid var(--border);
739
+ }
740
+
741
+ .s-s {
742
+ background: rgba(192, 57, 43, 0.08);
743
+ color: var(--red);
744
+ border: 1px solid rgba(192, 57, 43, 0.2);
745
+ }
746
+
747
+ /* ---- Volume Legend ---- */
748
+ .vol-legend {
749
+ display: flex;
750
+ gap: 14px;
751
+ margin-top: 10px;
752
+ font-size: 11px;
753
+ color: var(--ink3);
754
+ }
755
+
756
+ .vol-legend span {
757
+ display: flex;
758
+ align-items: center;
759
+ gap: 4px;
760
+ }
761
+
762
+ .sq {
763
+ width: 8px;
764
+ height: 8px;
765
+ border-radius: 2px;
766
+ display: inline-block;
767
+ }
768
+
769
+ .sq-green {
770
+ background: #2D6A4F;
771
+ }
772
+
773
+ .sq-red {
774
+ background: #C0392B;
775
+ }
776
+
777
+ .sq-muted {
778
+ background: #D8D1C4;
779
+ }
780
+
781
+ /* ---- Mood Chart ---- */
782
+ .mood-wrap {
783
+ padding: 16px 20px;
784
+ display: flex;
785
+ justify-content: center;
786
+ }
787
+
788
+ .mood-wrap canvas {
789
+ max-height: 140px;
790
+ }
791
+
792
+ .mood-legend {
793
+ display: flex;
794
+ justify-content: center;
795
+ gap: 16px;
796
+ padding: 0 20px 14px;
797
+ font-size: 12px;
798
+ color: var(--ink3);
799
+ }
800
+
801
+ .legend-item {
802
+ display: flex;
803
+ align-items: center;
804
+ gap: 4px;
805
+ }
806
+
807
+ .dot {
808
+ width: 7px;
809
+ height: 7px;
810
+ border-radius: 50%;
811
+ display: inline-block;
812
+ }
813
+
814
+ .dot-green {
815
+ background: var(--green);
816
+ }
817
+
818
+ .dot-red {
819
+ background: var(--red);
820
+ }
821
+
822
+ .dot-muted {
823
+ background: var(--border);
824
+ }
825
+
826
+ /* ---- Stats / Progress ---- */
827
+ .divider-line {
828
+ height: 1px;
829
+ background: var(--border);
830
+ margin: 0 20px;
831
+ }
832
+
833
+ .stats-list {
834
+ padding: 14px 20px;
835
+ }
836
+
837
+ .stat-row {
838
+ display: flex;
839
+ justify-content: space-between;
840
+ align-items: center;
841
+ padding: 6px 0;
842
+ font-size: 13px;
843
+ color: var(--ink2);
844
+ }
845
+
846
+ .stat-row strong {
847
+ font-family: 'DM Mono', monospace;
848
+ font-size: 14px;
849
+ color: var(--ink);
850
+ }
851
+
852
+ .clr-green {
853
+ color: var(--green) !important;
854
+ }
855
+
856
+ .clr-red {
857
+ color: var(--red) !important;
858
+ }
859
+
860
+ .progress-stats {
861
+ padding: 14px 20px;
862
+ }
863
+
864
+ .prog-item {
865
+ margin-bottom: 10px;
866
+ }
867
+
868
+ .prog-item:last-child {
869
+ margin-bottom: 0;
870
+ }
871
+
872
+ .prog-header {
873
+ display: flex;
874
+ justify-content: space-between;
875
+ font-size: 13px;
876
+ color: var(--ink2);
877
+ margin-bottom: 5px;
878
+ }
879
+
880
+ .prog-header strong {
881
+ font-family: 'DM Mono', monospace;
882
+ font-size: 13px;
883
+ color: var(--ink);
884
+ }
885
+
886
+ .prog-bar {
887
+ height: 4px;
888
+ background: var(--cream3);
889
+ border-radius: 4px;
890
+ overflow: hidden;
891
+ }
892
+
893
+ .fill {
894
+ height: 100%;
895
+ border-radius: 4px;
896
+ transition: width .6s ease;
897
+ }
898
+
899
+ .fill-gold {
900
+ background: linear-gradient(90deg, #B8924A, #D4A85A);
901
+ }
902
+
903
+ .fill-red {
904
+ background: linear-gradient(90deg, #C0392B, #E07070);
905
+ }
906
+
907
+ /* ---- Command Palette / Search ---- */
908
+ .cmd {
909
+ margin: 0;
910
+ display: flex;
911
+ align-items: center;
912
+ gap: 10px;
913
+ background: rgba(245, 240, 232, 0.75);
914
+ border-bottom: 1px solid var(--border);
915
+ padding: 10px 28px;
916
+ backdrop-filter: blur(12px);
917
+ -webkit-backdrop-filter: blur(12px);
918
+ transition: border-color .2s, box-shadow .2s;
919
+ position: sticky;
920
+ top: 53px;
921
+ z-index: 99;
922
+ }
923
+
924
+ .cmd:focus-within {
925
+ border-bottom-color: var(--gold);
926
+ box-shadow: 0 2px 12px rgba(184, 146, 74, 0.08);
927
+ }
928
+
929
+ .cmd-icon {
930
+ font-size: 15px;
931
+ color: var(--gold);
932
+ }
933
+
934
+ .cmd-input {
935
+ background: transparent;
936
+ border: none;
937
+ outline: none;
938
+ font-family: 'DM Sans', sans-serif;
939
+ font-size: 14px;
940
+ color: var(--ink);
941
+ flex: 1;
942
+ }
943
+
944
+ .cmd-input::placeholder {
945
+ color: var(--ink3);
946
+ }
947
+
948
+ .cmd-hint {
949
+ font-family: 'DM Mono', monospace;
950
+ font-size: 11px;
951
+ color: var(--ink3);
952
+ }
953
+
954
+ /* ---- Search Results Dropdown ---- */
955
+ .search-results {
956
+ position: fixed;
957
+ z-index: 200;
958
+ display: none;
959
+ background: rgba(245, 240, 232, 0.95);
960
+ border: 1px solid var(--border);
961
+ border-radius: 14px;
962
+ backdrop-filter: blur(20px);
963
+ -webkit-backdrop-filter: blur(20px);
964
+ box-shadow: 0 8px 32px rgba(28, 26, 23, 0.1);
965
+ max-height: 280px;
966
+ overflow-y: auto;
967
+ }
968
+
969
+ .search-result-item {
970
+ display: flex;
971
+ align-items: center;
972
+ justify-content: space-between;
973
+ padding: 14px 18px;
974
+ border-bottom: 1px solid var(--border);
975
+ font-size: 13px;
976
+ cursor: pointer;
977
+ transition: background .12s;
978
+ }
979
+
980
+ .search-result-item:last-child {
981
+ border-bottom: none;
982
+ }
983
+
984
+ .search-result-item:hover {
985
+ background: var(--cream2);
986
+ }
987
+
988
+ /* ---- Modal ---- */
989
+ .modal-overlay {
990
+ position: fixed;
991
+ inset: 0;
992
+ z-index: 300;
993
+ display: none;
994
+ align-items: center;
995
+ justify-content: center;
996
+ background: rgba(28, 26, 23, 0.4);
997
+ backdrop-filter: blur(8px);
998
+ -webkit-backdrop-filter: blur(8px);
999
+ }
1000
+
1001
+ .modal-card {
1002
+ width: 90%;
1003
+ max-width: 720px;
1004
+ max-height: 85vh;
1005
+ overflow-y: auto;
1006
+ border-radius: 20px;
1007
+ position: relative;
1008
+ }
1009
+
1010
+ .modal-close {
1011
+ position: absolute;
1012
+ top: 16px;
1013
+ right: 16px;
1014
+ background: var(--cream2);
1015
+ border: 1px solid var(--border);
1016
+ border-radius: 8px;
1017
+ width: 32px;
1018
+ height: 32px;
1019
+ display: flex;
1020
+ align-items: center;
1021
+ justify-content: center;
1022
+ cursor: pointer;
1023
+ font-size: 14px;
1024
+ color: var(--ink3);
1025
+ transition: all .2s;
1026
+ }
1027
+
1028
+ .modal-close:hover {
1029
+ background: var(--cream3);
1030
+ color: var(--ink);
1031
+ }
1032
+
1033
+ .modal-header {
1034
+ padding: 24px 24px 16px;
1035
+ display: flex;
1036
+ justify-content: space-between;
1037
+ align-items: flex-start;
1038
+ border-bottom: 1px solid var(--border);
1039
+ }
1040
+
1041
+ .modal-title h2 {
1042
+ font-family: 'Playfair Display', serif;
1043
+ font-size: 32px;
1044
+ font-weight: 600;
1045
+ color: var(--ink);
1046
+ margin-bottom: 2px;
1047
+ }
1048
+
1049
+ .modal-title p {
1050
+ font-size: 14px;
1051
+ color: var(--ink3);
1052
+ margin-bottom: 8px;
1053
+ }
1054
+
1055
+ .modal-score-box {
1056
+ text-align: right;
1057
+ }
1058
+
1059
+ .modal-score-val {
1060
+ font-family: 'Playfair Display', serif;
1061
+ font-size: 42px;
1062
+ font-weight: 600;
1063
+ letter-spacing: -1px;
1064
+ }
1065
+
1066
+ .modal-score-val.up {
1067
+ color: var(--green);
1068
+ }
1069
+
1070
+ .modal-score-val.dn {
1071
+ color: var(--red);
1072
+ }
1073
+
1074
+ .modal-score-val.nt {
1075
+ color: var(--ink3);
1076
+ }
1077
+
1078
+ .modal-price {
1079
+ font-family: 'DM Mono', monospace;
1080
+ font-size: 12px;
1081
+ margin-left: 6px;
1082
+ }
1083
+
1084
+ .modal-price.price-up {
1085
+ color: var(--green);
1086
+ }
1087
+
1088
+ .modal-price.price-down {
1089
+ color: var(--red);
1090
+ }
1091
+
1092
+ .modal-body {
1093
+ padding: 20px 24px;
1094
+ }
1095
+
1096
+ .modal-body h3 {
1097
+ font-size: 12px;
1098
+ letter-spacing: 1.5px;
1099
+ text-transform: uppercase;
1100
+ color: var(--ink3);
1101
+ font-weight: 500;
1102
+ margin-bottom: 12px;
1103
+ }
1104
+
1105
+ .modal-chart-section {
1106
+ margin-bottom: 20px;
1107
+ }
1108
+
1109
+ .modal-chart-wrap {
1110
+ height: 160px;
1111
+ position: relative;
1112
+ }
1113
+
1114
+ .modal-news-section .ni {
1115
+ padding: 12px 0;
1116
+ }
1117
+
1118
+ /* ---- Loading State ---- */
1119
+ .loading-text {
1120
+ padding: 20px;
1121
+ text-align: center;
1122
+ font-size: 13px;
1123
+ color: var(--ink3);
1124
+ font-style: italic;
1125
+ }
1126
+
1127
+ /* ---- Scrollbar ---- */
1128
+ ::-webkit-scrollbar {
1129
+ width: 4px;
1130
+ }
1131
+
1132
+ ::-webkit-scrollbar-track {
1133
+ background: transparent;
1134
+ }
1135
+
1136
+ ::-webkit-scrollbar-thumb {
1137
+ background: var(--border);
1138
+ border-radius: 4px;
1139
+ }
1140
+
1141
+ ::-webkit-scrollbar-thumb:hover {
1142
+ background: var(--ink3);
1143
+ }
1144
+
1145
+ /* ---- Responsive ---- */
1146
+ @media(max-width:900px) {
1147
+ .main {
1148
+ grid-template-columns: 1fr;
1149
+ }
1150
+
1151
+ ;
1152
+
1153
+ .two-col-grid {
1154
+ grid-template-columns: 1fr;
1155
+ }
1156
+
1157
+ .score-hero {
1158
+ grid-template-columns: 1fr;
1159
+ text-align: center;
1160
+ }
1161
+
1162
+ .globe-wrap {
1163
+ margin: 0 auto;
1164
+ }
1165
+
1166
+ .sh-desc {
1167
+ max-width: 100%;
1168
+ }
1169
+ }
templates/index.html ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Alpha | Live Sentiment Engine</title>
8
+ <meta name="description"
9
+ content="AI-powered real-time stock market sentiment analysis for 560+ Indian stocks using custom fine-tuned FinBERT.">
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link
13
+ href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600&family=DM+Mono:wght@300;400&family=DM+Sans:wght@300;400;500&display=swap"
14
+ rel="stylesheet">
15
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
16
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}?v=14">
17
+ </head>
18
+
19
+ <body>
20
+ <div class="bg">
21
+ <div class="blob b1"></div>
22
+ <div class="blob b2"></div>
23
+ <div class="blob b3"></div>
24
+ <!-- 3D Data Constellation -->
25
+ <div class="constellation" id="particle-canvas"></div>
26
+ </div>
27
+ <div class="noise"></div>
28
+
29
+ <div class="root">
30
+
31
+ <!-- Sticky Navigation -->
32
+ <nav class="nav" id="main-nav">
33
+ <div class="logo">Alpha<span>.</span></div>
34
+ <div class="nav-pills">
35
+ <div class="npill active">Overview</div>
36
+ <div class="npill">News Feed</div>
37
+ <div class="npill">Markets</div>
38
+ </div>
39
+ <div class="nav-right">
40
+ <div class="live-badge"><span class="dot-pulse"></span>LIVE</div>
41
+ <div class="nav-time" id="clock">--:--:--</div>
42
+ </div>
43
+ </nav>
44
+
45
+ <!-- Command Palette (Search) -->
46
+ <div class="cmd">
47
+ <span class="cmd-icon">✦</span>
48
+ <input class="cmd-input" id="company-search" placeholder="Search any stock — type ticker or company name..."
49
+ autocomplete="off" />
50
+ <span class="cmd-hint">↵ Search</span>
51
+ </div>
52
+
53
+ <!-- Scrolling Ticker -->
54
+ <div class="ticker">
55
+ <div class="ticker-track" id="ticker-track"></div>
56
+ </div>
57
+
58
+ <!-- Main Grid -->
59
+ <div class="main">
60
+ <!-- LEFT COLUMN -->
61
+ <div class="left-col">
62
+
63
+ <!-- Hero Score Card -->
64
+ <div class="score-hero">
65
+ <div class="sh-main">
66
+ <div>
67
+ <div class="sh-label">Aggregate Sentiment</div>
68
+ <div class="sh-score" id="hero-score">--<sup>%</sup></div>
69
+ <div class="sh-verdict" id="hero-verdict">↑ Analyzing...</div>
70
+ <div class="sh-desc" id="hero-desc">Loading market data from AI engine...</div>
71
+ </div>
72
+ <div class="globe-wrap">
73
+ <canvas id="globe-canvas" width="180" height="180"></canvas>
74
+ </div>
75
+ </div>
76
+ <div class="hero-metrics">
77
+ <div class="hm-item">
78
+ <span class="hm-val" id="hm-scanned">--</span>
79
+ <span class="hm-lbl">Equities Scanned</span>
80
+ </div>
81
+ <div class="hm-divider"></div>
82
+ <div class="hm-item">
83
+ <span class="hm-val clr-green" id="hm-bullish">--</span>
84
+ <span class="hm-lbl">Bullish Trends</span>
85
+ </div>
86
+ <div class="hm-divider"></div>
87
+ <div class="hm-item">
88
+ <span class="hm-val clr-red" id="hm-bearish">--</span>
89
+ <span class="hm-lbl">Bearish Trends</span>
90
+ </div>
91
+ <div class="hm-divider"></div>
92
+ <div class="hm-item">
93
+ <span class="hm-val" id="hm-news">--</span>
94
+ <span class="hm-lbl">Live Headlines</span>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ <!-- News Stream -->
100
+ <div class="glass" id="news-feed-container">
101
+ <div class="glass-header">
102
+ <span class="gh-title">News Stream</span>
103
+ <span class="gh-badge" id="badge-news">loading</span>
104
+ </div>
105
+ <div id="news-feed"></div>
106
+ </div>
107
+
108
+ <!-- Signal Breakdown + Trend Chart -->
109
+ <div class="two-col-grid">
110
+ <div class="glass">
111
+ <div class="glass-header">
112
+ <span class="gh-title">Signal Breakdown</span>
113
+ <span class="gh-badge" id="badge-sources">loading</span>
114
+ </div>
115
+ <div class="signals-body" id="signals"></div>
116
+ </div>
117
+ <div class="glass">
118
+ <div class="glass-header">
119
+ <span class="gh-title">Sentiment Trend</span>
120
+ <span class="gh-badge">Top 15</span>
121
+ </div>
122
+ <div class="chart-wrap">
123
+ <div class="chart-inner"><canvas id="trendChart"></canvas></div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+
129
+ <!-- Buy / Sell Signals -->
130
+ <div class="two-col-grid">
131
+ <div class="glass">
132
+ <div class="glass-header">
133
+ <span class="gh-title">Strong Buy Signals</span>
134
+ <span class="gh-badge badge-bull">bullish</span>
135
+ </div>
136
+ <div id="bullish-list"></div>
137
+ </div>
138
+ <div class="glass">
139
+ <div class="glass-header">
140
+ <span class="gh-title">Strong Sell Signals</span>
141
+ <span class="gh-badge badge-bear">bearish</span>
142
+ </div>
143
+ <div id="bearish-list"></div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- RIGHT COLUMN -->
149
+ <div class="right-col">
150
+ <!-- Market Mood -->
151
+ <div class="glass">
152
+ <div class="glass-header">
153
+ <span class="gh-title">Market Mood</span>
154
+ <span class="gh-badge">sentiment</span>
155
+ </div>
156
+ <div class="mood-wrap">
157
+ <canvas id="moodChart"></canvas>
158
+ </div>
159
+ <div class="mood-legend">
160
+ <div class="legend-item"><span class="dot dot-green"></span> Bullish</div>
161
+ <div class="legend-item"><span class="dot dot-red"></span> Bearish</div>
162
+ <div class="legend-item"><span class="dot dot-muted"></span> Neutral</div>
163
+ </div>
164
+ <div class="divider-line"></div>
165
+ <div class="stats-list">
166
+ <div class="stat-row"><span>Total Scored</span><strong id="rp-total">--</strong></div>
167
+ <div class="stat-row"><span>Bullish</span><strong class="clr-green" id="rp-bulls">--</strong></div>
168
+ <div class="stat-row"><span>Bearish</span><strong class="clr-red" id="rp-bears">--</strong></div>
169
+ </div>
170
+ <div class="divider-line"></div>
171
+ <div class="progress-stats">
172
+ <div class="prog-item">
173
+ <div class="prog-header"><span>Optimism</span><strong id="rp-opt-pct">--</strong></div>
174
+ <div class="prog-bar">
175
+ <div class="fill fill-gold" id="rp-opt-bar" style="width:50%"></div>
176
+ </div>
177
+ </div>
178
+ <div class="prog-item">
179
+ <div class="prog-header"><span>Pessimism</span><strong id="rp-pes-pct">--</strong></div>
180
+ <div class="prog-bar">
181
+ <div class="fill fill-red" id="rp-pes-bar" style="width:50%"></div>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ </div>
186
+
187
+ <!-- Watchlist (Top Movers) -->
188
+ <div class="glass">
189
+ <div class="glass-header">
190
+ <span class="gh-title">Top Movers</span>
191
+ <span class="gh-badge" id="badge-movers">loading</span>
192
+ </div>
193
+ <div id="watchlist"></div>
194
+ </div>
195
+
196
+ <!-- Volume Sentiment -->
197
+ <div class="glass">
198
+ <div class="glass-header">
199
+ <span class="gh-title">Volume Sentiment</span>
200
+ <span class="gh-badge">distribution</span>
201
+ </div>
202
+ <div class="chart-wrap">
203
+ <div class="chart-inner chart-sm"><canvas id="volChart"></canvas></div>
204
+ <div class="vol-legend">
205
+ <span><span class="sq sq-green"></span>Bullish</span>
206
+ <span><span class="sq sq-red"></span>Bearish</span>
207
+ <span><span class="sq sq-muted"></span>Neutral</span>
208
+ </div>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+
214
+
215
+ </div>
216
+
217
+ <!-- Search Results Dropdown -->
218
+ <div class="search-results" id="search-results"></div>
219
+
220
+ <!-- Company Detail Modal -->
221
+ <div class="modal-overlay" id="company-modal">
222
+ <div class="modal-card glass">
223
+ <button class="modal-close" id="modal-close">✕</button>
224
+ <div class="modal-header">
225
+ <div class="modal-title">
226
+ <h2 id="modal-ticker">--</h2>
227
+ <p id="modal-name">--</p>
228
+ <span class="tag" id="modal-sector">--</span>
229
+ </div>
230
+ <div class="modal-score-box">
231
+ <div class="sh-label">Current Score</div>
232
+ <div class="modal-score-val" id="modal-score">--</div>
233
+ <span class="tag" id="modal-confidence">--</span>
234
+ <span class="modal-price" id="modal-price" style="display:none;"></span>
235
+ </div>
236
+ </div>
237
+ <div class="modal-body">
238
+ <div class="modal-chart-section">
239
+ <h3>Sentiment Trend</h3>
240
+ <div class="modal-chart-wrap"><canvas id="modalTrendChart"></canvas></div>
241
+ </div>
242
+ <div class="modal-news-section">
243
+ <h3>Intelligence Report</h3>
244
+ <div id="modal-news-list"></div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ <script src="{{ url_for('static', filename='app.js') }}?v=14"></script>
251
+ </body>
252
+
253
+ </html>
templates/landing.html ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Sentix Alpha — The Future of Market Sentiment</title>
7
+ <link rel="stylesheet" href="/static/landing.css?v=1">
8
+ <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=DM+Sans:wght@300;400;500;700&family=DM+Mono:wght@400&display=swap" rel="stylesheet">
9
+ </head>
10
+ <body>
11
+ <div class="landing-noise"></div>
12
+ <div class="splatter-bg">
13
+ <div class="splat splat-1"></div>
14
+ <div class="splat splat-2"></div>
15
+ <div class="splat splat-3"></div>
16
+ </div>
17
+
18
+ <nav class="glass-nav">
19
+ <div class="nav-content">
20
+ <div class="logo">Sentix<span>Alpha</span></div>
21
+ <div class="nav-links">
22
+ <a href="#features">Engine</a>
23
+ <a href="#about">Intelligence</a>
24
+ <a href="/dashboard" class="cta-mini">Launch Dashboard</a>
25
+ </div>
26
+ </div>
27
+ </nav>
28
+
29
+ <main>
30
+ <section class="hero">
31
+ <div class="hero-content">
32
+ <div class="badge-reveal"><span>Sentix 2.0 Spatial Update</span></div>
33
+ <h1>ALPHA</h1>
34
+ <h2 class="hero-tagline">Sense the Market, <span class="accent">Before it Moves.</span></h2>
35
+ <p class="hero-desc">The world's first spatial sentiment engine. Powered by FinBERT. Visualized for the elite.</p>
36
+
37
+ <div class="hero-actions">
38
+ <a href="/dashboard" class="btn-primary">Enter Dashboard</a>
39
+ <a href="#features" class="btn-secondary">Explore Engine</a>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="hero-visual">
44
+ <div class="spatial-orb-container" id="hero-orb">
45
+ <!-- 3D Orb/Globe will be rendered here by landing.js -->
46
+ <div class="orb-fallback">560+ <span>STOCKS</span></div>
47
+ </div>
48
+ <div class="floating-metrics">
49
+ <div class="metric-card glass">
50
+ <span class="m-val">86.3%</span>
51
+ <span class="m-lbl">AI ACCURACY</span>
52
+ </div>
53
+ <div class="metric-card glass delay-1">
54
+ <span class="m-val">82K</span>
55
+ <span class="m-lbl">SIGNALS</span>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </section>
60
+
61
+ <section id="features" class="features">
62
+ <div class="section-header">
63
+ <h3>THE ENGINE</h3>
64
+ <p>Built for institutions, designed for detail.</p>
65
+ </div>
66
+
67
+ <div class="feature-grid">
68
+ <div class="f-cards-sub">
69
+ <div class="f-card glass">
70
+ <div class="f-icon">✦</div>
71
+ <h4>Spectral Analysis</h4>
72
+ <p>Proprietary FinBERT model trained on 2M+ financial documents for 99.9% entity accuracy.</p>
73
+ </div>
74
+ <div style="margin-top:20px" class="f-card glass">
75
+ <div class="f-icon">◉</div>
76
+ <h4>Real-time Pulse</h4>
77
+ <p>60-second refresh cycles across global indices, newsrooms, and social feeds.</p>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="code-glass">
82
+ <div class="code-header">
83
+ <div class="dot gold"></div>
84
+ <div class="dot"></div>
85
+ <div class="dot"></div>
86
+ <span style="font-size:10px; opacity:0.5; margin-left:10px">finbert_layer_04.py</span>
87
+ </div>
88
+ <pre>
89
+ <span class="gold">class</span> SentimentEngine:
90
+ <span class="gold">def</span> analyze(text):
91
+ tokens = tokenizer(text)
92
+ logits = model(tokens)
93
+
94
+ <span style="color:#5c6370"># Spatial vector mapping</span>
95
+ score = softmax(logits)
96
+ <span class="gold">return</span> blend_spatial(score)
97
+
98
+ <span style="color:#5c6370">// Output: Bullish (+0.842)</span>
99
+ </pre>
100
+ </div>
101
+ </div>
102
+ </section>
103
+
104
+ <section id="about" class="intelligence">
105
+ <div class="intel-grid">
106
+ <div class="intel-content">
107
+ <div class="badge-reveal"><span>The Intelligence Layer</span></div>
108
+ <h2>The Human Signal.</h2>
109
+ <p>Sentix doesn't just read words; it senses the underlying market weight. By mapping sentiment into a spatial 3D environment, we reveal patterns that 2D charts hide.</p>
110
+
111
+ <div class="sim-box">
112
+ <div class="flow-num">INTERACTIVE PROTOTYPE</div>
113
+ <input type="text" id="sim-input" class="sim-input" placeholder="Type a market headline...">
114
+ <div class="sim-result">
115
+ <div id="sim-indicator" class="sim-indicator"></div>
116
+ <div id="sim-msg" class="sim-msg">Awaiting signal inputs...</div>
117
+ </div>
118
+ </div>
119
+ </div>
120
+
121
+ <div class="signal-flow">
122
+ <div class="flow-step">
123
+ <div class="flow-num">01 / INGESTION</div>
124
+ <h5>Global Feed</h5>
125
+ <p>Aggregating data from 4,000+ sources including Bloomberg, Reuters, and X.</p>
126
+ </div>
127
+ <div class="flow-step">
128
+ <div class="flow-num">02 / VALIDATION</div>
129
+ <h5>Entity Logic</h5>
130
+ <p>Filtering noise from signal using high-accuracy stock entity detection.</p>
131
+ </div>
132
+ <div class="flow-step">
133
+ <div class="flow-num">03 / SPATIAL MAPPING</div>
134
+ <h5>3D Vectoring</h5>
135
+ <p>Translating sentiment scores into dimensional coordinates for our spatial UI.</p>
136
+ </div>
137
+ <div class="flow-step" style="border:none">
138
+ <div class="flow-num">04 / DELIVERY</div>
139
+ <h5>Alpha Output</h5>
140
+ <p>Real-time delivery to the Sentix dashboard for institutional decision making.</p>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </section>
145
+ </main>
146
+
147
+ <footer>
148
+ <div class="footer-content">
149
+ <div class="logo">Sentix</div>
150
+ <p>© 2026 Sentix AI Dashboard. All rights reserved.</p>
151
+ </div>
152
+ </footer>
153
+
154
+ <script src="/static/landing.js?v=1"></script>
155
+ </body>
156
+ </html>