Spaces:
Sleeping
Sleeping
Commit ·
49b3fff
1
Parent(s): fd5a63e
V5 Deploy: Three-Phase Hybrid Scraper + 24/7 Live Operation
Browse files- .gitattributes +1 -0
- Dockerfile +29 -0
- README.md +13 -5
- app.py +306 -0
- data/fetch_stocks.py +411 -0
- data/indian_stocks.json +0 -0
- domain/__init__.py +2 -0
- domain/models.py +80 -0
- live_scraper.py +540 -0
- models/my_finbert/config.json +41 -0
- models/my_finbert/model.safetensors +3 -0
- models/my_finbert/tokenizer.json +0 -0
- models/my_finbert/tokenizer_config.json +14 -0
- models/my_finbert/training_args.bin +3 -0
- requirements.txt +13 -0
- sentiment_data.db +3 -0
- services/__init__.py +1 -0
- services/database.py +219 -0
- services/sentiment_service.py +87 -0
- start.sh +17 -0
- static/app.js +831 -0
- static/landing.css +342 -0
- static/landing.js +178 -0
- static/style.css +1169 -0
- templates/index.html +253 -0
- templates/landing.html +156 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
pinned:
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|