Spaces:
Running
Running
Change it to only a scrapper
Browse files- .dockerignore +36 -0
- Dockerfile +67 -44
- actions.py +36 -170
- config.py +47 -44
- database.py +6 -2
- db_helpers.py +289 -714
- llm.py +0 -1087
- logger.py +24 -8
- macro_sources.csv +17 -0
- main.py +10 -9
- models.py +61 -128
- requirements.txt +19 -75
- us_stocks_list.csv +41 -0
- utils.py +63 -19
- workers.py +100 -461
.dockerignore
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Virtualenvs (start.sh creates scrapper_env locally)
|
| 2 |
+
scrapper_env/
|
| 3 |
+
*_env/
|
| 4 |
+
venv/
|
| 5 |
+
.venv/
|
| 6 |
+
|
| 7 |
+
# Python caches / build artifacts
|
| 8 |
+
__pycache__/
|
| 9 |
+
*.py[cod]
|
| 10 |
+
*.egg-info/
|
| 11 |
+
.pytest_cache/
|
| 12 |
+
.mypy_cache/
|
| 13 |
+
.ruff_cache/
|
| 14 |
+
|
| 15 |
+
# Local databases — must not be baked into the image
|
| 16 |
+
*.db
|
| 17 |
+
*.sqlite
|
| 18 |
+
*.sqlite3
|
| 19 |
+
|
| 20 |
+
# Secrets / local env
|
| 21 |
+
.env
|
| 22 |
+
.env.*
|
| 23 |
+
|
| 24 |
+
# Playwright browser cache (installed inside the image via the build step)
|
| 25 |
+
.cache/
|
| 26 |
+
ms-playwright/
|
| 27 |
+
|
| 28 |
+
# Logs and OS cruft
|
| 29 |
+
*.log
|
| 30 |
+
.DS_Store
|
| 31 |
+
|
| 32 |
+
# VCS / tooling
|
| 33 |
+
.git/
|
| 34 |
+
.gitignore
|
| 35 |
+
.dockerignore
|
| 36 |
+
Dockerfile
|
Dockerfile
CHANGED
|
@@ -1,72 +1,95 @@
|
|
| 1 |
FROM python:3.10-slim
|
| 2 |
|
| 3 |
-
#
|
| 4 |
-
# System dependencies
|
| 5 |
-
#
|
| 6 |
RUN apt-get update && apt-get install -y \
|
| 7 |
-
curl
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
&& rm -rf /var/lib/apt/lists/*
|
| 13 |
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
#
|
| 17 |
RUN useradd -m -u 1000 user
|
| 18 |
|
| 19 |
-
#
|
| 20 |
-
#
|
| 21 |
-
#
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
| 23 |
/home/user/app/models \
|
| 24 |
/home/user/app/ollama \
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
# =========================
|
| 30 |
-
# Env
|
| 31 |
-
# =========================
|
| 32 |
ENV HOME=/home/user
|
| 33 |
ENV PATH=/home/user/.local/bin:$PATH
|
| 34 |
ENV PYTHONUNBUFFERED=1
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
|
| 41 |
WORKDIR /home/user/app
|
| 42 |
|
| 43 |
-
#
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
COPY --chown=user:user requirements.txt .
|
| 47 |
|
| 48 |
-
# =========================
|
| 49 |
-
# Install Python deps
|
| 50 |
-
# =========================
|
| 51 |
RUN pip install --no-cache-dir --upgrade pip && \
|
| 52 |
pip install --no-cache-dir -r requirements.txt
|
| 53 |
|
| 54 |
-
#
|
| 55 |
-
# Playwright
|
| 56 |
-
#
|
| 57 |
-
RUN python -m playwright install
|
| 58 |
-
RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
| 59 |
|
| 60 |
-
#
|
| 61 |
-
#
|
| 62 |
-
#
|
| 63 |
COPY --chown=user:user . .
|
| 64 |
|
| 65 |
-
# =========================
|
| 66 |
-
# Switch to user
|
| 67 |
-
# =========================
|
| 68 |
-
USER user
|
| 69 |
-
|
| 70 |
EXPOSE 7860
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
FROM python:3.10-slim
|
| 2 |
|
| 3 |
+
# ----------------------------------------------------
|
| 4 |
+
# System dependencies (Playwright Chromium runtime libs)
|
| 5 |
+
# ----------------------------------------------------
|
| 6 |
RUN apt-get update && apt-get install -y \
|
| 7 |
+
curl \
|
| 8 |
+
wget \
|
| 9 |
+
ca-certificates \
|
| 10 |
+
libnss3 \
|
| 11 |
+
libatk-bridge2.0-0 \
|
| 12 |
+
libdrm2 \
|
| 13 |
+
libxkbcommon0 \
|
| 14 |
+
libgtk-3-0 \
|
| 15 |
+
libgbm1 \
|
| 16 |
+
libasound2 \
|
| 17 |
+
libxshmfence1 \
|
| 18 |
+
libxcomposite1 \
|
| 19 |
+
libxdamage1 \
|
| 20 |
+
libxfixes3 \
|
| 21 |
+
libxrandr2 \
|
| 22 |
+
libxext6 \
|
| 23 |
+
libx11-6 \
|
| 24 |
+
libxcb1 \
|
| 25 |
+
libxrender1 \
|
| 26 |
+
libxi6 \
|
| 27 |
&& rm -rf /var/lib/apt/lists/*
|
| 28 |
|
| 29 |
+
# ----------------------------------------------------
|
| 30 |
+
# Create application user
|
| 31 |
+
# ----------------------------------------------------
|
| 32 |
RUN useradd -m -u 1000 user
|
| 33 |
|
| 34 |
+
# ----------------------------------------------------
|
| 35 |
+
# Application directories
|
| 36 |
+
# NOTE: Do NOT create /data here.
|
| 37 |
+
# Hugging Face mounts /data at runtime.
|
| 38 |
+
# ----------------------------------------------------
|
| 39 |
+
RUN mkdir -p \
|
| 40 |
+
/home/user/app \
|
| 41 |
/home/user/app/models \
|
| 42 |
/home/user/app/ollama \
|
| 43 |
+
&& chown -R user:user /home/user
|
| 44 |
+
|
| 45 |
+
# ----------------------------------------------------
|
| 46 |
+
# Runtime environment
|
| 47 |
+
# ----------------------------------------------------
|
| 48 |
+
ENV HF_HOME=/data/hf-cache
|
| 49 |
+
ENV HF_HUB_CACHE=/data/hf-cache
|
| 50 |
+
ENV TRANSFORMERS_CACHE=/data/hf-cache
|
| 51 |
|
|
|
|
|
|
|
|
|
|
| 52 |
ENV HOME=/home/user
|
| 53 |
ENV PATH=/home/user/.local/bin:$PATH
|
| 54 |
ENV PYTHONUNBUFFERED=1
|
| 55 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 56 |
|
| 57 |
+
# ----------------------------------------------------
|
| 58 |
+
# Switch to non-root user
|
| 59 |
+
# ----------------------------------------------------
|
| 60 |
+
USER user
|
| 61 |
|
| 62 |
WORKDIR /home/user/app
|
| 63 |
|
| 64 |
+
# ----------------------------------------------------
|
| 65 |
+
# Python dependencies
|
| 66 |
+
# ----------------------------------------------------
|
| 67 |
COPY --chown=user:user requirements.txt .
|
| 68 |
|
|
|
|
|
|
|
|
|
|
| 69 |
RUN pip install --no-cache-dir --upgrade pip && \
|
| 70 |
pip install --no-cache-dir -r requirements.txt
|
| 71 |
|
| 72 |
+
# ----------------------------------------------------
|
| 73 |
+
# Playwright Chromium
|
| 74 |
+
# ----------------------------------------------------
|
| 75 |
+
RUN python -m playwright install chromium
|
|
|
|
| 76 |
|
| 77 |
+
# ----------------------------------------------------
|
| 78 |
+
# Application code
|
| 79 |
+
# ----------------------------------------------------
|
| 80 |
COPY --chown=user:user . .
|
| 81 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
EXPOSE 7860
|
| 83 |
|
| 84 |
+
# ----------------------------------------------------
|
| 85 |
+
# Health check
|
| 86 |
+
# ----------------------------------------------------
|
| 87 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
| 88 |
+
CMD curl -fsS -X POST http://localhost:7860/action \
|
| 89 |
+
-H "Content-Type: application/json" \
|
| 90 |
+
-d '{"action":"HEALTH_CHECK"}' || exit 1
|
| 91 |
+
|
| 92 |
+
# ----------------------------------------------------
|
| 93 |
+
# Start application
|
| 94 |
+
# ----------------------------------------------------
|
| 95 |
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
actions.py
CHANGED
|
@@ -11,21 +11,16 @@ from database import SessionLocal
|
|
| 11 |
from config import get_auth_token, reload_env
|
| 12 |
from db_helpers import (
|
| 13 |
add_source,
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
get_sector_sentiments,
|
| 17 |
get_stats,
|
| 18 |
)
|
| 19 |
|
| 20 |
-
# Import the new per-worker control API from the refactored workers module.
|
| 21 |
from workers import (
|
| 22 |
start_workers,
|
| 23 |
stop_workers,
|
| 24 |
-
stop_worker,
|
| 25 |
-
restart_worker,
|
| 26 |
worker_status,
|
| 27 |
shutdown_server,
|
| 28 |
-
_WORKER_REGISTRY,
|
| 29 |
)
|
| 30 |
|
| 31 |
# =========================
|
|
@@ -44,11 +39,7 @@ class ActionRequest(BaseModel):
|
|
| 44 |
|
| 45 |
|
| 46 |
def verify_auth(x_auth_token: Optional[str] = Header(None)):
|
| 47 |
-
"""Reject requests when BACKEND_AUTH_TOKEN is set and the header doesn't match.
|
| 48 |
-
|
| 49 |
-
Reads get_auth_token() each call so a reload_env() in handle_start()
|
| 50 |
-
immediately changes the accepted token without a process restart.
|
| 51 |
-
"""
|
| 52 |
|
| 53 |
expected = get_auth_token()
|
| 54 |
|
|
@@ -79,21 +70,8 @@ def _ping_database():
|
|
| 79 |
return False
|
| 80 |
|
| 81 |
|
| 82 |
-
def _worker_name_from_action(action: str, prefix: str) -> Optional[str]:
|
| 83 |
-
"""
|
| 84 |
-
Derive the internal worker name from an action string.
|
| 85 |
-
|
| 86 |
-
Examples
|
| 87 |
-
--------
|
| 88 |
-
_worker_name_from_action("START_SCRAPER", "START_") → "scraper"
|
| 89 |
-
_worker_name_from_action("STOP_HEADLINE", "STOP_") → "headline"
|
| 90 |
-
_worker_name_from_action("RESTART_CLEANUP", "RESTART_") → "cleanup"
|
| 91 |
-
"""
|
| 92 |
-
return action[len(prefix) :].lower()
|
| 93 |
-
|
| 94 |
-
|
| 95 |
# =========================
|
| 96 |
-
#
|
| 97 |
# =========================
|
| 98 |
|
| 99 |
|
|
@@ -119,9 +97,7 @@ def handle_start(payload=None):
|
|
| 119 |
|
| 120 |
|
| 121 |
def handle_shutdown(payload=None):
|
| 122 |
-
"""Stop ALL workers gracefully
|
| 123 |
-
user can edit .env and call START again — for full process exit, use EXIT.
|
| 124 |
-
"""
|
| 125 |
|
| 126 |
logger.info("handle_shutdown: invoked")
|
| 127 |
|
|
@@ -163,118 +139,39 @@ def handle_exit(payload=None):
|
|
| 163 |
|
| 164 |
|
| 165 |
# =========================
|
| 166 |
-
#
|
| 167 |
# =========================
|
| 168 |
|
| 169 |
|
| 170 |
-
def
|
| 171 |
-
"""
|
| 172 |
-
|
| 173 |
-
logger.info(f"handle_start_worker: invoked name={name!r}")
|
| 174 |
-
|
| 175 |
-
if name not in _WORKER_REGISTRY:
|
| 176 |
-
return {
|
| 177 |
-
"success": False,
|
| 178 |
-
"error": f"Unknown worker {name!r}. Valid names: {sorted(_WORKER_REGISTRY)}",
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
status = worker_status()
|
| 182 |
-
if status.get(name):
|
| 183 |
-
logger.info(f"handle_start_worker: '{name}' already running")
|
| 184 |
-
return {
|
| 185 |
-
"success": False,
|
| 186 |
-
"message": f"Worker '{name}' is already running",
|
| 187 |
-
}
|
| 188 |
-
|
| 189 |
-
start_workers([name])
|
| 190 |
-
logger.info(f"handle_start_worker: '{name}' started")
|
| 191 |
-
return {
|
| 192 |
-
"success": True,
|
| 193 |
-
"message": f"Worker '{name}' started",
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def handle_stop_worker(payload=None, *, name: str):
|
| 198 |
-
"""Stop a single named worker. No-op (with warning) if not running."""
|
| 199 |
-
|
| 200 |
-
logger.info(f"handle_stop_worker: invoked name={name!r}")
|
| 201 |
-
|
| 202 |
-
if name not in _WORKER_REGISTRY:
|
| 203 |
-
return {
|
| 204 |
-
"success": False,
|
| 205 |
-
"error": f"Unknown worker {name!r}. Valid names: {sorted(_WORKER_REGISTRY)}",
|
| 206 |
-
}
|
| 207 |
-
|
| 208 |
-
status = worker_status()
|
| 209 |
-
if not status.get(name):
|
| 210 |
-
logger.info(f"handle_stop_worker: '{name}' is not running")
|
| 211 |
-
return {
|
| 212 |
-
"success": False,
|
| 213 |
-
"message": f"Worker '{name}' is not running",
|
| 214 |
-
}
|
| 215 |
-
|
| 216 |
-
stop_worker(name)
|
| 217 |
-
logger.info(f"handle_stop_worker: '{name}' stopped")
|
| 218 |
-
return {
|
| 219 |
-
"success": True,
|
| 220 |
-
"message": f"Worker '{name}' stopped",
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
def handle_restart_worker(payload=None, *, name: str):
|
| 225 |
-
"""Stop a named worker (if running) then immediately restart it."""
|
| 226 |
-
|
| 227 |
-
logger.info(f"handle_restart_worker: invoked name={name!r}")
|
| 228 |
-
|
| 229 |
-
if name not in _WORKER_REGISTRY:
|
| 230 |
-
return {
|
| 231 |
-
"success": False,
|
| 232 |
-
"error": f"Unknown worker {name!r}. Valid names: {sorted(_WORKER_REGISTRY)}",
|
| 233 |
-
}
|
| 234 |
-
|
| 235 |
-
restart_worker(name)
|
| 236 |
-
logger.info(f"handle_restart_worker: '{name}' restarted")
|
| 237 |
-
return {
|
| 238 |
-
"success": True,
|
| 239 |
-
"message": f"Worker '{name}' restarted",
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
# =========================
|
| 246 |
|
|
|
|
| 247 |
|
| 248 |
-
|
| 249 |
-
|
|
|
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
return {
|
| 254 |
-
"success": True,
|
| 255 |
-
"count": len(articles),
|
| 256 |
-
"articles": articles,
|
| 257 |
-
}
|
| 258 |
|
| 259 |
|
| 260 |
-
def
|
| 261 |
-
"
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
return {"success": True, "count": len(raw_html), "data": raw_html}
|
| 266 |
|
|
|
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
return {
|
| 274 |
-
"success": True,
|
| 275 |
-
"count": len(sectors),
|
| 276 |
-
"sectors": sectors,
|
| 277 |
-
}
|
| 278 |
|
| 279 |
|
| 280 |
def handle_add_sources(payload=None):
|
|
@@ -311,7 +208,6 @@ def handle_health_check(payload=None):
|
|
| 311 |
elif all(workers.values()):
|
| 312 |
status = "ok"
|
| 313 |
else:
|
| 314 |
-
# Some workers running, some not.
|
| 315 |
status = "degraded"
|
| 316 |
|
| 317 |
logger.info(f"handle_health_check: status={status} workers={workers} db_ok={db_ok}")
|
|
@@ -327,57 +223,27 @@ def handle_health_check(payload=None):
|
|
| 327 |
# =========================
|
| 328 |
# ACTIONS DISPATCH
|
| 329 |
# =========================
|
| 330 |
-
# Bulk / lifecycle actions are registered directly.
|
| 331 |
-
# Per-worker actions (START_*, STOP_*, RESTART_*) are built dynamically from
|
| 332 |
-
# _WORKER_REGISTRY so adding a new worker to workers.py automatically exposes
|
| 333 |
-
# all three control actions here with zero extra boilerplate.
|
| 334 |
|
| 335 |
|
| 336 |
def _build_actions() -> dict:
|
| 337 |
-
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
"START": handle_start,
|
| 340 |
"SHUTDOWN": handle_shutdown,
|
| 341 |
-
"EXIT": handle_exit,
|
| 342 |
-
# ---
|
| 343 |
-
|
| 344 |
-
"
|
| 345 |
-
#
|
| 346 |
"ADD_SOURCES": handle_add_sources,
|
| 347 |
-
# this is for raw html model - pipeline
|
| 348 |
-
"GET_RAW_DATA": handle_get_raw,
|
| 349 |
-
# "GET_SECTOR": handle_get_sector,
|
| 350 |
# --- diagnostics ---
|
| 351 |
"HEALTH_CHECK": handle_health_check,
|
| 352 |
"GET_STATS": get_work_done,
|
| 353 |
}
|
| 354 |
|
| 355 |
-
# Dynamically register START_<WORKER>, STOP_<WORKER>, RESTART_<WORKER>
|
| 356 |
-
# for every entry in the worker registry.
|
| 357 |
-
#
|
| 358 |
-
# e.g. _WORKER_REGISTRY keys: scraper, headline, sentiment, cleanup, purge
|
| 359 |
-
#
|
| 360 |
-
# Registered actions:
|
| 361 |
-
# START_SCRAPER / STOP_SCRAPER / RESTART_SCRAPER
|
| 362 |
-
# START_HEADLINE / STOP_HEADLINE / RESTART_HEADLINE
|
| 363 |
-
# START_SENTIMENT / STOP_SENTIMENT / RESTART_SENTIMENT
|
| 364 |
-
# START_CLEANUP / STOP_CLEANUP / RESTART_CLEANUP
|
| 365 |
-
# START_PURGE / STOP_PURGE / RESTART_PURGE
|
| 366 |
-
|
| 367 |
-
for worker_name in _WORKER_REGISTRY:
|
| 368 |
-
key = worker_name.upper()
|
| 369 |
-
|
| 370 |
-
actions[f"START_{key}"] = lambda p=None, n=worker_name: handle_start_worker(
|
| 371 |
-
p, name=n
|
| 372 |
-
)
|
| 373 |
-
actions[f"STOP_{key}"] = lambda p=None, n=worker_name: handle_stop_worker(
|
| 374 |
-
p, name=n
|
| 375 |
-
)
|
| 376 |
-
actions[f"RESTART_{key}"] = lambda p=None, n=worker_name: handle_restart_worker(
|
| 377 |
-
p, name=n
|
| 378 |
-
)
|
| 379 |
-
|
| 380 |
-
return actions
|
| 381 |
-
|
| 382 |
|
| 383 |
ACTIONS = _build_actions()
|
|
|
|
| 11 |
from config import get_auth_token, reload_env
|
| 12 |
from db_helpers import (
|
| 13 |
add_source,
|
| 14 |
+
get_one_raw_html,
|
| 15 |
+
delete_raw_html,
|
|
|
|
| 16 |
get_stats,
|
| 17 |
)
|
| 18 |
|
|
|
|
| 19 |
from workers import (
|
| 20 |
start_workers,
|
| 21 |
stop_workers,
|
|
|
|
|
|
|
| 22 |
worker_status,
|
| 23 |
shutdown_server,
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
# =========================
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def verify_auth(x_auth_token: Optional[str] = Header(None)):
|
| 42 |
+
"""Reject requests when BACKEND_AUTH_TOKEN is set and the header doesn't match."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
expected = get_auth_token()
|
| 45 |
|
|
|
|
| 70 |
return False
|
| 71 |
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
# =========================
|
| 74 |
+
# LIFECYCLE ACTION HANDLERS
|
| 75 |
# =========================
|
| 76 |
|
| 77 |
|
|
|
|
| 97 |
|
| 98 |
|
| 99 |
def handle_shutdown(payload=None):
|
| 100 |
+
"""Stop ALL workers gracefully; server process keeps running."""
|
|
|
|
|
|
|
| 101 |
|
| 102 |
logger.info("handle_shutdown: invoked")
|
| 103 |
|
|
|
|
| 139 |
|
| 140 |
|
| 141 |
# =========================
|
| 142 |
+
# DATA / HANDOFF ACTION HANDLERS
|
| 143 |
# =========================
|
| 144 |
|
| 145 |
|
| 146 |
+
def handle_get_raw_html(payload=None):
|
| 147 |
+
"""Return ONE raw_html row for the LLM service to ingest.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
Optional payload: {"type": "news" | "stock"}.
|
| 150 |
+
"""
|
|
|
|
| 151 |
|
| 152 |
+
logger.info(f"handle_get_raw_html: invoked payload={payload!r}")
|
| 153 |
|
| 154 |
+
type_filter = None
|
| 155 |
+
if isinstance(payload, dict):
|
| 156 |
+
type_filter = payload.get("type")
|
| 157 |
|
| 158 |
+
data = get_one_raw_html(type_filter)
|
| 159 |
+
return {"success": True, "data": data}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
|
| 162 |
+
def handle_delete_raw_html(payload=None):
|
| 163 |
+
"""Delete a raw_html row once the consumer has stored its own copy.
|
| 164 |
|
| 165 |
+
Payload: {"id": "<uuid>"}.
|
| 166 |
+
"""
|
|
|
|
| 167 |
|
| 168 |
+
logger.info(f"handle_delete_raw_html: invoked payload={payload!r}")
|
| 169 |
|
| 170 |
+
if not isinstance(payload, dict) or not payload.get("id"):
|
| 171 |
+
return {"success": False, "error": "payload must include an 'id'"}
|
| 172 |
|
| 173 |
+
deleted = delete_raw_html(payload["id"])
|
| 174 |
+
return {"success": True, "deleted": deleted}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
|
| 177 |
def handle_add_sources(payload=None):
|
|
|
|
| 208 |
elif all(workers.values()):
|
| 209 |
status = "ok"
|
| 210 |
else:
|
|
|
|
| 211 |
status = "degraded"
|
| 212 |
|
| 213 |
logger.info(f"handle_health_check: status={status} workers={workers} db_ok={db_ok}")
|
|
|
|
| 223 |
# =========================
|
| 224 |
# ACTIONS DISPATCH
|
| 225 |
# =========================
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
|
| 228 |
def _build_actions() -> dict:
|
| 229 |
+
# The scrapper runs all its workers (scraper, stock_enhancer, macro) as a
|
| 230 |
+
# single unit: START launches them all, SHUTDOWN stops them all. There is no
|
| 231 |
+
# per-worker control here by design — see the LLM service for granular
|
| 232 |
+
# per-worker actions.
|
| 233 |
+
return {
|
| 234 |
+
# --- lifecycle (all workers together) ---
|
| 235 |
"START": handle_start,
|
| 236 |
"SHUTDOWN": handle_shutdown,
|
| 237 |
+
# "EXIT": handle_exit,
|
| 238 |
+
# --- handoff API (consumed by the LLM service) ---
|
| 239 |
+
"GET_RAW_HTML": handle_get_raw_html,
|
| 240 |
+
"DELETE_RAW_HTML": handle_delete_raw_html,
|
| 241 |
+
# --- sources ---
|
| 242 |
"ADD_SOURCES": handle_add_sources,
|
|
|
|
|
|
|
|
|
|
| 243 |
# --- diagnostics ---
|
| 244 |
"HEALTH_CHECK": handle_health_check,
|
| 245 |
"GET_STATS": get_work_done,
|
| 246 |
}
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
ACTIONS = _build_actions()
|
config.py
CHANGED
|
@@ -1,70 +1,73 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
from dotenv import load_dotenv
|
| 3 |
-
from huggingface_hub import hf_hub_download
|
| 4 |
|
| 5 |
# =========================
|
| 6 |
# PATHS
|
| 7 |
# =========================
|
| 8 |
|
| 9 |
-
MODEL_PATH = "Qwen/Qwen2.5-3B-Instruct"
|
| 10 |
-
|
| 11 |
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 12 |
-
|
| 13 |
-
#
|
| 14 |
-
# )
|
| 15 |
-
|
| 16 |
-
#
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
# repo_id=MODEL_REPO,
|
| 22 |
-
# filename="model/sentiment_analyzer_onnx/model.onnx",
|
| 23 |
-
# )
|
| 24 |
-
# MODEL_SENTIMENT_OWN_DIR = os.path.dirname(MODEL_SENTIMENT_OWN)
|
| 25 |
-
|
| 26 |
-
# Max token length used when tokenizing a headline for the ONNX sentiment model.
|
| 27 |
-
MODEL_SENTIMENT_OWN_MAX_LEN = 300
|
| 28 |
-
STOCK_PATH = os.path.join(BACKEND_DIR, "ind_nifty_list.csv")
|
| 29 |
SOURCES_PATH = os.path.join(BACKEND_DIR, "news_sources.json")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
DOTENV_PATH = os.path.join(BACKEND_DIR, ".env")
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# =========================
|
| 34 |
# TIMING / THRESHOLDS
|
| 35 |
# =========================
|
| 36 |
|
| 37 |
-
#
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
| 40 |
|
| 41 |
# Disable a source after this many consecutive failures.
|
| 42 |
MAX_CONSECUTIVE_FAILURES = 5
|
| 43 |
|
| 44 |
-
#
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
# Idle / backoff intervals
|
| 49 |
-
HEADLINE_IDLE_SECONDS = 45 * 60
|
| 50 |
-
SENTIMENT_IDLE_SECONDS = 25 * 60
|
| 51 |
-
SENTIMENT_DATASET_IDLE_SECONDS = 25 * 60
|
| 52 |
-
SENTIMENT_TABLE_IDLE_SECONDS = 25 * 60
|
| 53 |
-
CLEANUP_TICK_SECONDS = 60 * 60
|
| 54 |
ENRICHMENT_RETRY_MS = 6 * 60 * 60 * 1000
|
| 55 |
-
|
| 56 |
-
# and fetched more than this many times via GET_CLEAN_ANALYZED.
|
| 57 |
-
CLEAN_HEADLINE_MAX_FETCHES = 3
|
| 58 |
WORKER_ERROR_BACKOFF_SECONDS = 30
|
| 59 |
HTTP_REQUEST_TIMEOUT_SECONDS = 10
|
| 60 |
|
| 61 |
-
# Sector sentiment rollup (sector_worker): aggregate the sentiment table once
|
| 62 |
-
# per 24h. "today" is the trailing 24h; the 7-day average spans the trailing
|
| 63 |
-
# 7 days (inclusive). Both are confidence-weighted over raw sentiment rows.
|
| 64 |
-
SECTOR_TICK_SECONDS = 24 * 60 * 60
|
| 65 |
-
SECTOR_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
|
| 66 |
-
SECTOR_7D_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
|
| 67 |
-
|
| 68 |
# =========================
|
| 69 |
# ENV / DOTENV
|
| 70 |
# =========================
|
|
|
|
| 1 |
import os
|
| 2 |
+
|
| 3 |
from dotenv import load_dotenv
|
|
|
|
| 4 |
|
| 5 |
# =========================
|
| 6 |
# PATHS
|
| 7 |
# =========================
|
| 8 |
|
|
|
|
|
|
|
| 9 |
BACKEND_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 10 |
+
|
| 11 |
+
# One CSV per market. Each must share the same columns
|
| 12 |
+
# (Company Name, Industry, Symbol, Series, ISIN Code, Country, Exchange).
|
| 13 |
+
# load_stocks_to_db() loads every path here, so adding a new market is just
|
| 14 |
+
# dropping in a CSV and listing it below.
|
| 15 |
+
STOCK_PATHS = [
|
| 16 |
+
os.path.join(BACKEND_DIR, "ind_nifty_list.csv"),
|
| 17 |
+
os.path.join(BACKEND_DIR, "us_stocks_list.csv"),
|
| 18 |
+
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
SOURCES_PATH = os.path.join(BACKEND_DIR, "news_sources.json")
|
| 20 |
+
|
| 21 |
+
# Config-driven macro/market series (FII/DII, VIX, commodities, indices, FX,
|
| 22 |
+
# bond yields). One row per series; see load_macro_sources_to_db().
|
| 23 |
+
MACRO_SOURCES_PATH = os.path.join(BACKEND_DIR, "macro_sources.csv")
|
| 24 |
+
|
| 25 |
DOTENV_PATH = os.path.join(BACKEND_DIR, ".env")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# =========================
|
| 29 |
+
# MARKETS
|
| 30 |
+
# =========================
|
| 31 |
+
# Drives both the close-time scheduler (utils.py builds ZoneInfo from `tz`) and
|
| 32 |
+
# the per-market "fetch after close" gating in the stock/macro workers. Adding a
|
| 33 |
+
# market is a one-line change here. tz is kept as a string so config has no
|
| 34 |
+
# import dependency on utils/zoneinfo.
|
| 35 |
+
|
| 36 |
+
MARKET_CONFIG = {
|
| 37 |
+
"INDIA": {
|
| 38 |
+
"tz": "Asia/Kolkata",
|
| 39 |
+
"close": (15, 30), # NSE/BSE close 15:30 IST
|
| 40 |
+
"exchanges": ["NSE", "BSE"],
|
| 41 |
+
},
|
| 42 |
+
"US": {
|
| 43 |
+
"tz": "America/New_York",
|
| 44 |
+
"close": (16, 0), # NYSE/NASDAQ close 16:00 ET
|
| 45 |
+
"exchanges": ["NASDAQ", "NYSE"],
|
| 46 |
+
},
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
|
| 50 |
# =========================
|
| 51 |
# TIMING / THRESHOLDS
|
| 52 |
# =========================
|
| 53 |
|
| 54 |
+
# News: re-fetch each source once an hour. The loop wakes more often than the
|
| 55 |
+
# interval so a source becomes eligible promptly after its hour elapses (don't
|
| 56 |
+
# set the tick == interval, or the effective cadence drifts).
|
| 57 |
+
SCRAPE_INTERVAL_MS = 60 * 60 * 1000
|
| 58 |
+
SCRAPER_TICK_SECONDS = 60 * 5
|
| 59 |
|
| 60 |
# Disable a source after this many consecutive failures.
|
| 61 |
MAX_CONSECUTIVE_FAILURES = 5
|
| 62 |
|
| 63 |
+
# Stock enrichment: how long the stock worker idles when no stock is due, and
|
| 64 |
+
# how long after the last fetch a stock becomes eligible again.
|
| 65 |
+
STOCK_IDLE_SECONDS = 45 * 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
ENRICHMENT_RETRY_MS = 6 * 60 * 60 * 1000
|
| 67 |
+
|
|
|
|
|
|
|
| 68 |
WORKER_ERROR_BACKOFF_SECONDS = 30
|
| 69 |
HTTP_REQUEST_TIMEOUT_SECONDS = 10
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
# =========================
|
| 72 |
# ENV / DOTENV
|
| 73 |
# =========================
|
database.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from sqlalchemy import create_engine, event
|
| 2 |
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 3 |
|
| 4 |
-
DATABASE_URL = "sqlite:////
|
| 5 |
|
| 6 |
engine = create_engine(
|
| 7 |
DATABASE_URL,
|
|
@@ -25,6 +25,10 @@ def _set_sqlite_pragma(dbapi_connection, connection_record):
|
|
| 25 |
cursor.close()
|
| 26 |
|
| 27 |
|
| 28 |
-
SessionLocal = sessionmaker(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
Base = declarative_base()
|
|
|
|
| 1 |
from sqlalchemy import create_engine, event
|
| 2 |
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 3 |
|
| 4 |
+
DATABASE_URL = "sqlite:///./scrapper.db"
|
| 5 |
|
| 6 |
engine = create_engine(
|
| 7 |
DATABASE_URL,
|
|
|
|
| 25 |
cursor.close()
|
| 26 |
|
| 27 |
|
| 28 |
+
SessionLocal = sessionmaker(
|
| 29 |
+
autocommit=False,
|
| 30 |
+
autoflush=False,
|
| 31 |
+
bind=engine,
|
| 32 |
+
)
|
| 33 |
|
| 34 |
Base = declarative_base()
|
db_helpers.py
CHANGED
|
@@ -1,86 +1,25 @@
|
|
| 1 |
import json
|
| 2 |
-
import re
|
| 3 |
import time
|
| 4 |
-
from datetime import datetime, timedelta
|
| 5 |
-
|
| 6 |
from sqlalchemy import or_, text, func
|
| 7 |
from database import engine, SessionLocal
|
| 8 |
import pandas as pd
|
| 9 |
-
import numpy as np
|
| 10 |
from logger import logger
|
| 11 |
from models import (
|
| 12 |
RawHTML,
|
| 13 |
-
ParsedArticle,
|
| 14 |
NewsSource,
|
| 15 |
-
Sentiment,
|
| 16 |
-
Sector,
|
| 17 |
-
Stock,
|
| 18 |
StockSources,
|
|
|
|
| 19 |
)
|
| 20 |
from config import (
|
| 21 |
-
ENRICHMENT_RETRY_MS,
|
| 22 |
SOURCES_PATH,
|
| 23 |
MAX_CONSECUTIVE_FAILURES,
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
)
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def clean_headline_text(text: str) -> str:
|
| 31 |
-
if not text:
|
| 32 |
-
logger.warning("clean_headline_text: empty/invalid headline text")
|
| 33 |
-
return ""
|
| 34 |
-
|
| 35 |
-
text = text.strip()
|
| 36 |
-
|
| 37 |
-
# Remove markdown bullets
|
| 38 |
-
text = re.sub(r"^\s*[-*•]+\s*", "", text)
|
| 39 |
-
text = re.sub(r"^\s*[-–—•*]+\s*", "", text)
|
| 40 |
-
|
| 41 |
-
# Remove numbered lists
|
| 42 |
-
text = re.sub(r"^\s*\d+[.)]\s*", "", text)
|
| 43 |
-
|
| 44 |
-
# Remove lettered lists
|
| 45 |
-
text = re.sub(r"^\s*[a-zA-Z][.)]\s*", "", text)
|
| 46 |
-
|
| 47 |
-
# Remove surrounding quotes
|
| 48 |
-
text = text.strip("'\"`")
|
| 49 |
-
|
| 50 |
-
# Collapse whitespace
|
| 51 |
-
text = re.sub(r"\s+", " ", text)
|
| 52 |
-
|
| 53 |
-
return text
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def looks_like_headline(text: str) -> bool:
|
| 57 |
-
if not text:
|
| 58 |
-
return False
|
| 59 |
-
|
| 60 |
-
if len(text) < 20:
|
| 61 |
-
return False
|
| 62 |
-
|
| 63 |
-
if len(text) > 250:
|
| 64 |
-
return False
|
| 65 |
-
|
| 66 |
-
if text.count(" ") < 2:
|
| 67 |
-
return False
|
| 68 |
-
|
| 69 |
-
blacklist = [
|
| 70 |
-
"click here",
|
| 71 |
-
"read more",
|
| 72 |
-
"subscribe",
|
| 73 |
-
"advertisement",
|
| 74 |
-
"cookie policy",
|
| 75 |
-
"sign in",
|
| 76 |
-
]
|
| 77 |
-
|
| 78 |
-
lower = text.lower()
|
| 79 |
-
|
| 80 |
-
if any(x in lower for x in blacklist):
|
| 81 |
-
return False
|
| 82 |
-
|
| 83 |
-
return True
|
| 84 |
|
| 85 |
|
| 86 |
# =========================
|
|
@@ -102,33 +41,39 @@ def migrate_schema():
|
|
| 102 |
return
|
| 103 |
|
| 104 |
needed = {
|
|
|
|
|
|
|
|
|
|
| 105 |
"news_sources": [
|
| 106 |
("last_attempted_at", "INTEGER"),
|
| 107 |
("failure_count", "INTEGER NOT NULL DEFAULT 0"),
|
| 108 |
("failure_reason", "TEXT"),
|
| 109 |
("type", "TEXT"),
|
| 110 |
],
|
| 111 |
-
"
|
| 112 |
-
("
|
| 113 |
-
("
|
| 114 |
-
("
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
("
|
| 118 |
-
("deleted_at", "INTEGER"),
|
| 119 |
],
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
("
|
| 124 |
-
("
|
| 125 |
-
("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
("created_at", "INTEGER"),
|
| 127 |
("updated_at", "INTEGER"),
|
| 128 |
],
|
| 129 |
-
"sentiment": [
|
| 130 |
-
("headline_id", "TEXT"),
|
| 131 |
-
],
|
| 132 |
}
|
| 133 |
|
| 134 |
added = 0
|
|
@@ -151,7 +96,24 @@ def migrate_schema():
|
|
| 151 |
|
| 152 |
|
| 153 |
# =========================
|
| 154 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
# =========================
|
| 156 |
|
| 157 |
|
|
@@ -203,62 +165,171 @@ def load_sources_to_db():
|
|
| 203 |
|
| 204 |
|
| 205 |
# =========================
|
| 206 |
-
# LOAD SOURCES
|
| 207 |
# =========================
|
| 208 |
|
| 209 |
|
| 210 |
def load_stocks_to_db():
|
|
|
|
| 211 |
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
db = SessionLocal()
|
| 215 |
|
| 216 |
try:
|
| 217 |
|
| 218 |
-
stocks_df = pd.read_csv(STOCK_PATH)
|
| 219 |
-
|
| 220 |
inserted = 0
|
| 221 |
|
| 222 |
-
for
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
-
|
| 227 |
|
| 228 |
-
|
| 229 |
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
existing = (
|
| 235 |
-
db.query(
|
|
|
|
|
|
|
| 236 |
)
|
| 237 |
|
| 238 |
if existing:
|
|
|
|
|
|
|
|
|
|
| 239 |
continue
|
| 240 |
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
| 247 |
)
|
| 248 |
|
| 249 |
-
db.add(stock)
|
| 250 |
-
|
| 251 |
inserted += 1
|
| 252 |
|
| 253 |
db.commit()
|
| 254 |
|
| 255 |
-
logger.info(f"
|
| 256 |
|
| 257 |
except Exception:
|
| 258 |
|
| 259 |
db.rollback()
|
| 260 |
|
| 261 |
-
logger.exception("
|
| 262 |
|
| 263 |
raise
|
| 264 |
|
|
@@ -268,7 +339,7 @@ def load_stocks_to_db():
|
|
| 268 |
|
| 269 |
|
| 270 |
# =========================
|
| 271 |
-
# ADD ONE SOURCE
|
| 272 |
# =========================
|
| 273 |
|
| 274 |
|
|
@@ -304,7 +375,7 @@ def add_source(name: str, website: str):
|
|
| 304 |
),
|
| 305 |
}
|
| 306 |
|
| 307 |
-
news_source = NewsSource(name=name, website=website)
|
| 308 |
db.add(news_source)
|
| 309 |
db.commit()
|
| 310 |
|
|
@@ -324,7 +395,7 @@ def add_source(name: str, website: str):
|
|
| 324 |
|
| 325 |
|
| 326 |
# =========================
|
| 327 |
-
# READ SOURCES
|
| 328 |
# =========================
|
| 329 |
|
| 330 |
|
|
@@ -339,7 +410,7 @@ def read_sources(filter_option: str):
|
|
| 339 |
|
| 340 |
|
| 341 |
# =========================
|
| 342 |
-
# UPDATE last_fetched_at
|
| 343 |
# =========================
|
| 344 |
|
| 345 |
|
|
@@ -367,7 +438,7 @@ def update_last_fetched(source_id: str):
|
|
| 367 |
|
| 368 |
|
| 369 |
# =========================
|
| 370 |
-
# RECORD FAILURE
|
| 371 |
# =========================
|
| 372 |
|
| 373 |
|
|
@@ -406,650 +477,167 @@ def record_failure(source_id: str, reason: str):
|
|
| 406 |
# =========================
|
| 407 |
|
| 408 |
|
| 409 |
-
def store_raw_html(source: str, html: str, type: str):
|
| 410 |
-
|
| 411 |
-
try:
|
| 412 |
-
soup = BeautifulSoup(html, "lxml")
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
|
| 419 |
-
|
| 420 |
-
|
|
|
|
| 421 |
db.add(entry)
|
| 422 |
db.commit()
|
| 423 |
# entry.id is populated by the model's uuid default before INSERT,
|
| 424 |
# so no refresh is needed.
|
| 425 |
logger.info(
|
| 426 |
f"store_raw_html: inserted {entry.id} "
|
| 427 |
-
f"(source={source}, {len(
|
| 428 |
)
|
| 429 |
return entry.id
|
| 430 |
finally:
|
| 431 |
db.close()
|
| 432 |
|
| 433 |
|
| 434 |
-
def
|
| 435 |
-
|
| 436 |
-
try:
|
| 437 |
-
base_query = db.query(RawHTML).filter(
|
| 438 |
-
RawHTML.parsed.is_(False),
|
| 439 |
-
RawHTML.type.is_(type),
|
| 440 |
-
)
|
| 441 |
-
|
| 442 |
-
total = base_query.count()
|
| 443 |
-
logger.info(f"get_unparsed_record: found {total} unparsed article(s)")
|
| 444 |
-
|
| 445 |
-
record = base_query.order_by(RawHTML.created_at).first()
|
| 446 |
-
|
| 447 |
-
if record is None:
|
| 448 |
-
logger.info("get_unparsed_record: no unparsed rows")
|
| 449 |
-
else:
|
| 450 |
-
logger.info(
|
| 451 |
-
f"get_unparsed_record: returning {record.id} "
|
| 452 |
-
f"(source={record.source})"
|
| 453 |
-
)
|
| 454 |
-
return record
|
| 455 |
-
finally:
|
| 456 |
-
db.close()
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
def mark_as_parsed(record_id: str):
|
| 460 |
-
db = SessionLocal()
|
| 461 |
-
try:
|
| 462 |
-
record = db.query(RawHTML).filter(RawHTML.id == record_id).first()
|
| 463 |
-
if record:
|
| 464 |
-
record.parsed = True
|
| 465 |
-
db.commit()
|
| 466 |
-
logger.info(f"mark_as_parsed: {record_id} -> parsed=True")
|
| 467 |
-
else:
|
| 468 |
-
logger.warning(f"mark_as_parsed: {record_id} not found")
|
| 469 |
-
finally:
|
| 470 |
-
db.close()
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
# =========================
|
| 474 |
-
# PARSED ARTICLES
|
| 475 |
-
# =========================
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
def store_headlines(raw_html_id: str, headlines: list):
|
| 479 |
-
db = SessionLocal()
|
| 480 |
-
inserted_count = 0 # Track how many were actually inserted
|
| 481 |
-
|
| 482 |
-
try:
|
| 483 |
-
for headline in headlines:
|
| 484 |
-
clean_headlines = clean_headline_text(headline)
|
| 485 |
-
|
| 486 |
-
# If it doesn't look like a headline, skip it immediately (Guard Clause)
|
| 487 |
-
if not looks_like_headline(clean_headlines):
|
| 488 |
-
continue
|
| 489 |
-
|
| 490 |
-
article = ParsedArticle(
|
| 491 |
-
raw_html_id=raw_html_id,
|
| 492 |
-
title=clean_headlines, # Reused the variable instead of calling the function again
|
| 493 |
-
sentiment=None,
|
| 494 |
-
category=None,
|
| 495 |
-
analyzed=False,
|
| 496 |
-
)
|
| 497 |
-
db.add(article)
|
| 498 |
-
inserted_count += 1
|
| 499 |
-
|
| 500 |
-
# Commit everything in ONE batch after the loop finishes successfully
|
| 501 |
-
db.commit()
|
| 502 |
-
|
| 503 |
-
logger.info(
|
| 504 |
-
f"store_headlines: successfully inserted {inserted_count} of "
|
| 505 |
-
f"{len(headlines)} headlines for raw_html {raw_html_id}"
|
| 506 |
-
)
|
| 507 |
-
|
| 508 |
-
except Exception as e:
|
| 509 |
-
db.rollback() # Undo pending changes if an error occurs
|
| 510 |
-
logger.error(f"Failed to store headlines for raw_html {raw_html_id}: {e}")
|
| 511 |
-
raise
|
| 512 |
-
|
| 513 |
-
finally:
|
| 514 |
-
db.close()
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
def get_unanalyzed_article():
|
| 518 |
-
db = SessionLocal()
|
| 519 |
-
try:
|
| 520 |
-
article = (
|
| 521 |
-
db.query(ParsedArticle)
|
| 522 |
-
.filter(
|
| 523 |
-
ParsedArticle.analyzed.is_(False), ParsedArticle.is_deleted.is_(False)
|
| 524 |
-
)
|
| 525 |
-
.order_by(ParsedArticle.created_at)
|
| 526 |
-
.first()
|
| 527 |
-
)
|
| 528 |
-
if article is None:
|
| 529 |
-
logger.info("get_unanalyzed_article: no unanalyzed rows")
|
| 530 |
-
else:
|
| 531 |
-
preview = (article.title or "")[:60]
|
| 532 |
-
logger.info(
|
| 533 |
-
f"get_unanalyzed_article: returning {article.id} " f"title={preview!r}"
|
| 534 |
-
)
|
| 535 |
-
return article
|
| 536 |
-
finally:
|
| 537 |
-
db.close()
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
def get_analyzed_articles():
|
| 541 |
-
"""Return all analyzed, non-soft-deleted articles as a list of dicts."""
|
| 542 |
-
|
| 543 |
-
db = SessionLocal()
|
| 544 |
-
try:
|
| 545 |
-
rows = (
|
| 546 |
-
db.query(ParsedArticle)
|
| 547 |
-
.filter(
|
| 548 |
-
ParsedArticle.analyzed.is_(True),
|
| 549 |
-
ParsedArticle.is_deleted.is_(False),
|
| 550 |
-
)
|
| 551 |
-
.order_by(ParsedArticle.created_at)
|
| 552 |
-
.all()
|
| 553 |
-
)
|
| 554 |
-
|
| 555 |
-
articles = [
|
| 556 |
-
{
|
| 557 |
-
"id": r.id,
|
| 558 |
-
"raw_html_id": r.raw_html_id,
|
| 559 |
-
"title": r.title,
|
| 560 |
-
"sentiment": r.sentiment,
|
| 561 |
-
"category": r.category,
|
| 562 |
-
"created_at": r.created_at,
|
| 563 |
-
}
|
| 564 |
-
for r in rows
|
| 565 |
-
]
|
| 566 |
-
|
| 567 |
-
logger.info(f"get_analyzed_articles: returning {len(articles)} rows")
|
| 568 |
-
return articles
|
| 569 |
-
finally:
|
| 570 |
-
db.close()
|
| 571 |
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
Used by the cleaning_headlines worker, so it excludes rows it has already
|
| 577 |
-
turned into clean_headlines (is_cleaned=True).
|
| 578 |
"""
|
| 579 |
-
db = SessionLocal()
|
| 580 |
-
try:
|
| 581 |
-
article = (
|
| 582 |
-
db.query(ParsedArticle)
|
| 583 |
-
.filter(
|
| 584 |
-
ParsedArticle.analyzed.is_(True),
|
| 585 |
-
ParsedArticle.is_cleaned.is_(False),
|
| 586 |
-
ParsedArticle.is_deleted.is_(False),
|
| 587 |
-
ParsedArticle.category.is_("business"),
|
| 588 |
-
)
|
| 589 |
-
.order_by(ParsedArticle.created_at)
|
| 590 |
-
.first()
|
| 591 |
-
)
|
| 592 |
-
|
| 593 |
-
if article:
|
| 594 |
-
logger.info(f"get_analyzed_article found: {article.id}")
|
| 595 |
-
return article
|
| 596 |
-
else:
|
| 597 |
-
logger.info("get_analyzed_article: no uncleaned analyzed article found")
|
| 598 |
-
finally:
|
| 599 |
-
db.close()
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
def mark_article_cleaned(article_id: str):
|
| 603 |
-
"""Flag an article as dataset-cleaned so the cleanup worker can retire it."""
|
| 604 |
|
| 605 |
db = SessionLocal()
|
| 606 |
try:
|
| 607 |
-
|
| 608 |
-
if article:
|
| 609 |
-
article.is_cleaned = True
|
| 610 |
-
db.commit()
|
| 611 |
-
logger.info(f"mark_article_cleaned: {article_id} -> is_cleaned=True")
|
| 612 |
-
else:
|
| 613 |
-
logger.warning(f"mark_article_cleaned: {article_id} not found")
|
| 614 |
-
finally:
|
| 615 |
-
db.close()
|
| 616 |
|
|
|
|
|
|
|
|
|
|
| 617 |
|
| 618 |
-
|
| 619 |
-
db = SessionLocal()
|
| 620 |
-
try:
|
| 621 |
-
article = db.query(ParsedArticle).filter(ParsedArticle.id == article_id).first()
|
| 622 |
-
if article:
|
| 623 |
-
article.sentiment = sentiment.lower()
|
| 624 |
-
article.category = category.lower()
|
| 625 |
-
article.analyzed = True
|
| 626 |
-
db.commit()
|
| 627 |
-
logger.info(
|
| 628 |
-
f"update_article_analysis: {article_id} "
|
| 629 |
-
f"sentiment={sentiment} category={category}"
|
| 630 |
-
)
|
| 631 |
-
else:
|
| 632 |
-
logger.warning(f"update_article_analysis: {article_id} not found")
|
| 633 |
-
finally:
|
| 634 |
-
db.close()
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
def soft_delete_invalid_headlines(article_id: str):
|
| 638 |
-
db = SessionLocal()
|
| 639 |
-
try:
|
| 640 |
-
article = db.query(ParsedArticle).filter(ParsedArticle.id == article_id).first()
|
| 641 |
-
if article:
|
| 642 |
-
article.analyzed = True
|
| 643 |
-
article.is_deleted = True
|
| 644 |
-
db.commit()
|
| 645 |
-
logger.info(f"soft deleted article: {article_id} - Invalid headline")
|
| 646 |
-
else:
|
| 647 |
-
logger.warning(f"soft deleted article: {article_id} not found")
|
| 648 |
-
finally:
|
| 649 |
-
db.close()
|
| 650 |
-
|
| 651 |
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
def store_sentiment(
|
| 658 |
-
company: str,
|
| 659 |
-
sentiment_score: float,
|
| 660 |
-
confidence: float,
|
| 661 |
-
sector: str,
|
| 662 |
-
headline_id: str,
|
| 663 |
-
):
|
| 664 |
-
"""Insert one row into the sentiment results table."""
|
| 665 |
|
| 666 |
-
db = SessionLocal()
|
| 667 |
-
try:
|
| 668 |
-
entry = Sentiment(
|
| 669 |
-
company=company,
|
| 670 |
-
sentiment_score=sentiment_score,
|
| 671 |
-
confidence=confidence,
|
| 672 |
-
sector=sector,
|
| 673 |
-
headline_id=headline_id,
|
| 674 |
-
)
|
| 675 |
-
db.add(entry)
|
| 676 |
-
db.commit()
|
| 677 |
logger.info(
|
| 678 |
-
f"
|
| 679 |
-
f"(
|
| 680 |
-
f"confidence={confidence:.4f})"
|
| 681 |
)
|
| 682 |
-
return entry.id
|
| 683 |
-
finally:
|
| 684 |
-
db.close()
|
| 685 |
-
|
| 686 |
|
| 687 |
-
def get_stats():
|
| 688 |
-
db = SessionLocal()
|
| 689 |
-
try:
|
| 690 |
return {
|
| 691 |
-
"
|
| 692 |
-
|
| 693 |
-
.
|
| 694 |
-
"
|
| 695 |
-
|
| 696 |
-
.
|
| 697 |
-
"articles_analyzed": db.query(ParsedArticle)
|
| 698 |
-
.filter(ParsedArticle.analyzed == True)
|
| 699 |
-
.count(),
|
| 700 |
-
"false_headlines_detected": db.query(ParsedArticle)
|
| 701 |
-
.filter(
|
| 702 |
-
ParsedArticle.analyzed.is_(True),
|
| 703 |
-
ParsedArticle.is_cleaned.is_(True),
|
| 704 |
-
ParsedArticle.sentiment.is_(None),
|
| 705 |
-
)
|
| 706 |
-
.count(),
|
| 707 |
-
"headlines_cleaned": db.query(ParsedArticle)
|
| 708 |
-
.filter(
|
| 709 |
-
ParsedArticle.analyzed.is_(True),
|
| 710 |
-
ParsedArticle.is_cleaned.is_(True),
|
| 711 |
-
ParsedArticle.sentiment.is_not(None),
|
| 712 |
-
)
|
| 713 |
-
.count(),
|
| 714 |
}
|
| 715 |
finally:
|
| 716 |
db.close()
|
| 717 |
|
| 718 |
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
# =========================
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
def _weighted_sector_map(db, cutoff_ms: int) -> dict:
|
| 725 |
-
"""Confidence-weighted sentiment per sector for rows created since cutoff_ms.
|
| 726 |
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
"""
|
| 730 |
-
from llm import is_valid_sector
|
| 731 |
-
|
| 732 |
-
rows = (
|
| 733 |
-
db.query(
|
| 734 |
-
Sentiment.sector,
|
| 735 |
-
func.sum(Sentiment.sentiment_score * Sentiment.confidence),
|
| 736 |
-
func.sum(Sentiment.confidence),
|
| 737 |
-
)
|
| 738 |
-
.filter(Sentiment.created_at >= cutoff_ms)
|
| 739 |
-
.group_by(Sentiment.sector)
|
| 740 |
-
.all()
|
| 741 |
-
)
|
| 742 |
-
|
| 743 |
-
result = {}
|
| 744 |
-
for sector, weighted_sum, confidence_sum in rows:
|
| 745 |
-
|
| 746 |
-
if not is_valid_sector(sector):
|
| 747 |
-
continue
|
| 748 |
-
if confidence_sum is None or confidence_sum <= 0:
|
| 749 |
-
continue
|
| 750 |
-
result[sector] = float(weighted_sum) / float(confidence_sum)
|
| 751 |
-
|
| 752 |
-
return result
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
def update_sector_table() -> dict:
|
| 756 |
-
"""Recompute the sector rollup from the sentiment table and upsert one row
|
| 757 |
-
per sector (matched on sector name). Returns {inserted, updated, sectors}.
|
| 758 |
-
"""
|
| 759 |
-
|
| 760 |
-
db = SessionLocal()
|
| 761 |
-
try:
|
| 762 |
-
now_ms = int(time.time() * 1000)
|
| 763 |
-
|
| 764 |
-
today = _weighted_sector_map(db, now_ms - SECTOR_TODAY_WINDOW_MS)
|
| 765 |
-
seven_day = _weighted_sector_map(db, now_ms - SECTOR_7D_WINDOW_MS)
|
| 766 |
-
|
| 767 |
-
logger.info(
|
| 768 |
-
f"update_sector_table: {len(today)} sector(s) active in last 24h, "
|
| 769 |
-
f"{len(seven_day)} in last 7d"
|
| 770 |
-
)
|
| 771 |
-
|
| 772 |
-
inserted = 0
|
| 773 |
-
updated = 0
|
| 774 |
-
|
| 775 |
-
for sector, today_value in today.items():
|
| 776 |
-
avg_7d = seven_day.get(sector, today_value)
|
| 777 |
-
momentum = today_value - avg_7d
|
| 778 |
-
|
| 779 |
-
row = db.query(Sector).filter(Sector.sector == sector).first()
|
| 780 |
-
|
| 781 |
-
if row is None:
|
| 782 |
-
row = Sector(sector=sector)
|
| 783 |
-
db.add(row)
|
| 784 |
-
inserted += 1
|
| 785 |
-
else:
|
| 786 |
-
updated += 1
|
| 787 |
-
|
| 788 |
-
row.sector_sentiment = today_value
|
| 789 |
-
row.sector_sentiment_7d_avg = avg_7d
|
| 790 |
-
row.sector_momentum = momentum
|
| 791 |
-
row.is_analyzed = False
|
| 792 |
-
row.updated_at = now_ms
|
| 793 |
-
|
| 794 |
-
db.commit()
|
| 795 |
-
|
| 796 |
-
logger.info(
|
| 797 |
-
f"update_sector_table: done (inserted={inserted}, updated={updated})"
|
| 798 |
-
)
|
| 799 |
-
return {
|
| 800 |
-
"inserted": inserted,
|
| 801 |
-
"updated": updated,
|
| 802 |
-
"sectors": sorted(today.keys()),
|
| 803 |
-
}
|
| 804 |
-
|
| 805 |
-
except Exception:
|
| 806 |
-
db.rollback()
|
| 807 |
-
logger.exception("update_sector_table failed")
|
| 808 |
-
raise
|
| 809 |
-
finally:
|
| 810 |
-
db.close()
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
def mark_clean_headlines_past_7d() -> int:
|
| 814 |
-
"""Flag analyzed headlines whose sentiment has aged out of the sector
|
| 815 |
-
7-day window (created_at older than SECTOR_7D_WINDOW_MS).
|
| 816 |
|
| 817 |
-
Once past the window the row has been fully counted into the last sector
|
| 818 |
-
7d-average and will not be read again, so set has_been_read_sector=True.
|
| 819 |
-
Returns the number of rows newly flagged.
|
| 820 |
-
"""
|
| 821 |
db = SessionLocal()
|
| 822 |
try:
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
db.query(Sentiment)
|
| 828 |
-
.filter(
|
| 829 |
-
ParsedArticle.created_at < cutoff_ms,
|
| 830 |
-
Sentiment.has_been_read_sector.is_(False),
|
| 831 |
-
)
|
| 832 |
-
.update({Sentiment.has_been_read_sector: True}, synchronize_session=False)
|
| 833 |
-
)
|
| 834 |
|
| 835 |
-
|
| 836 |
db.commit()
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
return count
|
| 840 |
-
|
| 841 |
-
except Exception:
|
| 842 |
-
db.rollback()
|
| 843 |
-
logger.exception("mark_clean_headlines_past_7d execution failed")
|
| 844 |
-
raise
|
| 845 |
finally:
|
| 846 |
db.close()
|
| 847 |
|
| 848 |
|
| 849 |
-
|
| 850 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 851 |
|
| 852 |
-
db = SessionLocal()
|
| 853 |
-
try:
|
| 854 |
-
rows = db.query(Sector).order_by(Sector.sector_momentum.desc()).all()
|
| 855 |
-
|
| 856 |
-
sectors = [
|
| 857 |
-
{
|
| 858 |
-
"id": r.id,
|
| 859 |
-
"sector": r.sector,
|
| 860 |
-
"sector_sentiment": r.sector_sentiment,
|
| 861 |
-
"sector_sentiment_7d_avg": r.sector_sentiment_7d_avg,
|
| 862 |
-
"sector_momentum": r.sector_momentum,
|
| 863 |
-
"is_analyzed": r.is_analyzed,
|
| 864 |
-
"created_at": r.created_at,
|
| 865 |
-
"updated_at": r.updated_at,
|
| 866 |
-
}
|
| 867 |
-
for r in rows
|
| 868 |
-
]
|
| 869 |
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
finally:
|
| 873 |
-
db.close()
|
| 874 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
|
| 876 |
-
|
|
|
|
|
|
|
|
|
|
| 877 |
|
| 878 |
-
|
| 879 |
|
| 880 |
db = SessionLocal()
|
| 881 |
-
|
| 882 |
try:
|
|
|
|
| 883 |
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
if not source:
|
| 887 |
-
logger.warning(f"unable to find stock source id:{source_id}")
|
| 888 |
-
return
|
| 889 |
-
|
| 890 |
-
now_ms = int(time.time() * 1000)
|
| 891 |
-
|
| 892 |
-
# UTC day boundaries
|
| 893 |
-
today = datetime.now(timezone.utc).date()
|
| 894 |
-
|
| 895 |
-
existing_stock = (
|
| 896 |
-
db.query(Stock)
|
| 897 |
-
.filter(
|
| 898 |
-
Stock.ticker == source.ticker,
|
| 899 |
-
Stock.country == source.country,
|
| 900 |
-
Stock.created_at
|
| 901 |
-
>= datetime.combine(
|
| 902 |
-
today,
|
| 903 |
-
datetime.min.time(),
|
| 904 |
-
tzinfo=timezone.utc,
|
| 905 |
-
),
|
| 906 |
-
)
|
| 907 |
-
.order_by(Stock.created_at.desc())
|
| 908 |
-
.first()
|
| 909 |
-
)
|
| 910 |
-
|
| 911 |
-
if existing_stock:
|
| 912 |
-
|
| 913 |
-
existing_stock.open = data.get("open")
|
| 914 |
-
existing_stock.high = data.get("high")
|
| 915 |
-
existing_stock.low = data.get("low")
|
| 916 |
-
existing_stock.close = data.get("close")
|
| 917 |
-
existing_stock.volume = data.get("volume")
|
| 918 |
-
existing_stock.is_complete = data.get("is_complete")
|
| 919 |
-
existing_stock.raw_html_id = data.get("raw_html_id")
|
| 920 |
-
existing_stock.updated_at = now_ms
|
| 921 |
-
|
| 922 |
-
logger.info(f"updated stock row for ticker:{source.ticker}")
|
| 923 |
-
|
| 924 |
else:
|
|
|
|
| 925 |
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
open=data.get("open"),
|
| 931 |
-
high=data.get("high"),
|
| 932 |
-
low=data.get("low"),
|
| 933 |
-
close=data.get("close"),
|
| 934 |
-
volume=data.get("volume"),
|
| 935 |
-
is_complete=data.get("is_complete", False),
|
| 936 |
-
raw_html_id=data.get("raw_html_id"),
|
| 937 |
-
created_at=datetime.now(timezone.utc),
|
| 938 |
-
updated_at=now_ms,
|
| 939 |
)
|
|
|
|
| 940 |
|
| 941 |
-
|
| 942 |
|
| 943 |
-
|
|
|
|
| 944 |
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
except Exception:
|
| 948 |
-
db.rollback()
|
| 949 |
-
raise
|
| 950 |
|
| 951 |
finally:
|
| 952 |
db.close()
|
| 953 |
|
| 954 |
|
| 955 |
-
def
|
| 956 |
-
|
| 957 |
-
now = datetime.now()
|
| 958 |
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
now.day,
|
| 963 |
-
0,
|
| 964 |
-
0,
|
| 965 |
-
0,
|
| 966 |
-
)
|
| 967 |
-
|
| 968 |
-
end = datetime(
|
| 969 |
-
now.year,
|
| 970 |
-
now.month,
|
| 971 |
-
now.day,
|
| 972 |
-
23,
|
| 973 |
-
59,
|
| 974 |
-
59,
|
| 975 |
-
)
|
| 976 |
-
|
| 977 |
-
return (
|
| 978 |
-
int(start.timestamp() * 1000),
|
| 979 |
-
int(end.timestamp() * 1000),
|
| 980 |
-
)
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
def get_today_stock(
|
| 984 |
-
db,
|
| 985 |
-
ticker: str,
|
| 986 |
-
):
|
| 987 |
-
|
| 988 |
-
ticker = str(ticker).strip().upper()
|
| 989 |
-
|
| 990 |
-
start_ms, end_ms = get_today_bounds()
|
| 991 |
-
|
| 992 |
-
return (
|
| 993 |
-
db.query(Stock)
|
| 994 |
-
.filter(
|
| 995 |
-
Stock.ticker == ticker,
|
| 996 |
-
Stock.created_at >= start_ms,
|
| 997 |
-
Stock.created_at <= end_ms,
|
| 998 |
-
)
|
| 999 |
-
.first()
|
| 1000 |
-
)
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
def get_stock_for_enrichment():
|
| 1004 |
-
|
| 1005 |
-
db = SessionLocal()
|
| 1006 |
-
|
| 1007 |
-
try:
|
| 1008 |
-
six_hours_ago = datetime.utcnow() - timedelta(hours=6)
|
| 1009 |
-
|
| 1010 |
-
stock = (
|
| 1011 |
-
db.query(StockSources)
|
| 1012 |
-
.filter(
|
| 1013 |
-
or_(
|
| 1014 |
-
StockSources.last_fetched_at.is_(None),
|
| 1015 |
-
StockSources.last_fetched_at < six_hours_ago,
|
| 1016 |
-
)
|
| 1017 |
-
)
|
| 1018 |
-
.order_by(StockSources.last_fetched_at.nullsfirst())
|
| 1019 |
-
.first()
|
| 1020 |
-
)
|
| 1021 |
-
|
| 1022 |
-
if stock:
|
| 1023 |
-
db.expunge(stock)
|
| 1024 |
-
|
| 1025 |
-
return stock
|
| 1026 |
-
|
| 1027 |
-
finally:
|
| 1028 |
-
db.close()
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
def update_stock_table(id: str, error: str | None = None):
|
| 1032 |
|
| 1033 |
db = SessionLocal()
|
| 1034 |
|
| 1035 |
try:
|
| 1036 |
|
| 1037 |
-
|
| 1038 |
|
| 1039 |
-
if not
|
| 1040 |
return
|
| 1041 |
|
| 1042 |
now_ms = int(time.time() * 1000)
|
| 1043 |
|
| 1044 |
-
|
| 1045 |
|
| 1046 |
if error is not None:
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
else:
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
|
| 1053 |
|
| 1054 |
db.commit()
|
| 1055 |
|
|
@@ -1061,42 +649,29 @@ def update_stock_table(id: str, error: str | None = None):
|
|
| 1061 |
db.close()
|
| 1062 |
|
| 1063 |
|
| 1064 |
-
|
| 1065 |
-
|
|
|
|
|
|
|
| 1066 |
|
|
|
|
|
|
|
| 1067 |
try:
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
)
|
| 1073 |
-
.outerjoin(
|
| 1074 |
-
ParsedArticle,
|
| 1075 |
-
ParsedArticle.raw_html_id == RawHTML.id,
|
| 1076 |
-
)
|
| 1077 |
-
.filter(RawHTML.parsed.is_(True))
|
| 1078 |
-
.order_by(RawHTML.created_at.desc())
|
| 1079 |
.all()
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
|
| 1085 |
-
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
"created_at": raw_html.created_at,
|
| 1092 |
-
"raw_html": raw_html.html,
|
| 1093 |
-
"headlines": [],
|
| 1094 |
-
}
|
| 1095 |
-
|
| 1096 |
-
if title:
|
| 1097 |
-
result[raw_html.id]["headlines"].append(title)
|
| 1098 |
-
|
| 1099 |
-
return list(result.values())
|
| 1100 |
-
|
| 1101 |
finally:
|
| 1102 |
db.close()
|
|
|
|
| 1 |
import json
|
|
|
|
| 2 |
import time
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
|
| 5 |
from sqlalchemy import or_, text, func
|
| 6 |
from database import engine, SessionLocal
|
| 7 |
import pandas as pd
|
|
|
|
| 8 |
from logger import logger
|
| 9 |
from models import (
|
| 10 |
RawHTML,
|
|
|
|
| 11 |
NewsSource,
|
|
|
|
|
|
|
|
|
|
| 12 |
StockSources,
|
| 13 |
+
MacroSource,
|
| 14 |
)
|
| 15 |
from config import (
|
|
|
|
| 16 |
SOURCES_PATH,
|
| 17 |
MAX_CONSECUTIVE_FAILURES,
|
| 18 |
+
STOCK_PATHS,
|
| 19 |
+
MACRO_SOURCES_PATH,
|
| 20 |
+
MARKET_CONFIG,
|
| 21 |
)
|
| 22 |
+
from utils import get_market_day_start_ms
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
# =========================
|
|
|
|
| 41 |
return
|
| 42 |
|
| 43 |
needed = {
|
| 44 |
+
"raw_html": [
|
| 45 |
+
("country", "TEXT"),
|
| 46 |
+
],
|
| 47 |
"news_sources": [
|
| 48 |
("last_attempted_at", "INTEGER"),
|
| 49 |
("failure_count", "INTEGER NOT NULL DEFAULT 0"),
|
| 50 |
("failure_reason", "TEXT"),
|
| 51 |
("type", "TEXT"),
|
| 52 |
],
|
| 53 |
+
"stock_sources": [
|
| 54 |
+
("url", "TEXT"),
|
| 55 |
+
("last_attempted_at", "INTEGER"),
|
| 56 |
+
("last_fetched_at", "INTEGER"),
|
| 57 |
+
("failure_count", "INTEGER NOT NULL DEFAULT 0"),
|
| 58 |
+
("failure_reason", "TEXT"),
|
| 59 |
+
("updated_at", "INTEGER"),
|
|
|
|
| 60 |
],
|
| 61 |
+
# create_all builds macro_sources fresh; these entries make partial
|
| 62 |
+
# upgrades idempotent.
|
| 63 |
+
"macro_sources": [
|
| 64 |
+
("name", "TEXT"),
|
| 65 |
+
("type", "TEXT"),
|
| 66 |
+
("country", "TEXT"),
|
| 67 |
+
("market", "TEXT"),
|
| 68 |
+
("exchange", "TEXT"),
|
| 69 |
+
("url", "TEXT"),
|
| 70 |
+
("last_fetched_at", "INTEGER"),
|
| 71 |
+
("last_attempted_at", "INTEGER"),
|
| 72 |
+
("failure_count", "INTEGER NOT NULL DEFAULT 0"),
|
| 73 |
+
("failure_reason", "TEXT"),
|
| 74 |
("created_at", "INTEGER"),
|
| 75 |
("updated_at", "INTEGER"),
|
| 76 |
],
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
added = 0
|
|
|
|
| 96 |
|
| 97 |
|
| 98 |
# =========================
|
| 99 |
+
# GOOGLE FINANCE URL
|
| 100 |
+
# =========================
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def build_google_finance_url(ticker: str, exchange: str) -> str:
|
| 104 |
+
"""Construct the Google Finance quote URL for a ticker/exchange pair.
|
| 105 |
+
|
| 106 |
+
Built once at load time and stored on StockSources.url so the stock worker
|
| 107 |
+
doesn't reconstruct it every tick.
|
| 108 |
+
"""
|
| 109 |
+
|
| 110 |
+
ticker = (ticker or "").strip().upper()
|
| 111 |
+
exchange = (exchange or "NSE").strip().upper() or "NSE"
|
| 112 |
+
return f"https://www.google.com/finance/quote/{ticker}:{exchange}?tab=overview"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# =========================
|
| 116 |
+
# LOAD NEWS SOURCES JSON TO DB
|
| 117 |
# =========================
|
| 118 |
|
| 119 |
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
# =========================
|
| 168 |
+
# LOAD STOCK SOURCES CSV TO DB
|
| 169 |
# =========================
|
| 170 |
|
| 171 |
|
| 172 |
def load_stocks_to_db():
|
| 173 |
+
"""Load every market CSV in STOCK_PATHS into stock_sources.
|
| 174 |
|
| 175 |
+
Each row's Google Finance URL is built from its own exchange, so NSE,
|
| 176 |
+
NASDAQ, NYSE, etc. are all handled the same way. Rows are de-duplicated on
|
| 177 |
+
(ticker, exchange) so the same symbol can legitimately exist on two markets.
|
| 178 |
+
"""
|
| 179 |
|
| 180 |
db = SessionLocal()
|
| 181 |
|
| 182 |
try:
|
| 183 |
|
|
|
|
|
|
|
| 184 |
inserted = 0
|
| 185 |
|
| 186 |
+
for path in STOCK_PATHS:
|
| 187 |
+
|
| 188 |
+
logger.info(f"load_stocks_to_db: reading {path}")
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
stocks_df = pd.read_csv(path)
|
| 192 |
+
except FileNotFoundError:
|
| 193 |
+
logger.warning(f"load_stocks_to_db: {path} not found; skipping")
|
| 194 |
+
continue
|
| 195 |
+
|
| 196 |
+
for _, row in stocks_df.iterrows():
|
| 197 |
+
|
| 198 |
+
company_name = str(row["Company Name"]).strip().lower()
|
| 199 |
+
|
| 200 |
+
ticker = str(row["Symbol"]).strip().upper()
|
| 201 |
+
|
| 202 |
+
sector = str(row["Industry"]).strip().upper()
|
| 203 |
+
|
| 204 |
+
country = str(row["Country"]).strip().upper()
|
| 205 |
+
|
| 206 |
+
exchange = str(row["Exchange"]).strip().upper()
|
| 207 |
+
|
| 208 |
+
existing = (
|
| 209 |
+
db.query(StockSources)
|
| 210 |
+
.filter(
|
| 211 |
+
StockSources.ticker == ticker,
|
| 212 |
+
StockSources.exchange == exchange,
|
| 213 |
+
)
|
| 214 |
+
.first()
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
if existing:
|
| 218 |
+
# Backfill the URL on a pre-existing row that predates this column.
|
| 219 |
+
if not existing.url:
|
| 220 |
+
existing.url = build_google_finance_url(ticker, exchange)
|
| 221 |
+
continue
|
| 222 |
+
|
| 223 |
+
stock = StockSources(
|
| 224 |
+
company_name=company_name,
|
| 225 |
+
ticker=ticker,
|
| 226 |
+
sector=sector,
|
| 227 |
+
country=country,
|
| 228 |
+
exchange=exchange,
|
| 229 |
+
url=build_google_finance_url(ticker, exchange),
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
db.add(stock)
|
| 233 |
+
|
| 234 |
+
inserted += 1
|
| 235 |
+
|
| 236 |
+
db.commit()
|
| 237 |
+
|
| 238 |
+
logger.info(f"load_stocks_to_db: inserted " f"{inserted} stocks")
|
| 239 |
+
|
| 240 |
+
except Exception:
|
| 241 |
+
|
| 242 |
+
db.rollback()
|
| 243 |
+
|
| 244 |
+
logger.exception("load_stocks_to_db failed")
|
| 245 |
+
|
| 246 |
+
raise
|
| 247 |
+
|
| 248 |
+
finally:
|
| 249 |
+
|
| 250 |
+
db.close()
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
# =========================
|
| 254 |
+
# LOAD MACRO SOURCES CSV TO DB
|
| 255 |
+
# =========================
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def load_macro_sources_to_db():
|
| 259 |
+
"""Load macro/market series from MACRO_SOURCES_PATH into MacroSource.
|
| 260 |
+
|
| 261 |
+
CSV columns: name, type, country, market, exchange, gf_ticker, url.
|
| 262 |
+
For Google-Finance series leave `url` blank and supply `gf_ticker`+`exchange`
|
| 263 |
+
(the URL is built via build_google_finance_url); for raw pages (e.g. NSE
|
| 264 |
+
FII/DII) supply `url` directly. De-duplicated on (name, type).
|
| 265 |
+
"""
|
| 266 |
+
|
| 267 |
+
logger.info(f"load_macro_sources_to_db: reading {MACRO_SOURCES_PATH}")
|
| 268 |
+
|
| 269 |
+
db = SessionLocal()
|
| 270 |
+
|
| 271 |
+
try:
|
| 272 |
|
| 273 |
+
try:
|
| 274 |
+
df = pd.read_csv(MACRO_SOURCES_PATH).fillna("")
|
| 275 |
+
except FileNotFoundError:
|
| 276 |
+
logger.warning(
|
| 277 |
+
f"load_macro_sources_to_db: {MACRO_SOURCES_PATH} not found; skipping"
|
| 278 |
+
)
|
| 279 |
+
return
|
| 280 |
|
| 281 |
+
inserted = 0
|
| 282 |
|
| 283 |
+
for _, row in df.iterrows():
|
| 284 |
|
| 285 |
+
name = str(row["name"]).strip()
|
| 286 |
+
type_ = str(row["type"]).strip().lower()
|
| 287 |
+
country = str(row.get("country", "")).strip().upper() or None
|
| 288 |
+
market = str(row["market"]).strip().upper()
|
| 289 |
+
exchange = str(row.get("exchange", "")).strip().upper() or None
|
| 290 |
+
gf_ticker = str(row.get("gf_ticker", "")).strip().upper()
|
| 291 |
+
raw_url = str(row.get("url", "")).strip()
|
| 292 |
|
| 293 |
+
# Materialize the fetch URL: explicit url wins; otherwise build a
|
| 294 |
+
# Google Finance quote URL from gf_ticker:exchange.
|
| 295 |
+
url = raw_url or (
|
| 296 |
+
build_google_finance_url(gf_ticker, exchange) if gf_ticker else None
|
| 297 |
+
)
|
| 298 |
|
| 299 |
existing = (
|
| 300 |
+
db.query(MacroSource)
|
| 301 |
+
.filter(MacroSource.name == name, MacroSource.type == type_)
|
| 302 |
+
.first()
|
| 303 |
)
|
| 304 |
|
| 305 |
if existing:
|
| 306 |
+
# Backfill a URL onto a pre-existing row that predates this load.
|
| 307 |
+
if not existing.url and url:
|
| 308 |
+
existing.url = url
|
| 309 |
continue
|
| 310 |
|
| 311 |
+
db.add(
|
| 312 |
+
MacroSource(
|
| 313 |
+
name=name,
|
| 314 |
+
type=type_,
|
| 315 |
+
country=country,
|
| 316 |
+
market=market,
|
| 317 |
+
exchange=exchange,
|
| 318 |
+
url=url,
|
| 319 |
+
)
|
| 320 |
)
|
| 321 |
|
|
|
|
|
|
|
| 322 |
inserted += 1
|
| 323 |
|
| 324 |
db.commit()
|
| 325 |
|
| 326 |
+
logger.info(f"load_macro_sources_to_db: inserted {inserted} macro sources")
|
| 327 |
|
| 328 |
except Exception:
|
| 329 |
|
| 330 |
db.rollback()
|
| 331 |
|
| 332 |
+
logger.exception("load_macro_sources_to_db failed")
|
| 333 |
|
| 334 |
raise
|
| 335 |
|
|
|
|
| 339 |
|
| 340 |
|
| 341 |
# =========================
|
| 342 |
+
# ADD ONE NEWS SOURCE
|
| 343 |
# =========================
|
| 344 |
|
| 345 |
|
|
|
|
| 375 |
),
|
| 376 |
}
|
| 377 |
|
| 378 |
+
news_source = NewsSource(name=name, website=website, type="news")
|
| 379 |
db.add(news_source)
|
| 380 |
db.commit()
|
| 381 |
|
|
|
|
| 395 |
|
| 396 |
|
| 397 |
# =========================
|
| 398 |
+
# READ NEWS SOURCES
|
| 399 |
# =========================
|
| 400 |
|
| 401 |
|
|
|
|
| 410 |
|
| 411 |
|
| 412 |
# =========================
|
| 413 |
+
# UPDATE last_fetched_at (news)
|
| 414 |
# =========================
|
| 415 |
|
| 416 |
|
|
|
|
| 438 |
|
| 439 |
|
| 440 |
# =========================
|
| 441 |
+
# RECORD FAILURE (news)
|
| 442 |
# =========================
|
| 443 |
|
| 444 |
|
|
|
|
| 477 |
# =========================
|
| 478 |
|
| 479 |
|
| 480 |
+
def store_raw_html(source: str, html: str, type: str, country: str | None = None):
|
| 481 |
+
"""Persist a fetched page verbatim (true raw HTML — no stripping).
|
|
|
|
|
|
|
| 482 |
|
| 483 |
+
`country` is set for stock pages (carried over from the StockSources row) and
|
| 484 |
+
left NULL for news. The LLM service does its own BeautifulSoup cleaning when
|
| 485 |
+
it parses.
|
| 486 |
+
"""
|
| 487 |
|
| 488 |
+
db = SessionLocal()
|
| 489 |
+
try:
|
| 490 |
+
entry = RawHTML(source=source, html=html, type=type, country=country)
|
| 491 |
db.add(entry)
|
| 492 |
db.commit()
|
| 493 |
# entry.id is populated by the model's uuid default before INSERT,
|
| 494 |
# so no refresh is needed.
|
| 495 |
logger.info(
|
| 496 |
f"store_raw_html: inserted {entry.id} "
|
| 497 |
+
f"(source={source}, country={country}, {len(html)} chars)"
|
| 498 |
)
|
| 499 |
return entry.id
|
| 500 |
finally:
|
| 501 |
db.close()
|
| 502 |
|
| 503 |
|
| 504 |
+
def get_one_raw_html(type: str | None = None):
|
| 505 |
+
"""Return the oldest raw_html row as a dict (or None).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
|
| 507 |
+
Optional `type` filter ("news" / "stock"). Single-consumer + delete-after-
|
| 508 |
+
ingest means no locking is needed — the consumer deletes the row right after
|
| 509 |
+
storing its own copy.
|
|
|
|
|
|
|
|
|
|
| 510 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
|
| 512 |
db = SessionLocal()
|
| 513 |
try:
|
| 514 |
+
query = db.query(RawHTML)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
+
if type:
|
| 517 |
+
normalized = str(type).strip().lower()
|
| 518 |
+
query = query.filter(RawHTML.type == normalized)
|
| 519 |
|
| 520 |
+
record = query.order_by(RawHTML.created_at).first()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
+
if record is None:
|
| 523 |
+
logger.info(f"get_one_raw_html: nothing available (type={type!r})")
|
| 524 |
+
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
logger.info(
|
| 527 |
+
f"get_one_raw_html: returning {record.id} "
|
| 528 |
+
f"(source={record.source}, type={record.type})"
|
|
|
|
| 529 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
|
|
|
|
|
|
|
|
|
|
| 531 |
return {
|
| 532 |
+
"id": record.id,
|
| 533 |
+
"source": record.source,
|
| 534 |
+
"type": record.type,
|
| 535 |
+
"country": record.country,
|
| 536 |
+
"created_at": record.created_at,
|
| 537 |
+
"html": record.html,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
}
|
| 539 |
finally:
|
| 540 |
db.close()
|
| 541 |
|
| 542 |
|
| 543 |
+
def delete_raw_html(record_id: str) -> bool:
|
| 544 |
+
"""Hard-delete a raw_html row by id. Returns True if a row was removed."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
+
if not record_id:
|
| 547 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
db = SessionLocal()
|
| 550 |
try:
|
| 551 |
+
record = db.query(RawHTML).filter(RawHTML.id == record_id).first()
|
| 552 |
+
if not record:
|
| 553 |
+
logger.warning(f"delete_raw_html: {record_id} not found")
|
| 554 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
+
db.delete(record)
|
| 557 |
db.commit()
|
| 558 |
+
logger.info(f"delete_raw_html: deleted {record_id}")
|
| 559 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
finally:
|
| 561 |
db.close()
|
| 562 |
|
| 563 |
|
| 564 |
+
# =========================
|
| 565 |
+
# SOURCE FETCH SCHEDULING (stock + macro)
|
| 566 |
+
# =========================
|
| 567 |
+
# Both StockSources and MacroSource share the "fetch once per market day" shape,
|
| 568 |
+
# so these helpers are generic over the ORM model. A market closes once a day;
|
| 569 |
+
# rows whose last_fetched_at falls within the current market day are skipped.
|
| 570 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 571 |
|
| 572 |
+
def get_sources_due_for_market(model, market: str):
|
| 573 |
+
"""Return rows of `model` belonging to `market` not yet fetched today.
|
|
|
|
|
|
|
| 574 |
|
| 575 |
+
StockSources is grouped by `exchange` (from MARKET_CONFIG); MacroSource
|
| 576 |
+
carries an explicit `market` column. Rows are detached (expunged) so callers
|
| 577 |
+
can read attributes after the session closes.
|
| 578 |
+
"""
|
| 579 |
|
| 580 |
+
cfg = MARKET_CONFIG.get(market)
|
| 581 |
+
if not cfg:
|
| 582 |
+
logger.warning(f"get_sources_due_for_market: unknown market {market!r}")
|
| 583 |
+
return []
|
| 584 |
|
| 585 |
+
day_start_ms = get_market_day_start_ms(market)
|
| 586 |
|
| 587 |
db = SessionLocal()
|
|
|
|
| 588 |
try:
|
| 589 |
+
query = db.query(model)
|
| 590 |
|
| 591 |
+
if hasattr(model, "market"):
|
| 592 |
+
query = query.filter(model.market == market)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
else:
|
| 594 |
+
query = query.filter(model.exchange.in_(cfg["exchanges"]))
|
| 595 |
|
| 596 |
+
query = query.filter(
|
| 597 |
+
or_(
|
| 598 |
+
model.last_fetched_at.is_(None),
|
| 599 |
+
model.last_fetched_at < day_start_ms,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
)
|
| 601 |
+
).order_by(model.last_fetched_at.nullsfirst())
|
| 602 |
|
| 603 |
+
rows = query.all()
|
| 604 |
|
| 605 |
+
for row in rows:
|
| 606 |
+
db.expunge(row)
|
| 607 |
|
| 608 |
+
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
|
| 610 |
finally:
|
| 611 |
db.close()
|
| 612 |
|
| 613 |
|
| 614 |
+
def update_source_table(model, id: str, error: str | None = None):
|
| 615 |
+
"""Stamp a fetch attempt on a stock/macro source row.
|
|
|
|
| 616 |
|
| 617 |
+
On success clears failure state and stamps last_fetched_at; on error bumps
|
| 618 |
+
the failure counter. Generic over StockSources / MacroSource.
|
| 619 |
+
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
|
| 621 |
db = SessionLocal()
|
| 622 |
|
| 623 |
try:
|
| 624 |
|
| 625 |
+
row = db.query(model).filter(model.id == id).first()
|
| 626 |
|
| 627 |
+
if not row:
|
| 628 |
return
|
| 629 |
|
| 630 |
now_ms = int(time.time() * 1000)
|
| 631 |
|
| 632 |
+
row.last_attempted_at = now_ms
|
| 633 |
|
| 634 |
if error is not None:
|
| 635 |
+
row.failure_reason = error
|
| 636 |
+
row.failure_count = (row.failure_count or 0) + 1
|
| 637 |
else:
|
| 638 |
+
row.last_fetched_at = now_ms
|
| 639 |
+
row.failure_reason = None
|
| 640 |
+
row.failure_count = 0
|
| 641 |
|
| 642 |
db.commit()
|
| 643 |
|
|
|
|
| 649 |
db.close()
|
| 650 |
|
| 651 |
|
| 652 |
+
# =========================
|
| 653 |
+
# STATS
|
| 654 |
+
# =========================
|
| 655 |
+
|
| 656 |
|
| 657 |
+
def get_stats():
|
| 658 |
+
db = SessionLocal()
|
| 659 |
try:
|
| 660 |
+
by_type = {
|
| 661 |
+
t: c
|
| 662 |
+
for t, c in db.query(RawHTML.type, func.count(RawHTML.id))
|
| 663 |
+
.group_by(RawHTML.type)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
.all()
|
| 665 |
+
}
|
| 666 |
+
return {
|
| 667 |
+
# raw_html waiting to be picked up by the LLM service, total + split
|
| 668 |
+
# by type (news / stock / macro families).
|
| 669 |
+
"raw_html_total": sum(by_type.values()),
|
| 670 |
+
"raw_html_by_type": by_type,
|
| 671 |
+
# configured source counts
|
| 672 |
+
"news_sources": db.query(NewsSource).count(),
|
| 673 |
+
"stock_sources": db.query(StockSources).count(),
|
| 674 |
+
"macro_sources": db.query(MacroSource).count(),
|
| 675 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
finally:
|
| 677 |
db.close()
|
llm.py
DELETED
|
@@ -1,1087 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
from threading import Lock
|
| 3 |
-
import json
|
| 4 |
-
import re
|
| 5 |
-
import time
|
| 6 |
-
from transformers import (
|
| 7 |
-
AutoTokenizer,
|
| 8 |
-
AutoModelForCausalLM,
|
| 9 |
-
AutoModelForSequenceClassification,
|
| 10 |
-
)
|
| 11 |
-
import torch
|
| 12 |
-
import numpy as np
|
| 13 |
-
import pandas as pd
|
| 14 |
-
import onnxruntime as ort
|
| 15 |
-
from bs4 import BeautifulSoup
|
| 16 |
-
import spacy
|
| 17 |
-
|
| 18 |
-
from database import SessionLocal
|
| 19 |
-
from db_helpers import clean_headline_text
|
| 20 |
-
from logger import logger
|
| 21 |
-
from config import (
|
| 22 |
-
MODEL_PATH,
|
| 23 |
-
MODEL_REPO,
|
| 24 |
-
MODEL_SENTIMENT_OWN_MAX_LEN,
|
| 25 |
-
)
|
| 26 |
-
from models import StockSources
|
| 27 |
-
from utils import _normalize_na
|
| 28 |
-
|
| 29 |
-
# =========================
|
| 30 |
-
# PIPELINE LOCK
|
| 31 |
-
# =========================
|
| 32 |
-
|
| 33 |
-
# Serializes all LLM inference calls so headline_worker and sentiment_worker
|
| 34 |
-
# can't fight over the same in-process pipeline (transformers' generation
|
| 35 |
-
# state is not re-entrant on a single device). Removing this lock without
|
| 36 |
-
# moving to a request-queue / multi-GPU setup will produce CUDA OOMs or
|
| 37 |
-
# corrupted outputs.
|
| 38 |
-
pipeline_lock = Lock()
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
# =========================
|
| 42 |
-
# MODEL STATE
|
| 43 |
-
# =========================
|
| 44 |
-
|
| 45 |
-
# Our own sentiment model: an ONNX Runtime session + its tokenizer. Loaded once
|
| 46 |
-
# by load_models(); both stay None if the artifacts are missing (predict falls
|
| 47 |
-
# back to pure-neutral).
|
| 48 |
-
_nlp = None
|
| 49 |
-
_ner = None
|
| 50 |
-
_llm_model = None
|
| 51 |
-
_llm_tokenizer = None
|
| 52 |
-
_own_sentiment_model = None
|
| 53 |
-
_own_sentiment_tokenizer = None
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def load_models():
|
| 57 |
-
"""Load spaCy + transformers + ONNX sentiment model (idempotent)."""
|
| 58 |
-
|
| 59 |
-
global _nlp, _ner, _llm_model, _llm_tokenizer, _own_sentiment_model, _own_sentiment_tokenizer
|
| 60 |
-
|
| 61 |
-
# ---------------- SPA CY (base pipeline) ----------------
|
| 62 |
-
if _nlp is None:
|
| 63 |
-
logger.info("load_models: loading spaCy base pipeline")
|
| 64 |
-
_nlp = spacy.load("en_core_web_sm", disable=["ner", "tagger", "lemmatizer"])
|
| 65 |
-
_nlp.max_length = 2000000
|
| 66 |
-
logger.info("load_models: spaCy ready")
|
| 67 |
-
else:
|
| 68 |
-
logger.info("load_models: spaCy already loaded")
|
| 69 |
-
|
| 70 |
-
# ---------------- NER (full model) ----------------
|
| 71 |
-
if _ner is None:
|
| 72 |
-
logger.info("load_models: loading spaCy NER model")
|
| 73 |
-
_ner = spacy.load("en_core_web_sm")
|
| 74 |
-
logger.info("load_models: NER ready")
|
| 75 |
-
else:
|
| 76 |
-
logger.info("load_models: NER already loaded")
|
| 77 |
-
|
| 78 |
-
# ---------------- TRANSFORMERS LLM (Qwen) ----------------
|
| 79 |
-
if _llm_model is None:
|
| 80 |
-
try:
|
| 81 |
-
logger.info("load_models: loading Qwen model")
|
| 82 |
-
|
| 83 |
-
model_id = MODEL_PATH # or "Qwen/Qwen2.5-3B"
|
| 84 |
-
|
| 85 |
-
_llm_tokenizer = AutoTokenizer.from_pretrained(model_id)
|
| 86 |
-
|
| 87 |
-
_llm_model = AutoModelForCausalLM.from_pretrained(
|
| 88 |
-
model_id,
|
| 89 |
-
device_map="auto",
|
| 90 |
-
torch_dtype=(
|
| 91 |
-
torch.float16 if torch.cuda.is_available() else torch.float32
|
| 92 |
-
),
|
| 93 |
-
low_cpu_mem_usage=True,
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
logger.info("load_models: transformers LLM ready")
|
| 97 |
-
|
| 98 |
-
except Exception:
|
| 99 |
-
_llm_model = None
|
| 100 |
-
_llm_tokenizer = None
|
| 101 |
-
logger.exception("load_models: failed to load transformers model")
|
| 102 |
-
else:
|
| 103 |
-
logger.info("load_models: transformers already loaded")
|
| 104 |
-
|
| 105 |
-
# ---------------- SENTIMENT (TRANSFORMERS) ----------------
|
| 106 |
-
if _own_sentiment_model is None:
|
| 107 |
-
try:
|
| 108 |
-
logger.info("load_models: loading HF sentiment model")
|
| 109 |
-
|
| 110 |
-
_own_sentiment_tokenizer = AutoTokenizer.from_pretrained(MODEL_REPO)
|
| 111 |
-
|
| 112 |
-
_own_sentiment_model = AutoModelForSequenceClassification.from_pretrained(
|
| 113 |
-
MODEL_REPO,
|
| 114 |
-
torch_dtype=(
|
| 115 |
-
torch.float16 if torch.cuda.is_available() else torch.float32
|
| 116 |
-
),
|
| 117 |
-
)
|
| 118 |
-
_own_sentiment_model.to("cuda" if torch.cuda.is_available() else "cpu")
|
| 119 |
-
_own_sentiment_model.eval()
|
| 120 |
-
|
| 121 |
-
logger.info("load_models: sentiment model ready")
|
| 122 |
-
|
| 123 |
-
except Exception:
|
| 124 |
-
_own_sentiment_model = None
|
| 125 |
-
_own_sentiment_tokenizer = None
|
| 126 |
-
logger.exception("load_models: sentiment failed (non-fatal)")
|
| 127 |
-
else:
|
| 128 |
-
logger.info("load_models: sentiment already loaded")
|
| 129 |
-
|
| 130 |
-
logger.info("load_models: done")
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
def analyze(text: str, filter_type: str = "headlines"):
|
| 134 |
-
limits = {
|
| 135 |
-
"headlines": 1000,
|
| 136 |
-
"sentiment": 50,
|
| 137 |
-
"sector": 20,
|
| 138 |
-
"stocks": 1000,
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
max_tokens = limits.get(filter_type, 2000)
|
| 142 |
-
|
| 143 |
-
if _llm_model is None or _llm_tokenizer is None:
|
| 144 |
-
raise RuntimeError("LLM model not loaded. Check load_models() logs.")
|
| 145 |
-
|
| 146 |
-
messages = [
|
| 147 |
-
{
|
| 148 |
-
"role": "system",
|
| 149 |
-
"content": "You extract structured data and return JSON when needed.",
|
| 150 |
-
},
|
| 151 |
-
{"role": "user", "content": text},
|
| 152 |
-
]
|
| 153 |
-
|
| 154 |
-
prompt = _llm_tokenizer.apply_chat_template(
|
| 155 |
-
messages, tokenize=False, add_generation_prompt=True
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
inputs = _llm_tokenizer(prompt, return_tensors="pt")
|
| 159 |
-
|
| 160 |
-
# move to model device safely
|
| 161 |
-
device = next(_llm_model.parameters()).device
|
| 162 |
-
inputs = {k: v.to(device) for k, v in inputs.items()}
|
| 163 |
-
|
| 164 |
-
with torch.no_grad():
|
| 165 |
-
outputs = _llm_model.generate(
|
| 166 |
-
**inputs, max_new_tokens=max_tokens, temperature=0, do_sample=False
|
| 167 |
-
)
|
| 168 |
-
|
| 169 |
-
decoded = _llm_tokenizer.decode(
|
| 170 |
-
outputs[0][inputs["input_ids"].shape[-1] :], skip_special_tokens=True
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
-
return decoded
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
# =========================
|
| 177 |
-
# OWN SENTIMENT MODEL (ONNX Runtime)
|
| 178 |
-
# =========================
|
| 179 |
-
# Our own model is an ONNX sequence-classification graph labelled
|
| 180 |
-
# {0: negative, 1: neutral, 2: positive}. It is run directly via ONNX Runtime
|
| 181 |
-
# (NOT through Ollama): the headline is tokenized with the model's own tokenizer,
|
| 182 |
-
# fed to the session, and the output logits are softmaxed into the three class
|
| 183 |
-
# probabilities. Any failure (missing artifacts, unexpected output) falls back
|
| 184 |
-
# to pure-neutral so the worker loop stays simple.
|
| 185 |
-
|
| 186 |
-
SENTIMENT_LABELS = {0: "negative", 1: "neutral", 2: "positive"}
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
def _softmax(logits: np.ndarray) -> np.ndarray:
|
| 190 |
-
"""Numerically-stable softmax over a 1-D logit vector."""
|
| 191 |
-
|
| 192 |
-
shifted = logits - np.max(logits)
|
| 193 |
-
exp = np.exp(shifted)
|
| 194 |
-
return exp / np.sum(exp)
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
def _sentiment_probs(headline: str) -> dict:
|
| 198 |
-
"""Run HF transformers sentiment model and return probabilities."""
|
| 199 |
-
|
| 200 |
-
default = {"negative": 0.0, "neutral": 1.0, "positive": 0.0}
|
| 201 |
-
|
| 202 |
-
if not headline:
|
| 203 |
-
return default
|
| 204 |
-
|
| 205 |
-
if _own_sentiment_model is None or _own_sentiment_tokenizer is None:
|
| 206 |
-
logger.error("sentiment model not loaded")
|
| 207 |
-
return default
|
| 208 |
-
|
| 209 |
-
try:
|
| 210 |
-
inputs = _own_sentiment_tokenizer(
|
| 211 |
-
headline,
|
| 212 |
-
return_tensors="pt",
|
| 213 |
-
truncation=True,
|
| 214 |
-
max_length=512,
|
| 215 |
-
)
|
| 216 |
-
|
| 217 |
-
inputs = {k: v.to(_own_sentiment_model.device) for k, v in inputs.items()}
|
| 218 |
-
|
| 219 |
-
with torch.no_grad():
|
| 220 |
-
outputs = _own_sentiment_model(**inputs)
|
| 221 |
-
|
| 222 |
-
logits = outputs.logits[0].cpu().numpy()
|
| 223 |
-
|
| 224 |
-
probs = _softmax(logits)
|
| 225 |
-
|
| 226 |
-
return {
|
| 227 |
-
"negative": float(probs[0]),
|
| 228 |
-
"neutral": float(probs[1]),
|
| 229 |
-
"positive": float(probs[2]),
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
except Exception:
|
| 233 |
-
logger.exception("sentiment inference failed")
|
| 234 |
-
return default
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
def predict_own_sentiment(headline: str) -> dict:
|
| 238 |
-
"""Score a headline with our own ONNX model; derive sentiment_score/confidence.
|
| 239 |
-
|
| 240 |
-
Returns:
|
| 241 |
-
{
|
| 242 |
-
"probs": {"negative": p, "neutral": p, "positive": p},
|
| 243 |
-
"sentiment_score": P(positive) - P(negative),
|
| 244 |
-
"confidence": 1 - P(neutral),
|
| 245 |
-
}
|
| 246 |
-
"""
|
| 247 |
-
|
| 248 |
-
preview = (headline or "")[:80]
|
| 249 |
-
logger.info(f"predict_own_sentiment: headline={preview!r}")
|
| 250 |
-
|
| 251 |
-
probs = _sentiment_probs(headline)
|
| 252 |
-
|
| 253 |
-
sentiment_score = probs["positive"] - probs["negative"]
|
| 254 |
-
confidence = 1.0 - probs["neutral"]
|
| 255 |
-
|
| 256 |
-
logger.info(
|
| 257 |
-
f"predict_own_sentiment: probs={probs} "
|
| 258 |
-
f"sentiment_score={sentiment_score:.4f} confidence={confidence:.4f}"
|
| 259 |
-
)
|
| 260 |
-
|
| 261 |
-
return {
|
| 262 |
-
"probs": probs,
|
| 263 |
-
"sentiment_score": sentiment_score,
|
| 264 |
-
"confidence": confidence,
|
| 265 |
-
}
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
def extract_json(text: str):
|
| 269 |
-
text = text.replace("```json", "")
|
| 270 |
-
text = text.replace("```", "")
|
| 271 |
-
text = text.strip()
|
| 272 |
-
|
| 273 |
-
match = re.search(r"\{.*\}", text, re.DOTALL)
|
| 274 |
-
|
| 275 |
-
if not match:
|
| 276 |
-
logger.error("No JSON found for text")
|
| 277 |
-
logger.error(text)
|
| 278 |
-
return ""
|
| 279 |
-
|
| 280 |
-
return json.loads(match.group(0))
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
def extract_headlines_from_RAW_html(text: str):
|
| 284 |
-
text = text.replace("```json", "")
|
| 285 |
-
text = text.replace("```", "")
|
| 286 |
-
text = text.strip()
|
| 287 |
-
|
| 288 |
-
# find first opening brace
|
| 289 |
-
start = text.find("{")
|
| 290 |
-
|
| 291 |
-
if start == -1:
|
| 292 |
-
logger.error("No JSON found")
|
| 293 |
-
logger.error(text)
|
| 294 |
-
raise ValueError("No JSON found")
|
| 295 |
-
|
| 296 |
-
decoder = json.JSONDecoder()
|
| 297 |
-
|
| 298 |
-
try:
|
| 299 |
-
obj, end = decoder.raw_decode(text[start:])
|
| 300 |
-
return obj
|
| 301 |
-
except Exception:
|
| 302 |
-
logger.error("Failed parsing JSON")
|
| 303 |
-
logger.error(text)
|
| 304 |
-
raise
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
def looks_like_headline(text: str) -> bool:
|
| 308 |
-
if not text:
|
| 309 |
-
return False
|
| 310 |
-
|
| 311 |
-
if len(text) < 20:
|
| 312 |
-
return False
|
| 313 |
-
|
| 314 |
-
if len(text) > 250:
|
| 315 |
-
return False
|
| 316 |
-
|
| 317 |
-
if text.count(" ") < 2:
|
| 318 |
-
return False
|
| 319 |
-
|
| 320 |
-
blacklist = [
|
| 321 |
-
"click here",
|
| 322 |
-
"read more",
|
| 323 |
-
"subscribe",
|
| 324 |
-
"advertisement",
|
| 325 |
-
"cookie policy",
|
| 326 |
-
"sign in",
|
| 327 |
-
]
|
| 328 |
-
|
| 329 |
-
lower = text.lower()
|
| 330 |
-
|
| 331 |
-
if any(x in lower for x in blacklist):
|
| 332 |
-
return False
|
| 333 |
-
|
| 334 |
-
return True
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
# =========================
|
| 338 |
-
# Split text into semantic chunks
|
| 339 |
-
# =========================
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
def split_into_chunks(raw_text, chunk_size=3000):
|
| 343 |
-
logger.info(
|
| 344 |
-
f"split_into_chunks: input={len(raw_text)} chars, " f"chunk_size={chunk_size}"
|
| 345 |
-
)
|
| 346 |
-
|
| 347 |
-
doc = _nlp(raw_text)
|
| 348 |
-
|
| 349 |
-
chunks = []
|
| 350 |
-
current_chunk = ""
|
| 351 |
-
|
| 352 |
-
for sentence in doc.sents:
|
| 353 |
-
sentence_text = sentence.text.strip()
|
| 354 |
-
if not sentence_text:
|
| 355 |
-
continue
|
| 356 |
-
|
| 357 |
-
if len(current_chunk) + len(sentence_text) + 1 > chunk_size:
|
| 358 |
-
if current_chunk:
|
| 359 |
-
chunks.append(current_chunk.strip())
|
| 360 |
-
current_chunk = sentence_text
|
| 361 |
-
else:
|
| 362 |
-
current_chunk += " " + sentence_text
|
| 363 |
-
|
| 364 |
-
if current_chunk:
|
| 365 |
-
chunks.append(current_chunk.strip())
|
| 366 |
-
|
| 367 |
-
logger.info(f"split_into_chunks: produced {len(chunks)} chunk(s)")
|
| 368 |
-
return chunks
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
# =========================
|
| 372 |
-
# Extract Headlines
|
| 373 |
-
# =========================
|
| 374 |
-
def extract_headlines(raw_html: str):
|
| 375 |
-
"""Extract headlines from RAW html content. It also cleans the headline using regex and filters it."""
|
| 376 |
-
logger.info(f"extract_headlines: parsing {len(raw_html)} chars of HTML")
|
| 377 |
-
soup = BeautifulSoup(raw_html, "lxml")
|
| 378 |
-
|
| 379 |
-
for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "svg"]):
|
| 380 |
-
tag.decompose()
|
| 381 |
-
|
| 382 |
-
cleaned_text = soup.get_text(" ", strip=True)
|
| 383 |
-
|
| 384 |
-
logger.info(f"extract_headlines: cleaned text = {len(cleaned_text)} chars")
|
| 385 |
-
|
| 386 |
-
text_chunks = split_into_chunks(cleaned_text, chunk_size=3000)
|
| 387 |
-
|
| 388 |
-
all_headlines = []
|
| 389 |
-
|
| 390 |
-
for chunk_idx, chunk in enumerate(text_chunks, start=1):
|
| 391 |
-
|
| 392 |
-
logger.info(
|
| 393 |
-
f"extract_headlines: chunk {chunk_idx}/{len(text_chunks)} "
|
| 394 |
-
f"({len(chunk)} chars) -> LLM"
|
| 395 |
-
)
|
| 396 |
-
|
| 397 |
-
prompt = f"""
|
| 398 |
-
Respond ONLY with valid JSON.
|
| 399 |
-
|
| 400 |
-
{{
|
| 401 |
-
"headlines": [
|
| 402 |
-
"headline 1",
|
| 403 |
-
"headline 2"
|
| 404 |
-
]
|
| 405 |
-
}}
|
| 406 |
-
|
| 407 |
-
Rules:
|
| 408 |
-
- Return only headlines
|
| 409 |
-
- No explanations
|
| 410 |
-
- No numbering
|
| 411 |
-
- No duplicates
|
| 412 |
-
- Ignore advertisements
|
| 413 |
-
- Ignore navigation text
|
| 414 |
-
- Ignore broken text
|
| 415 |
-
- Ignore any lines which you think is not a valid headline
|
| 416 |
-
|
| 417 |
-
CONTENT:
|
| 418 |
-
{chunk}
|
| 419 |
-
"""
|
| 420 |
-
|
| 421 |
-
try:
|
| 422 |
-
with pipeline_lock:
|
| 423 |
-
headlines_text = analyze(prompt, filter_type="headlines").strip()
|
| 424 |
-
|
| 425 |
-
try:
|
| 426 |
-
data = extract_headlines_from_RAW_html(headlines_text)
|
| 427 |
-
|
| 428 |
-
headlines = []
|
| 429 |
-
|
| 430 |
-
for h in data.get("headlines", []):
|
| 431 |
-
h = clean_headline_text(str(h).strip())
|
| 432 |
-
|
| 433 |
-
if not looks_like_headline(h):
|
| 434 |
-
continue
|
| 435 |
-
|
| 436 |
-
if len(h) < 20:
|
| 437 |
-
continue
|
| 438 |
-
|
| 439 |
-
if "<" in h or ">" in h:
|
| 440 |
-
continue
|
| 441 |
-
|
| 442 |
-
headlines.append(h)
|
| 443 |
-
|
| 444 |
-
except Exception:
|
| 445 |
-
logger.exception(
|
| 446 |
-
f"extract_headlines: invalid JSON from chunk {chunk_idx}"
|
| 447 |
-
)
|
| 448 |
-
headlines = []
|
| 449 |
-
|
| 450 |
-
logger.info(
|
| 451 |
-
f"extract_headlines: chunk {chunk_idx} -> "
|
| 452 |
-
f"{len(headlines)} headlines"
|
| 453 |
-
)
|
| 454 |
-
|
| 455 |
-
all_headlines.extend(headlines)
|
| 456 |
-
|
| 457 |
-
except Exception:
|
| 458 |
-
logger.exception(f"extract_headlines: chunk {chunk_idx} failed")
|
| 459 |
-
|
| 460 |
-
unique_headlines = []
|
| 461 |
-
seen = set()
|
| 462 |
-
|
| 463 |
-
for headline in all_headlines:
|
| 464 |
-
normalized = headline.lower().strip()
|
| 465 |
-
|
| 466 |
-
if normalized not in seen:
|
| 467 |
-
seen.add(normalized)
|
| 468 |
-
unique_headlines.append(headline)
|
| 469 |
-
|
| 470 |
-
logger.info(
|
| 471 |
-
f"extract_headlines: done; {len(all_headlines)} total, "
|
| 472 |
-
f"{len(unique_headlines)} after dedup"
|
| 473 |
-
)
|
| 474 |
-
|
| 475 |
-
return unique_headlines
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
def is_valid_sector(sector: str):
|
| 479 |
-
"""Validates a sectort, returns is_valid:true/false"""
|
| 480 |
-
logger.info("Validating sector: sector={sector}")
|
| 481 |
-
prompt = f"""
|
| 482 |
-
You are a industry sector validator in India.
|
| 483 |
-
|
| 484 |
-
Determine whether the input is a genuine industry sector.
|
| 485 |
-
|
| 486 |
-
A valid industry sector:
|
| 487 |
-
- Section A: Agriculture, Forestry, and Fishing
|
| 488 |
-
- Section C: Manufacturing (e.g., Textiles, Chemicals, Automobiles)
|
| 489 |
-
- Section J: Information and Communication (IT, Software, Telecommunications)
|
| 490 |
-
- Section K: Financial and Insurance Activities
|
| 491 |
-
|
| 492 |
-
An invalid industry sector:
|
| 493 |
-
- Navigation text
|
| 494 |
-
- Menu items
|
| 495 |
-
- Advertisement text
|
| 496 |
-
- Login/signup text
|
| 497 |
-
- Cookie notices
|
| 498 |
-
- Random fragments
|
| 499 |
-
- Category labels
|
| 500 |
-
- Gibberish
|
| 501 |
-
|
| 502 |
-
Respond ONLY with valid JSON.
|
| 503 |
-
|
| 504 |
-
If the industry sector is INVALID:
|
| 505 |
-
|
| 506 |
-
{{
|
| 507 |
-
"is_valid": false
|
| 508 |
-
}}
|
| 509 |
-
|
| 510 |
-
If the industry sector is VALID:
|
| 511 |
-
|
| 512 |
-
{{
|
| 513 |
-
"is_valid": true,
|
| 514 |
-
}}
|
| 515 |
-
|
| 516 |
-
Headline:
|
| 517 |
-
{sector}
|
| 518 |
-
"""
|
| 519 |
-
global_data = ""
|
| 520 |
-
try:
|
| 521 |
-
with pipeline_lock:
|
| 522 |
-
logger.info("analyze_sector: calling Ollama")
|
| 523 |
-
text = analyze(prompt, "sector").strip()
|
| 524 |
-
|
| 525 |
-
logger.info(f"analyze_sector: raw output length={len(text)} chars")
|
| 526 |
-
logger.debug(f"analyze_sector: For sector {sector}")
|
| 527 |
-
logger.debug(f"raw LLM response: {text}")
|
| 528 |
-
data = extract_json(text)
|
| 529 |
-
logger.info(f"Extracted json is {data}")
|
| 530 |
-
global_data = data
|
| 531 |
-
|
| 532 |
-
raw_valid = data.get("is_valid", False)
|
| 533 |
-
|
| 534 |
-
if isinstance(raw_valid, bool):
|
| 535 |
-
is_valid = raw_valid
|
| 536 |
-
else:
|
| 537 |
-
is_valid = str(raw_valid).strip().lower() == "true"
|
| 538 |
-
|
| 539 |
-
if not is_valid:
|
| 540 |
-
logger.info(f"analyze_article: invalid sector detected: " f"{sector}")
|
| 541 |
-
|
| 542 |
-
return {
|
| 543 |
-
"is_valid": False,
|
| 544 |
-
}
|
| 545 |
-
|
| 546 |
-
return {
|
| 547 |
-
"is_valid": True,
|
| 548 |
-
}
|
| 549 |
-
|
| 550 |
-
except Exception:
|
| 551 |
-
logger.exception("sector: failed to parse model response")
|
| 552 |
-
|
| 553 |
-
return {
|
| 554 |
-
"is_valid": False,
|
| 555 |
-
"sentiment": None,
|
| 556 |
-
"category": None,
|
| 557 |
-
}
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
# =========================
|
| 561 |
-
# Analyze Headline
|
| 562 |
-
# =========================
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
def analyze_article(title: str):
|
| 566 |
-
preview = (title or "")[:80]
|
| 567 |
-
logger.info(f"analyze_article: title={preview!r}")
|
| 568 |
-
|
| 569 |
-
clean_title = clean_headline_text(title)
|
| 570 |
-
|
| 571 |
-
prompt = f"""
|
| 572 |
-
You are a news headline validator and classifier.
|
| 573 |
-
|
| 574 |
-
Determine whether the input is a genuine news headline.
|
| 575 |
-
|
| 576 |
-
A valid headline:
|
| 577 |
-
- Describes a real news event, announcement, development, report, statement, market move, policy change, sports result, technology update, etc.
|
| 578 |
-
- Looks like something published by a news organization.
|
| 579 |
-
|
| 580 |
-
An invalid headline:
|
| 581 |
-
- Navigation text
|
| 582 |
-
- Menu items
|
| 583 |
-
- Advertisement text
|
| 584 |
-
- Login/signup text
|
| 585 |
-
- Cookie notices
|
| 586 |
-
- Random fragments
|
| 587 |
-
- Broken HTML text
|
| 588 |
-
- Single words
|
| 589 |
-
- Category labels
|
| 590 |
-
- Gibberish
|
| 591 |
-
|
| 592 |
-
Respond ONLY with valid JSON.
|
| 593 |
-
|
| 594 |
-
If the headline is INVALID:
|
| 595 |
-
|
| 596 |
-
{{
|
| 597 |
-
"is_valid": false
|
| 598 |
-
}}
|
| 599 |
-
|
| 600 |
-
If the headline is VALID:
|
| 601 |
-
|
| 602 |
-
{{
|
| 603 |
-
"is_valid": true,
|
| 604 |
-
"sentiment": "positive|negative|neutral",
|
| 605 |
-
"category": "business|politics|technology|sports|world|other"
|
| 606 |
-
}}
|
| 607 |
-
|
| 608 |
-
Headline:
|
| 609 |
-
{clean_title}
|
| 610 |
-
"""
|
| 611 |
-
global_data = ""
|
| 612 |
-
try:
|
| 613 |
-
with pipeline_lock:
|
| 614 |
-
logger.info("analyze_article: calling Ollama")
|
| 615 |
-
text = analyze(prompt, "sentiment").strip()
|
| 616 |
-
|
| 617 |
-
logger.info(f"analyze_article: raw output length={len(text)} chars")
|
| 618 |
-
logger.debug(f"analyze_article: For headline {clean_title}")
|
| 619 |
-
logger.debug(f"raw LLM response: {text}")
|
| 620 |
-
data = extract_json(text)
|
| 621 |
-
global_data = data
|
| 622 |
-
|
| 623 |
-
raw_valid = data.get("is_valid", False)
|
| 624 |
-
|
| 625 |
-
if isinstance(raw_valid, bool):
|
| 626 |
-
is_valid = raw_valid
|
| 627 |
-
else:
|
| 628 |
-
is_valid = str(raw_valid).strip().lower() == "true"
|
| 629 |
-
|
| 630 |
-
if not is_valid:
|
| 631 |
-
logger.info(
|
| 632 |
-
f"analyze_article: invalid headline detected: " f"{clean_title[:100]}"
|
| 633 |
-
)
|
| 634 |
-
|
| 635 |
-
return {
|
| 636 |
-
"is_valid": False,
|
| 637 |
-
"sentiment": None,
|
| 638 |
-
"category": None,
|
| 639 |
-
}
|
| 640 |
-
|
| 641 |
-
sentiment = str(data.get("sentiment", "neutral")).strip().lower()
|
| 642 |
-
|
| 643 |
-
category = str(data.get("category", "other")).strip().lower()
|
| 644 |
-
|
| 645 |
-
valid_sentiments = {
|
| 646 |
-
"positive",
|
| 647 |
-
"negative",
|
| 648 |
-
"neutral",
|
| 649 |
-
}
|
| 650 |
-
|
| 651 |
-
valid_categories = {
|
| 652 |
-
"business",
|
| 653 |
-
"politics",
|
| 654 |
-
"technology",
|
| 655 |
-
"sports",
|
| 656 |
-
"world",
|
| 657 |
-
"other",
|
| 658 |
-
}
|
| 659 |
-
|
| 660 |
-
if sentiment not in valid_sentiments:
|
| 661 |
-
sentiment = "neutral"
|
| 662 |
-
|
| 663 |
-
if category not in valid_categories:
|
| 664 |
-
category = "other"
|
| 665 |
-
|
| 666 |
-
logger.info(
|
| 667 |
-
f"analyze_article: valid headline "
|
| 668 |
-
f"sentiment={sentiment} "
|
| 669 |
-
f"category={category}"
|
| 670 |
-
)
|
| 671 |
-
|
| 672 |
-
return {
|
| 673 |
-
"is_valid": True,
|
| 674 |
-
"sentiment": sentiment,
|
| 675 |
-
"category": category,
|
| 676 |
-
}
|
| 677 |
-
|
| 678 |
-
except Exception:
|
| 679 |
-
logger.exception("analyze_article: failed to parse model response")
|
| 680 |
-
|
| 681 |
-
logger.exception(
|
| 682 |
-
f"{clean_title[:100]}",
|
| 683 |
-
)
|
| 684 |
-
|
| 685 |
-
logger.exception(
|
| 686 |
-
f"{global_data}",
|
| 687 |
-
)
|
| 688 |
-
|
| 689 |
-
return {
|
| 690 |
-
"is_valid": False,
|
| 691 |
-
"sentiment": None,
|
| 692 |
-
"category": None,
|
| 693 |
-
}
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
# =========================
|
| 697 |
-
# ML DATASET PREPROCESSING
|
| 698 |
-
# =========================
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
def clean_for_dataset(text: str) -> str:
|
| 702 |
-
"""Local preprocessing for the ML dataset.
|
| 703 |
-
|
| 704 |
-
Builds on clean_headline_text (bullets / numbering / quotes) and additionally
|
| 705 |
-
strips dashes so the headline is normalized before being sent to the model.
|
| 706 |
-
"""
|
| 707 |
-
|
| 708 |
-
if not text:
|
| 709 |
-
return ""
|
| 710 |
-
|
| 711 |
-
# Shared cleaning: bullets, numbered/lettered lists, surrounding quotes.
|
| 712 |
-
text = clean_headline_text(text)
|
| 713 |
-
|
| 714 |
-
# Remove dashes (hyphen, en dash, em dash) -> space.
|
| 715 |
-
text = re.sub(r"[-–—]+", " ", text)
|
| 716 |
-
|
| 717 |
-
# Collapse any whitespace introduced above.
|
| 718 |
-
text = re.sub(r"\s+", " ", text).strip()
|
| 719 |
-
|
| 720 |
-
return text
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
def extract_entities(text):
|
| 724 |
-
doc = _ner(text)
|
| 725 |
-
# Return a list of tuples containing (Entity Text, Entity Type)
|
| 726 |
-
return [(ent.text, ent.label_) for ent in doc.ents]
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
def extract_company_sector(headline: str) -> dict:
|
| 730 |
-
"""
|
| 731 |
-
Resolve a company mentioned in a headline using:
|
| 732 |
-
1. Direct company name match
|
| 733 |
-
2. Direct ticker match
|
| 734 |
-
3. LLM fallback (ticker only)
|
| 735 |
-
4. DB as single source of truth for company + sector
|
| 736 |
-
"""
|
| 737 |
-
|
| 738 |
-
preview = (headline or "")[:80]
|
| 739 |
-
logger.info(f"extract_company_sector: headline={preview!r}")
|
| 740 |
-
|
| 741 |
-
default = {
|
| 742 |
-
"is_valid": False,
|
| 743 |
-
"ticker": "NA",
|
| 744 |
-
"company": "NA",
|
| 745 |
-
"sector": "NA",
|
| 746 |
-
}
|
| 747 |
-
|
| 748 |
-
if not headline:
|
| 749 |
-
return default
|
| 750 |
-
|
| 751 |
-
headline_lower = headline.lower()
|
| 752 |
-
|
| 753 |
-
db = SessionLocal()
|
| 754 |
-
|
| 755 |
-
try:
|
| 756 |
-
|
| 757 |
-
sources = db.query(
|
| 758 |
-
StockSources.ticker,
|
| 759 |
-
StockSources.company_name,
|
| 760 |
-
StockSources.sector,
|
| 761 |
-
).all()
|
| 762 |
-
|
| 763 |
-
entities = extract_entities(headline_lower)
|
| 764 |
-
|
| 765 |
-
entity_texts = {e[0].lower() for e in entities}
|
| 766 |
-
|
| 767 |
-
#
|
| 768 |
-
# STEP 1: Company name match
|
| 769 |
-
#
|
| 770 |
-
for source in sources:
|
| 771 |
-
|
| 772 |
-
company_name = (source.company_name or "").strip()
|
| 773 |
-
if not company_name:
|
| 774 |
-
continue
|
| 775 |
-
|
| 776 |
-
if company_name.lower() in headline_lower:
|
| 777 |
-
|
| 778 |
-
logger.info(f"Matched company name: {company_name}")
|
| 779 |
-
|
| 780 |
-
return {
|
| 781 |
-
"is_valid": True,
|
| 782 |
-
"ticker": source.ticker,
|
| 783 |
-
"company": source.company_name, # keep original casing
|
| 784 |
-
"sector": source.sector,
|
| 785 |
-
}
|
| 786 |
-
|
| 787 |
-
#
|
| 788 |
-
# STEP 2: Ticker match
|
| 789 |
-
#
|
| 790 |
-
for source in sources:
|
| 791 |
-
ticker = (source.ticker or "").strip()
|
| 792 |
-
|
| 793 |
-
if source.company_name and source.company_name.lower() in entity_texts:
|
| 794 |
-
return {
|
| 795 |
-
"is_valid": True,
|
| 796 |
-
"ticker": source.ticker,
|
| 797 |
-
"company": source.company_name,
|
| 798 |
-
"sector": source.sector,
|
| 799 |
-
}
|
| 800 |
-
|
| 801 |
-
if not ticker:
|
| 802 |
-
continue
|
| 803 |
-
|
| 804 |
-
if ticker.lower() in headline_lower:
|
| 805 |
-
|
| 806 |
-
logger.info(f"Matched ticker: {ticker}")
|
| 807 |
-
|
| 808 |
-
return {
|
| 809 |
-
"is_valid": True,
|
| 810 |
-
"ticker": source.ticker,
|
| 811 |
-
"company": source.company_name,
|
| 812 |
-
"sector": source.sector,
|
| 813 |
-
}
|
| 814 |
-
|
| 815 |
-
#
|
| 816 |
-
# STEP 3: LLM fallback (ticker only)
|
| 817 |
-
#
|
| 818 |
-
logger.info("No direct StockSources match found. Falling back to LLM.")
|
| 819 |
-
|
| 820 |
-
company_list = [
|
| 821 |
-
{
|
| 822 |
-
"ticker": s.ticker,
|
| 823 |
-
"company": s.company_name,
|
| 824 |
-
}
|
| 825 |
-
for s in sources
|
| 826 |
-
if s.company_name
|
| 827 |
-
]
|
| 828 |
-
|
| 829 |
-
prompt = f"""
|
| 830 |
-
You are a financial news classifier. Identify the most relevant stock ticker/company name from the headline.
|
| 831 |
-
|
| 832 |
-
Return ONLY JSON:
|
| 833 |
-
|
| 834 |
-
{{
|
| 835 |
-
"ticker": "ticker",
|
| 836 |
-
"company_name": "company name",
|
| 837 |
-
"is_valid": "True|False"
|
| 838 |
-
}}
|
| 839 |
-
|
| 840 |
-
Headline:
|
| 841 |
-
{headline}
|
| 842 |
-
"""
|
| 843 |
-
|
| 844 |
-
with pipeline_lock:
|
| 845 |
-
text = analyze(prompt, "headlines").strip()
|
| 846 |
-
|
| 847 |
-
valid_tickers = {s.ticker.upper() for s in sources if s.ticker}
|
| 848 |
-
|
| 849 |
-
valid_companies = {s.company_name.upper() for s in sources if s.company_name}
|
| 850 |
-
|
| 851 |
-
logger.debug(f"raw LLM response: {text}")
|
| 852 |
-
|
| 853 |
-
data = extract_json(text)
|
| 854 |
-
|
| 855 |
-
logger.debug(f"extracted response: {data}")
|
| 856 |
-
|
| 857 |
-
if not isinstance(data, dict):
|
| 858 |
-
return default
|
| 859 |
-
|
| 860 |
-
is_valid = None
|
| 861 |
-
|
| 862 |
-
if "is_valid" in data:
|
| 863 |
-
is_valid = (data.get("is_valid") or "").strip().upper()
|
| 864 |
-
|
| 865 |
-
if not is_valid:
|
| 866 |
-
return default
|
| 867 |
-
|
| 868 |
-
ticker = None
|
| 869 |
-
company = None
|
| 870 |
-
|
| 871 |
-
# CASE 1: normal format
|
| 872 |
-
if "ticker" in data:
|
| 873 |
-
t = (data.get("ticker") or "").strip().upper()
|
| 874 |
-
if t in valid_tickers:
|
| 875 |
-
ticker = t
|
| 876 |
-
|
| 877 |
-
# CASE 2: weird key-value format {"TICKER": "Company Name"}
|
| 878 |
-
else:
|
| 879 |
-
for k, v in data.items():
|
| 880 |
-
if isinstance(k, str) and k.strip():
|
| 881 |
-
# check if it exists in database
|
| 882 |
-
if k.upper() in valid_tickers:
|
| 883 |
-
ticker = k
|
| 884 |
-
break
|
| 885 |
-
if k.upper() in valid_companies:
|
| 886 |
-
company = k.upper()
|
| 887 |
-
break
|
| 888 |
-
|
| 889 |
-
ticker = (ticker or "").strip().upper()
|
| 890 |
-
company = (company or "").strip().lower()
|
| 891 |
-
source = None
|
| 892 |
-
|
| 893 |
-
if (not ticker or ticker == "NA") and (not company or company == "NA"):
|
| 894 |
-
return default
|
| 895 |
-
|
| 896 |
-
if ticker in valid_tickers:
|
| 897 |
-
source = (
|
| 898 |
-
db.query(StockSources).filter(StockSources.ticker.ilike(ticker)).first()
|
| 899 |
-
)
|
| 900 |
-
|
| 901 |
-
if company in valid_companies:
|
| 902 |
-
source = (
|
| 903 |
-
db.query(StockSources)
|
| 904 |
-
.filter(StockSources.company_name.ilike(company))
|
| 905 |
-
.first()
|
| 906 |
-
)
|
| 907 |
-
|
| 908 |
-
if not source:
|
| 909 |
-
return default
|
| 910 |
-
|
| 911 |
-
return {
|
| 912 |
-
"is_valid": True,
|
| 913 |
-
"ticker": source.ticker,
|
| 914 |
-
"company": source.company_name,
|
| 915 |
-
"sector": source.sector or "NA",
|
| 916 |
-
}
|
| 917 |
-
|
| 918 |
-
except Exception:
|
| 919 |
-
logger.exception("extract_company_sector: failed")
|
| 920 |
-
return default
|
| 921 |
-
|
| 922 |
-
finally:
|
| 923 |
-
db.close()
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
# =========================
|
| 927 |
-
# Extract Stock data
|
| 928 |
-
# =========================
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
def metrics_score(metrics):
|
| 932 |
-
|
| 933 |
-
score = 0
|
| 934 |
-
|
| 935 |
-
for field in (
|
| 936 |
-
"Open",
|
| 937 |
-
"High",
|
| 938 |
-
"Low",
|
| 939 |
-
"Close",
|
| 940 |
-
"volume",
|
| 941 |
-
):
|
| 942 |
-
|
| 943 |
-
value = str(metrics.get(field, "")).strip()
|
| 944 |
-
|
| 945 |
-
if value and value != "—":
|
| 946 |
-
score += 1
|
| 947 |
-
|
| 948 |
-
return score
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
import json
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
def extract_stock_metrics_from_html(raw_html: str):
|
| 955 |
-
"""
|
| 956 |
-
Uses the LLM to extract all key/value metrics from
|
| 957 |
-
Google Finance style stock information HTML.
|
| 958 |
-
|
| 959 |
-
Returns:
|
| 960 |
-
{
|
| 961 |
-
"Open": "...",
|
| 962 |
-
"High": "...",
|
| 963 |
-
...
|
| 964 |
-
}
|
| 965 |
-
"""
|
| 966 |
-
soup = BeautifulSoup(raw_html, "lxml")
|
| 967 |
-
|
| 968 |
-
for tag in soup(["script", "style", "nav", "footer", "header", "noscript", "svg"]):
|
| 969 |
-
tag.decompose()
|
| 970 |
-
|
| 971 |
-
cleaned_text = soup.get_text(" ", strip=True)
|
| 972 |
-
|
| 973 |
-
logger.info(f"extract_headlines: cleaned text = {len(cleaned_text)} chars")
|
| 974 |
-
|
| 975 |
-
chunks = split_into_chunks(cleaned_text)
|
| 976 |
-
|
| 977 |
-
merged_metrics = {}
|
| 978 |
-
|
| 979 |
-
logger.info(
|
| 980 |
-
f"extract_stock_metrics_from_html: " f"Split HTML into {len(chunks)} chunks"
|
| 981 |
-
)
|
| 982 |
-
|
| 983 |
-
best_score = 0
|
| 984 |
-
|
| 985 |
-
for chunk_idx, chunk in enumerate(chunks, start=1):
|
| 986 |
-
|
| 987 |
-
prompt = f"""
|
| 988 |
-
Respond ONLY with valid JSON.
|
| 989 |
-
|
| 990 |
-
Desired Format:
|
| 991 |
-
|
| 992 |
-
{{
|
| 993 |
-
"metrics": {{
|
| 994 |
-
"company_name": "company name"
|
| 995 |
-
"ticker": ticker,
|
| 996 |
-
"exchange": exchange,
|
| 997 |
-
"Open": open_price,
|
| 998 |
-
"High": high_price,
|
| 999 |
-
"Low": low_price,
|
| 1000 |
-
"Close": close_price,
|
| 1001 |
-
"volume": volume,
|
| 1002 |
-
}}
|
| 1003 |
-
}}
|
| 1004 |
-
|
| 1005 |
-
Rules:
|
| 1006 |
-
- Parse the HTML.
|
| 1007 |
-
- Extract every metric label and its value.
|
| 1008 |
-
- Preserve metric names exactly.
|
| 1009 |
-
- Remove HTML tags.
|
| 1010 |
-
- Ignore styling and javascript attributes.
|
| 1011 |
-
- Ignore duplicate metric/value pairs.
|
| 1012 |
-
- Return only JSON.
|
| 1013 |
-
- No explanations.
|
| 1014 |
-
- No markdown.
|
| 1015 |
-
|
| 1016 |
-
HTML:
|
| 1017 |
-
|
| 1018 |
-
{chunk}
|
| 1019 |
-
"""
|
| 1020 |
-
|
| 1021 |
-
try:
|
| 1022 |
-
|
| 1023 |
-
logger.info(
|
| 1024 |
-
f"extract_stock_metrics_from_html: "
|
| 1025 |
-
f"Processing chunk "
|
| 1026 |
-
f"{chunk_idx}/{len(chunks)}"
|
| 1027 |
-
)
|
| 1028 |
-
|
| 1029 |
-
with pipeline_lock:
|
| 1030 |
-
llm_response = analyze(prompt, filter_type="stocks").strip()
|
| 1031 |
-
|
| 1032 |
-
parsed = json.loads(llm_response)
|
| 1033 |
-
|
| 1034 |
-
logger.debug(f"LLM JSON response:\n{llm_response}")
|
| 1035 |
-
|
| 1036 |
-
metrics = parsed.get("metrics", {})
|
| 1037 |
-
|
| 1038 |
-
score = metrics_score(metrics)
|
| 1039 |
-
|
| 1040 |
-
if score > best_score:
|
| 1041 |
-
best_score = score
|
| 1042 |
-
# merge immediately
|
| 1043 |
-
for k, v in metrics.items():
|
| 1044 |
-
k = str(k).strip()
|
| 1045 |
-
v = str(v).strip() if v is not None else ""
|
| 1046 |
-
|
| 1047 |
-
if not k or not v:
|
| 1048 |
-
continue
|
| 1049 |
-
|
| 1050 |
-
if v.lower() in {
|
| 1051 |
-
"company name",
|
| 1052 |
-
"ticker",
|
| 1053 |
-
"ticker symbol",
|
| 1054 |
-
"exchange",
|
| 1055 |
-
"null",
|
| 1056 |
-
"none",
|
| 1057 |
-
}:
|
| 1058 |
-
continue
|
| 1059 |
-
|
| 1060 |
-
if v.lower() in {"", "—", "na", "n/a"}:
|
| 1061 |
-
continue
|
| 1062 |
-
|
| 1063 |
-
if k not in merged_metrics or merged_metrics[k] in {
|
| 1064 |
-
"",
|
| 1065 |
-
"—",
|
| 1066 |
-
"null",
|
| 1067 |
-
"none",
|
| 1068 |
-
}:
|
| 1069 |
-
merged_metrics[k] = v
|
| 1070 |
-
|
| 1071 |
-
if score >= 5:
|
| 1072 |
-
logger.info("Perfect stock quote found, " "stopping chunk processing")
|
| 1073 |
-
break
|
| 1074 |
-
|
| 1075 |
-
except Exception:
|
| 1076 |
-
logger.exception(
|
| 1077 |
-
f"extract_stock_metrics_from_html: " f"chunk {chunk_idx} failed"
|
| 1078 |
-
)
|
| 1079 |
-
|
| 1080 |
-
logger.info(
|
| 1081 |
-
f"extract_stock_metrics_from_html: " f"Returning {len(merged_metrics)} metrics"
|
| 1082 |
-
)
|
| 1083 |
-
logger.debug(
|
| 1084 |
-
f"extract_stock_metrics_from_html: " f"Returning {(merged_metrics)} metrics"
|
| 1085 |
-
)
|
| 1086 |
-
|
| 1087 |
-
return merged_metrics
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logger.py
CHANGED
|
@@ -8,8 +8,7 @@ class Logger:
|
|
| 8 |
|
| 9 |
def __init__(self):
|
| 10 |
|
| 11 |
-
self.logs_folder =
|
| 12 |
-
os.makedirs(self.logs_folder, exist_ok=True)
|
| 13 |
|
| 14 |
self.max_entries = 10000
|
| 15 |
|
|
@@ -41,13 +40,24 @@ class Logger:
|
|
| 41 |
except Exception:
|
| 42 |
pass
|
| 43 |
|
| 44 |
-
timestamp = datetime.now().strftime(
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
filename =
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
self.current_file_path = os.path.join(
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
-
self.current_file = open(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
self.current_entries = 0
|
| 53 |
|
|
@@ -65,9 +75,15 @@ class Logger:
|
|
| 65 |
|
| 66 |
self._create_new_log_file()
|
| 67 |
|
| 68 |
-
timestamp = datetime.now().strftime(
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
formatted =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
# Print to terminal
|
| 73 |
print(formatted)
|
|
|
|
| 8 |
|
| 9 |
def __init__(self):
|
| 10 |
|
| 11 |
+
self.logs_folder = "logs"
|
|
|
|
| 12 |
|
| 13 |
self.max_entries = 10000
|
| 14 |
|
|
|
|
| 40 |
except Exception:
|
| 41 |
pass
|
| 42 |
|
| 43 |
+
timestamp = datetime.now().strftime(
|
| 44 |
+
"%Y-%m-%d_%H-%M-%S"
|
| 45 |
+
)
|
| 46 |
|
| 47 |
+
filename = (
|
| 48 |
+
f"backend_logs_{timestamp}_{self.file_index}.txt"
|
| 49 |
+
)
|
| 50 |
|
| 51 |
+
self.current_file_path = os.path.join(
|
| 52 |
+
self.logs_folder,
|
| 53 |
+
filename
|
| 54 |
+
)
|
| 55 |
|
| 56 |
+
self.current_file = open(
|
| 57 |
+
self.current_file_path,
|
| 58 |
+
"a",
|
| 59 |
+
encoding="utf-8"
|
| 60 |
+
)
|
| 61 |
|
| 62 |
self.current_entries = 0
|
| 63 |
|
|
|
|
| 75 |
|
| 76 |
self._create_new_log_file()
|
| 77 |
|
| 78 |
+
timestamp = datetime.now().strftime(
|
| 79 |
+
"%Y-%m-%d %H:%M:%S"
|
| 80 |
+
)
|
| 81 |
|
| 82 |
+
formatted = (
|
| 83 |
+
f"[{timestamp}] "
|
| 84 |
+
f"[{level.upper()}] "
|
| 85 |
+
f"{message}"
|
| 86 |
+
)
|
| 87 |
|
| 88 |
# Print to terminal
|
| 89 |
print(formatted)
|
macro_sources.csv
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name,type,country,market,exchange,gf_ticker,url
|
| 2 |
+
Nifty 50,index,INDIA,INDIA,INDEXNSE,NIFTY_50,
|
| 3 |
+
BSE Sensex,index,INDIA,INDIA,INDEXBOM,SENSEX,
|
| 4 |
+
S&P 500,index,UNITED STATES,US,INDEXSP,.INX,
|
| 5 |
+
Nasdaq Composite,index,UNITED STATES,US,INDEXNASDAQ,.IXIC,
|
| 6 |
+
Dow Jones,index,UNITED STATES,US,INDEXDJX,.DJI,
|
| 7 |
+
India VIX,vix,INDIA,INDIA,INDEXNSE,INDIAVIX,
|
| 8 |
+
CBOE VIX,vix,UNITED STATES,US,INDEXCBOE,VIX,
|
| 9 |
+
Gold (GLD),commodity,GLOBAL,US,NYSEARCA,GLD,
|
| 10 |
+
Brent Oil (BNO),commodity,GLOBAL,US,NYSEARCA,BNO,
|
| 11 |
+
WTI Oil (USO),commodity,GLOBAL,US,NYSEARCA,USO,
|
| 12 |
+
Silver (SLV),commodity,GLOBAL,US,NYSEARCA,SLV,
|
| 13 |
+
Copper (CPER),commodity,GLOBAL,US,NYSEARCA,CPER,
|
| 14 |
+
USD/INR,fx,INDIA,INDIA,,,https://www.google.com/finance/quote/USD-INR
|
| 15 |
+
India 10Y Bond,yield,INDIA,INDIA,,,https://www.worldgovernmentbonds.com/bond-historical-data/india/10-years/
|
| 16 |
+
US 10Y Bond,yield,UNITED STATES,US,,,https://www.worldgovernmentbonds.com/bond-historical-data/united-states/10-years/
|
| 17 |
+
FII/DII Activity,fii_dii,INDIA,INDIA,,,https://www.moneycontrol.com/stocks/marketstats/fii_dii_activity/index.php
|
main.py
CHANGED
|
@@ -1,16 +1,18 @@
|
|
| 1 |
from contextlib import asynccontextmanager
|
| 2 |
|
| 3 |
-
from fastapi import
|
| 4 |
-
import os
|
| 5 |
import subprocess
|
| 6 |
from logger import logger
|
| 7 |
from database import engine
|
| 8 |
from models import Base
|
| 9 |
-
from db_helpers import
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
from workers import stop_workers
|
| 13 |
-
from actions import ActionRequest,
|
| 14 |
|
| 15 |
# =========================
|
| 16 |
# LIFESPAN
|
|
@@ -26,14 +28,13 @@ async def lifespan(app: FastAPI):
|
|
| 26 |
Base.metadata.create_all(bind=engine)
|
| 27 |
logger.info("lifespan: schema create_all done")
|
| 28 |
|
| 29 |
-
create_ollama_modelfile()
|
| 30 |
migrate_schema()
|
| 31 |
-
load_models()
|
| 32 |
load_sources_to_db()
|
| 33 |
load_stocks_to_db()
|
|
|
|
|
|
|
| 34 |
logger.info("Checking/Installing Playwright Chromium browser...")
|
| 35 |
try:
|
| 36 |
-
# Executes 'playwright install chromium' programmatically
|
| 37 |
subprocess.run(["playwright", "install", "chromium"], check=True)
|
| 38 |
logger.info("Playwright browser check complete.")
|
| 39 |
except Exception as e:
|
|
|
|
| 1 |
from contextlib import asynccontextmanager
|
| 2 |
|
| 3 |
+
from fastapi import FastAPI
|
|
|
|
| 4 |
import subprocess
|
| 5 |
from logger import logger
|
| 6 |
from database import engine
|
| 7 |
from models import Base
|
| 8 |
+
from db_helpers import (
|
| 9 |
+
load_stocks_to_db,
|
| 10 |
+
migrate_schema,
|
| 11 |
+
load_sources_to_db,
|
| 12 |
+
load_macro_sources_to_db,
|
| 13 |
+
)
|
| 14 |
from workers import stop_workers
|
| 15 |
+
from actions import ActionRequest, ACTIONS
|
| 16 |
|
| 17 |
# =========================
|
| 18 |
# LIFESPAN
|
|
|
|
| 28 |
Base.metadata.create_all(bind=engine)
|
| 29 |
logger.info("lifespan: schema create_all done")
|
| 30 |
|
|
|
|
| 31 |
migrate_schema()
|
|
|
|
| 32 |
load_sources_to_db()
|
| 33 |
load_stocks_to_db()
|
| 34 |
+
load_macro_sources_to_db()
|
| 35 |
+
|
| 36 |
logger.info("Checking/Installing Playwright Chromium browser...")
|
| 37 |
try:
|
|
|
|
| 38 |
subprocess.run(["playwright", "install", "chromium"], check=True)
|
| 39 |
logger.info("Playwright browser check complete.")
|
| 40 |
except Exception as e:
|
models.py
CHANGED
|
@@ -1,33 +1,46 @@
|
|
| 1 |
# =========================
|
| 2 |
-
# models.py
|
| 3 |
# =========================
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
from sqlalchemy import (
|
| 6 |
Column,
|
| 7 |
String,
|
| 8 |
Text,
|
| 9 |
Integer,
|
| 10 |
-
Float,
|
| 11 |
-
Boolean,
|
| 12 |
text as text_func,
|
| 13 |
-
Enum,
|
| 14 |
)
|
| 15 |
|
| 16 |
import time
|
| 17 |
import uuid
|
| 18 |
-
import enum
|
| 19 |
from database import Base
|
| 20 |
|
| 21 |
|
| 22 |
-
#
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
class RawHTML(Base):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
__tablename__ = "raw_html"
|
| 33 |
|
|
@@ -39,44 +52,11 @@ class RawHTML(Base):
|
|
| 39 |
|
| 40 |
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
| 41 |
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
type = Column(Enum(SourceType), nullable=False)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
# =========================
|
| 48 |
-
# PARSED ARTICLES TABLE
|
| 49 |
-
# =========================
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
class ParsedArticle(Base):
|
| 53 |
-
|
| 54 |
-
__tablename__ = "parsed_articles"
|
| 55 |
-
|
| 56 |
-
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
|
| 57 |
-
|
| 58 |
-
raw_html_id = Column(String)
|
| 59 |
-
|
| 60 |
-
title = Column(Text)
|
| 61 |
-
|
| 62 |
-
sentiment = Column(String)
|
| 63 |
-
|
| 64 |
-
category = Column(String)
|
| 65 |
-
|
| 66 |
-
analyzed = Column(Boolean, default=False)
|
| 67 |
-
|
| 68 |
-
# set by sentiment worker, is set to true after sentiment score is done
|
| 69 |
-
is_cleaned = Column(
|
| 70 |
-
Boolean, nullable=False, default=False, server_default=text_func("0")
|
| 71 |
-
)
|
| 72 |
-
# set by analyzed headlines, is set to true when headline is not valid
|
| 73 |
-
is_deleted = Column(
|
| 74 |
-
Boolean, nullable=False, default=False, server_default=text_func("0")
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
deleted_at = Column(Integer, nullable=True)
|
| 78 |
|
| 79 |
-
|
|
|
|
|
|
|
| 80 |
|
| 81 |
|
| 82 |
# =========================
|
|
@@ -92,6 +72,7 @@ class NewsSource(Base):
|
|
| 92 |
|
| 93 |
name = Column(String, nullable=False, unique=True)
|
| 94 |
|
|
|
|
| 95 |
website = Column(String, nullable=False)
|
| 96 |
|
| 97 |
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
|
@@ -110,76 +91,10 @@ class NewsSource(Base):
|
|
| 110 |
|
| 111 |
|
| 112 |
# =========================
|
| 113 |
-
#
|
| 114 |
# =========================
|
| 115 |
|
| 116 |
|
| 117 |
-
class Sentiment(Base):
|
| 118 |
-
|
| 119 |
-
__tablename__ = "sentiment"
|
| 120 |
-
|
| 121 |
-
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
|
| 122 |
-
|
| 123 |
-
company = Column(String)
|
| 124 |
-
|
| 125 |
-
# P(positive) - P(negative), range -1..1
|
| 126 |
-
sentiment_score = Column(Float)
|
| 127 |
-
|
| 128 |
-
# 1 - P(neutral)
|
| 129 |
-
confidence = Column(Float)
|
| 130 |
-
|
| 131 |
-
sector = Column(String)
|
| 132 |
-
|
| 133 |
-
has_been_read_company = Column(
|
| 134 |
-
Boolean, nullable=False, default=False, server_default=text_func("0")
|
| 135 |
-
)
|
| 136 |
-
|
| 137 |
-
has_been_read_sector = Column(
|
| 138 |
-
Boolean, nullable=False, default=False, server_default=text_func("0")
|
| 139 |
-
)
|
| 140 |
-
|
| 141 |
-
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
| 142 |
-
|
| 143 |
-
headline_id = Column(String)
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
# =========================
|
| 147 |
-
# SECTOR ROLLUP TABLE
|
| 148 |
-
# =========================
|
| 149 |
-
# Derived sector-level summary, refreshed once per 24h by sector_worker from the
|
| 150 |
-
# sentiment table. One row per sector (upserted in place): metrics are
|
| 151 |
-
# confidence-weighted averages, sum(score*confidence)/sum(confidence).
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
class Sector(Base):
|
| 155 |
-
|
| 156 |
-
__tablename__ = "sector"
|
| 157 |
-
|
| 158 |
-
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
|
| 159 |
-
|
| 160 |
-
sector = Column(String, nullable=False, unique=True, index=True)
|
| 161 |
-
|
| 162 |
-
# Weighted sentiment over the trailing 24h ("today").
|
| 163 |
-
sector_sentiment = Column(Float)
|
| 164 |
-
|
| 165 |
-
# Weighted sentiment over the trailing 7 days (inclusive).
|
| 166 |
-
sector_sentiment_7d_avg = Column(Float)
|
| 167 |
-
|
| 168 |
-
# sector_sentiment - sector_sentiment_7d_avg
|
| 169 |
-
sector_momentum = Column(Float)
|
| 170 |
-
|
| 171 |
-
# Forward-looking flag for a downstream consumer; reset to False on each
|
| 172 |
-
# recompute since the row's numbers change.
|
| 173 |
-
is_analyzed = Column(
|
| 174 |
-
Boolean, nullable=False, default=False, server_default=text_func("0")
|
| 175 |
-
)
|
| 176 |
-
|
| 177 |
-
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
| 178 |
-
|
| 179 |
-
# Stamped each time the row is recomputed/upserted.
|
| 180 |
-
updated_at = Column(Integer, nullable=True)
|
| 181 |
-
|
| 182 |
-
|
| 183 |
class StockSources(Base):
|
| 184 |
|
| 185 |
__tablename__ = "stock_sources"
|
|
@@ -193,6 +108,10 @@ class StockSources(Base):
|
|
| 193 |
exchange = Column(String, nullable=False)
|
| 194 |
sector = Column(String, nullable=False)
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
last_fetched_at = Column(Integer, nullable=True)
|
| 197 |
last_attempted_at = Column(Integer, nullable=True)
|
| 198 |
failure_count = Column(
|
|
@@ -201,22 +120,36 @@ class StockSources(Base):
|
|
| 201 |
failure_reason = Column(Text, nullable=True)
|
| 202 |
|
| 203 |
|
| 204 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
-
|
|
|
|
|
|
|
| 207 |
|
| 208 |
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
|
| 209 |
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
| 210 |
updated_at = Column(Integer, nullable=True)
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
)
|
|
|
|
|
|
| 1 |
# =========================
|
| 2 |
+
# models.py (scrapper)
|
| 3 |
# =========================
|
| 4 |
+
# The scrapper only needs the tables required to (a) know what to fetch and
|
| 5 |
+
# (b) hand raw HTML off to the LLM service. All parsing / sentiment / sector /
|
| 6 |
+
# stock-metric tables live in the LLM service.
|
| 7 |
|
| 8 |
from sqlalchemy import (
|
| 9 |
Column,
|
| 10 |
String,
|
| 11 |
Text,
|
| 12 |
Integer,
|
|
|
|
|
|
|
| 13 |
text as text_func,
|
|
|
|
| 14 |
)
|
| 15 |
|
| 16 |
import time
|
| 17 |
import uuid
|
|
|
|
| 18 |
from database import Base
|
| 19 |
|
| 20 |
|
| 21 |
+
# raw_html.type is stored as one of these plain lowercase strings.
|
| 22 |
+
NEWS = "news"
|
| 23 |
+
STOCK = "stock"
|
| 24 |
+
|
| 25 |
+
# Macro/market series types. Each MacroSource row carries one of these as its
|
| 26 |
+
# raw_html.type, so the LLM side can route it to the right processor.
|
| 27 |
+
FII_DII = "fii_dii"
|
| 28 |
+
VIX = "vix"
|
| 29 |
+
COMMODITY = "commodity"
|
| 30 |
+
INDEX = "index"
|
| 31 |
+
FX = "fx"
|
| 32 |
+
YIELD = "yield"
|
| 33 |
+
MACRO_TYPES = (FII_DII, VIX, COMMODITY, INDEX, FX, YIELD)
|
| 34 |
|
| 35 |
|
| 36 |
class RawHTML(Base):
|
| 37 |
+
"""A single fetched page. Stored verbatim (true raw HTML) and served to the
|
| 38 |
+
LLM service via GET_RAW_HTML, then hard-deleted via DELETE_RAW_HTML once the
|
| 39 |
+
consumer has stored its own copy.
|
| 40 |
+
|
| 41 |
+
`type` is a plain lowercase string ("news" / "stock") to keep value handling
|
| 42 |
+
unambiguous across the HTTP boundary.
|
| 43 |
+
"""
|
| 44 |
|
| 45 |
__tablename__ = "raw_html"
|
| 46 |
|
|
|
|
| 52 |
|
| 53 |
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
| 54 |
|
| 55 |
+
type = Column(String, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
+
# Market country for stock pages (e.g. "INDIA", "UNITED STATES"), derived
|
| 58 |
+
# from the StockSources row at fetch time. NULL for news pages.
|
| 59 |
+
country = Column(String, nullable=True)
|
| 60 |
|
| 61 |
|
| 62 |
# =========================
|
|
|
|
| 72 |
|
| 73 |
name = Column(String, nullable=False, unique=True)
|
| 74 |
|
| 75 |
+
# `website` is the URL the scraper fetches for this news source.
|
| 76 |
website = Column(String, nullable=False)
|
| 77 |
|
| 78 |
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
# =========================
|
| 94 |
+
# STOCK SOURCES TABLE
|
| 95 |
# =========================
|
| 96 |
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
class StockSources(Base):
|
| 99 |
|
| 100 |
__tablename__ = "stock_sources"
|
|
|
|
| 108 |
exchange = Column(String, nullable=False)
|
| 109 |
sector = Column(String, nullable=False)
|
| 110 |
|
| 111 |
+
# Pre-built Google Finance URL, computed once when the CSV is loaded so the
|
| 112 |
+
# stock_enhancer worker just fetches it instead of constructing it per tick.
|
| 113 |
+
url = Column(String, nullable=True)
|
| 114 |
+
|
| 115 |
last_fetched_at = Column(Integer, nullable=True)
|
| 116 |
last_attempted_at = Column(Integer, nullable=True)
|
| 117 |
failure_count = Column(
|
|
|
|
| 120 |
failure_reason = Column(Text, nullable=True)
|
| 121 |
|
| 122 |
|
| 123 |
+
# =========================
|
| 124 |
+
# MACRO SOURCES TABLE
|
| 125 |
+
# =========================
|
| 126 |
+
# Config-driven market/macro series (FII/DII, VIX, commodities, indices, FX,
|
| 127 |
+
# bond yields). Mirrors StockSources' fetch/failure shape but carries a `type`
|
| 128 |
+
# (one of MACRO_TYPES) and a `market` (key into config.MARKET_CONFIG) instead of
|
| 129 |
+
# ticker/sector semantics. The same generic fetch-after-close worker drives it.
|
| 130 |
+
|
| 131 |
|
| 132 |
+
class MacroSource(Base):
|
| 133 |
+
|
| 134 |
+
__tablename__ = "macro_sources"
|
| 135 |
|
| 136 |
id = Column(String, primary_key=True, index=True, default=lambda: str(uuid.uuid4()))
|
| 137 |
created_at = Column(Integer, default=lambda: int(time.time() * 1000))
|
| 138 |
updated_at = Column(Integer, nullable=True)
|
| 139 |
+
|
| 140 |
+
name = Column(String, nullable=False)
|
| 141 |
+
type = Column(String, nullable=False) # one of MACRO_TYPES
|
| 142 |
+
country = Column(String, nullable=True)
|
| 143 |
+
market = Column(String, nullable=False) # key into MARKET_CONFIG
|
| 144 |
+
exchange = Column(String, nullable=True)
|
| 145 |
+
|
| 146 |
+
# Pre-built fetch URL (Google Finance quote URL or a raw page URL), computed
|
| 147 |
+
# once when the CSV is loaded.
|
| 148 |
+
url = Column(String, nullable=True)
|
| 149 |
+
|
| 150 |
+
last_fetched_at = Column(Integer, nullable=True)
|
| 151 |
+
last_attempted_at = Column(Integer, nullable=True)
|
| 152 |
+
failure_count = Column(
|
| 153 |
+
Integer, nullable=False, default=0, server_default=text_func("0")
|
| 154 |
)
|
| 155 |
+
failure_reason = Column(Text, nullable=True)
|
requirements.txt
CHANGED
|
@@ -1,88 +1,32 @@
|
|
| 1 |
# =========================
|
| 2 |
-
#
|
| 3 |
# =========================
|
|
|
|
|
|
|
| 4 |
fastapi==0.136.1
|
| 5 |
uvicorn==0.47.0
|
| 6 |
starlette==1.0.0
|
|
|
|
|
|
|
| 7 |
python-dotenv==1.2.2
|
| 8 |
-
SQLAlchemy
|
| 9 |
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
| 16 |
urllib3==2.7.0
|
|
|
|
|
|
|
| 17 |
beautifulsoup4==4.14.3
|
|
|
|
| 18 |
lxml==6.1.0
|
| 19 |
-
lxml_html_clean==0.4.4
|
| 20 |
-
cssselect==1.4.0
|
| 21 |
-
curl_cffi==0.15.0
|
| 22 |
-
feedparser==6.0.12
|
| 23 |
-
feedfinder2==0.0.4
|
| 24 |
-
tldextract==5.3.1
|
| 25 |
-
newspaper3k==0.2.8
|
| 26 |
-
|
| 27 |
-
# =========================
|
| 28 |
-
# NLP CORE (STABLE STACK)
|
| 29 |
-
# =========================
|
| 30 |
-
spacy==3.8.14
|
| 31 |
-
thinc==8.3.13
|
| 32 |
-
blis==1.3.3
|
| 33 |
-
preshed==3.0.13
|
| 34 |
-
catalogue==2.0.10
|
| 35 |
-
confection==1.3.3
|
| 36 |
-
srsly==2.5.3
|
| 37 |
-
wasabi==1.1.3
|
| 38 |
-
|
| 39 |
-
nltk==3.9.4
|
| 40 |
-
regex==2026.5.9
|
| 41 |
-
jieba3k==0.35.1
|
| 42 |
-
tinysegmenter==0.3
|
| 43 |
-
|
| 44 |
-
# spaCy model
|
| 45 |
-
en_core_web_sm @ https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.8.0/en_core_web_sm-3.8.0-py3-none-any.whl
|
| 46 |
-
|
| 47 |
-
# =========================
|
| 48 |
-
# ML / AI STACK (SAFE FOR PY310)
|
| 49 |
-
# =========================
|
| 50 |
-
numpy==1.26.4
|
| 51 |
-
networkx>=3.3,<3.6
|
| 52 |
-
sympy==1.14.0
|
| 53 |
-
accelerate>=0.26.0
|
| 54 |
-
torch>=2.3.1
|
| 55 |
-
transformers==4.57.6
|
| 56 |
-
tokenizers>=0.21
|
| 57 |
-
safetensors>=0.4
|
| 58 |
-
huggingface_hub>=0.30
|
| 59 |
-
onnxruntime==1.17.3
|
| 60 |
-
|
| 61 |
-
# =========================
|
| 62 |
-
# DATA / UTILS
|
| 63 |
-
# =========================
|
| 64 |
pandas
|
| 65 |
-
scikit-learn
|
| 66 |
-
joblib==1.5.3
|
| 67 |
-
diskcache==5.6.3
|
| 68 |
-
python-dateutil==2.9.0.post0
|
| 69 |
-
tqdm==4.67.3
|
| 70 |
-
rich==15.0.0
|
| 71 |
|
| 72 |
-
#
|
| 73 |
-
|
| 74 |
-
# =========================
|
| 75 |
-
anyio==4.13.0
|
| 76 |
-
h11==0.16.0
|
| 77 |
-
httptools==0.7.1
|
| 78 |
-
watchfiles==1.2.0
|
| 79 |
-
websockets==16.0
|
| 80 |
-
typing_extensions==4.15.0
|
| 81 |
-
annotated-types==0.7.0
|
| 82 |
-
pydantic==2.13.4
|
| 83 |
-
pydantic_core==2.46.4
|
| 84 |
-
|
| 85 |
-
# =========================
|
| 86 |
-
# PLAYWRIGHT
|
| 87 |
-
# =========================
|
| 88 |
-
playwright
|
|
|
|
| 1 |
# =========================
|
| 2 |
+
# Scrapper service deps
|
| 3 |
# =========================
|
| 4 |
+
# Fetches pages and stores raw HTML. No ML / LLM stack here.
|
| 5 |
+
|
| 6 |
fastapi==0.136.1
|
| 7 |
uvicorn==0.47.0
|
| 8 |
starlette==1.0.0
|
| 9 |
+
pydantic==2.13.4
|
| 10 |
+
pydantic_core==2.46.4
|
| 11 |
python-dotenv==1.2.2
|
|
|
|
| 12 |
|
| 13 |
+
# DB
|
| 14 |
+
SQLAlchemy==2.0.49
|
| 15 |
+
greenlet==3.5.0
|
| 16 |
|
| 17 |
+
# Fetching
|
| 18 |
+
curl_cffi==0.15.0
|
| 19 |
+
playwright
|
| 20 |
+
certifi==2026.4.22
|
| 21 |
+
charset-normalizer==3.4.7
|
| 22 |
+
idna==3.15
|
| 23 |
urllib3==2.7.0
|
| 24 |
+
|
| 25 |
+
# HTML / CSV
|
| 26 |
beautifulsoup4==4.14.3
|
| 27 |
+
soupsieve==2.8.3
|
| 28 |
lxml==6.1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
pandas
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
# perf (non-windows)
|
| 32 |
+
uvloop==0.22.1 ; sys_platform != "win32"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
us_stocks_list.csv
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Company Name,Industry,Symbol,Series,ISIN Code,Country,Exchange
|
| 2 |
+
Apple Inc.,Technology,AAPL,,,United States,NASDAQ
|
| 3 |
+
Microsoft Corporation,Technology,MSFT,,,United States,NASDAQ
|
| 4 |
+
Alphabet Inc.,Communication Services,GOOGL,,,United States,NASDAQ
|
| 5 |
+
Amazon.com Inc.,Consumer Discretionary,AMZN,,,United States,NASDAQ
|
| 6 |
+
Meta Platforms Inc.,Communication Services,META,,,United States,NASDAQ
|
| 7 |
+
NVIDIA Corporation,Technology,NVDA,,,United States,NASDAQ
|
| 8 |
+
Tesla Inc.,Consumer Discretionary,TSLA,,,United States,NASDAQ
|
| 9 |
+
Broadcom Inc.,Technology,AVGO,,,United States,NASDAQ
|
| 10 |
+
PepsiCo Inc.,Consumer Staples,PEP,,,United States,NASDAQ
|
| 11 |
+
Costco Wholesale Corporation,Consumer Staples,COST,,,United States,NASDAQ
|
| 12 |
+
Adobe Inc.,Technology,ADBE,,,United States,NASDAQ
|
| 13 |
+
Netflix Inc.,Communication Services,NFLX,,,United States,NASDAQ
|
| 14 |
+
Advanced Micro Devices Inc.,Technology,AMD,,,United States,NASDAQ
|
| 15 |
+
Intel Corporation,Technology,INTC,,,United States,NASDAQ
|
| 16 |
+
Comcast Corporation,Communication Services,CMCSA,,,United States,NASDAQ
|
| 17 |
+
QUALCOMM Incorporated,Technology,QCOM,,,United States,NASDAQ
|
| 18 |
+
Texas Instruments Incorporated,Technology,TXN,,,United States,NASDAQ
|
| 19 |
+
Amgen Inc.,Healthcare,AMGN,,,United States,NASDAQ
|
| 20 |
+
Intuit Inc.,Technology,INTU,,,United States,NASDAQ
|
| 21 |
+
Starbucks Corporation,Consumer Discretionary,SBUX,,,United States,NASDAQ
|
| 22 |
+
JPMorgan Chase & Co.,Financial Services,JPM,,,United States,NYSE
|
| 23 |
+
Visa Inc.,Financial Services,V,,,United States,NYSE
|
| 24 |
+
Johnson & Johnson,Healthcare,JNJ,,,United States,NYSE
|
| 25 |
+
Walmart Inc.,Consumer Staples,WMT,,,United States,NYSE
|
| 26 |
+
Procter & Gamble Company,Consumer Staples,PG,,,United States,NYSE
|
| 27 |
+
Mastercard Incorporated,Financial Services,MA,,,United States,NYSE
|
| 28 |
+
The Home Depot Inc.,Consumer Discretionary,HD,,,United States,NYSE
|
| 29 |
+
Bank of America Corporation,Financial Services,BAC,,,United States,NYSE
|
| 30 |
+
Exxon Mobil Corporation,Energy,XOM,,,United States,NYSE
|
| 31 |
+
Chevron Corporation,Energy,CVX,,,United States,NYSE
|
| 32 |
+
The Coca-Cola Company,Consumer Staples,KO,,,United States,NYSE
|
| 33 |
+
Pfizer Inc.,Healthcare,PFE,,,United States,NYSE
|
| 34 |
+
The Walt Disney Company,Communication Services,DIS,,,United States,NYSE
|
| 35 |
+
Merck & Co. Inc.,Healthcare,MRK,,,United States,NYSE
|
| 36 |
+
AbbVie Inc.,Healthcare,ABBV,,,United States,NYSE
|
| 37 |
+
Salesforce Inc.,Technology,CRM,,,United States,NYSE
|
| 38 |
+
NIKE Inc.,Consumer Discretionary,NKE,,,United States,NYSE
|
| 39 |
+
McDonald's Corporation,Consumer Discretionary,MCD,,,United States,NYSE
|
| 40 |
+
Oracle Corporation,Technology,ORCL,,,United States,NYSE
|
| 41 |
+
UnitedHealth Group Incorporated,Healthcare,UNH,,,United States,NYSE
|
utils.py
CHANGED
|
@@ -4,7 +4,8 @@ from datetime import datetime, timedelta
|
|
| 4 |
from zoneinfo import ZoneInfo
|
| 5 |
|
| 6 |
IST = ZoneInfo("Asia/Kolkata")
|
| 7 |
-
|
|
|
|
| 8 |
from logger import logger
|
| 9 |
|
| 10 |
|
|
@@ -116,20 +117,6 @@ def fetch_html(url: str):
|
|
| 116 |
pass
|
| 117 |
|
| 118 |
|
| 119 |
-
def _normalize_na(value) -> str:
|
| 120 |
-
"""Coerce empty / placeholder model output to the literal "NA"."""
|
| 121 |
-
|
| 122 |
-
if value is None:
|
| 123 |
-
return "NA"
|
| 124 |
-
|
| 125 |
-
s = str(value).strip()
|
| 126 |
-
|
| 127 |
-
if not s or s.lower() in {"na", "n/a", "none", "null", "unknown", "-", ""}:
|
| 128 |
-
return "NA"
|
| 129 |
-
|
| 130 |
-
return s
|
| 131 |
-
|
| 132 |
-
|
| 133 |
def seconds_until_next_monday():
|
| 134 |
now = datetime.now(IST)
|
| 135 |
|
|
@@ -146,7 +133,64 @@ def seconds_until_next_monday():
|
|
| 146 |
return max(1, int((next_monday - now).total_seconds()))
|
| 147 |
|
| 148 |
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from zoneinfo import ZoneInfo
|
| 5 |
|
| 6 |
IST = ZoneInfo("Asia/Kolkata")
|
| 7 |
+
ET = ZoneInfo("America/New_York")
|
| 8 |
+
from config import HTTP_REQUEST_TIMEOUT_SECONDS, MARKET_CONFIG
|
| 9 |
from logger import logger
|
| 10 |
|
| 11 |
|
|
|
|
| 117 |
pass
|
| 118 |
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
def seconds_until_next_monday():
|
| 121 |
now = datetime.now(IST)
|
| 122 |
|
|
|
|
| 133 |
return max(1, int((next_monday - now).total_seconds()))
|
| 134 |
|
| 135 |
|
| 136 |
+
# =========================
|
| 137 |
+
# MARKET-CLOSE SCHEDULING
|
| 138 |
+
# =========================
|
| 139 |
+
# Generalizes seconds_until_next_monday() to "next weekday close for a market".
|
| 140 |
+
# The stock/macro workers wake at the soonest close across markets, fetch the
|
| 141 |
+
# rows for whichever market closed today, then sleep again to the next close.
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _market_tz(market: str) -> ZoneInfo:
|
| 145 |
+
"""ZoneInfo for a market key in MARKET_CONFIG (defaults to IST)."""
|
| 146 |
+
|
| 147 |
+
cfg = MARKET_CONFIG.get(market)
|
| 148 |
+
return ZoneInfo(cfg["tz"]) if cfg else IST
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _market_close_today(market: str, now: datetime) -> datetime:
|
| 152 |
+
"""Today's close datetime (tz-aware) for `market`, on the date of `now`."""
|
| 153 |
+
|
| 154 |
+
hour, minute = MARKET_CONFIG[market]["close"]
|
| 155 |
+
return now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def market_already_closed_today(market: str) -> bool:
|
| 159 |
+
"""True if `market` is on a weekday and its close time has already passed."""
|
| 160 |
+
|
| 161 |
+
now = datetime.now(_market_tz(market))
|
| 162 |
+
|
| 163 |
+
# Saturday=5, Sunday=6 — markets shut.
|
| 164 |
+
if now.weekday() in (5, 6):
|
| 165 |
+
return False
|
| 166 |
+
|
| 167 |
+
return now >= _market_close_today(market, now)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def seconds_until_next_market_close(market: str) -> int:
|
| 171 |
+
"""Seconds until `market`'s next close, skipping weekends.
|
| 172 |
+
|
| 173 |
+
If today is a weekday and the close hasn't happened yet, that's the target.
|
| 174 |
+
Otherwise advance day-by-day to the next weekday's close.
|
| 175 |
+
"""
|
| 176 |
+
|
| 177 |
+
now = datetime.now(_market_tz(market))
|
| 178 |
+
close = _market_close_today(market, now)
|
| 179 |
+
|
| 180 |
+
# Move to the next valid close: future + on a weekday.
|
| 181 |
+
while close <= now or close.weekday() in (5, 6):
|
| 182 |
+
close = close + timedelta(days=1)
|
| 183 |
+
|
| 184 |
+
return max(1, int((close - now).total_seconds()))
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def get_market_day_start_ms(market: str) -> int:
|
| 188 |
+
"""Epoch-ms for the start (00:00) of the current day in `market`'s tz.
|
| 189 |
+
|
| 190 |
+
Used to gate "already fetched today": a row whose last_fetched_at is >= this
|
| 191 |
+
value has been fetched during the current market day and is skipped.
|
| 192 |
+
"""
|
| 193 |
+
|
| 194 |
+
now = datetime.now(_market_tz(market))
|
| 195 |
+
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
| 196 |
+
return int(start.timestamp() * 1000)
|
workers.py
CHANGED
|
@@ -1,56 +1,31 @@
|
|
| 1 |
import os
|
| 2 |
import signal
|
| 3 |
import time
|
| 4 |
-
import re
|
| 5 |
from threading import Thread, Lock, Event
|
| 6 |
-
from datetime import datetime, timedelta
|
| 7 |
-
from zoneinfo import ZoneInfo
|
| 8 |
|
| 9 |
-
IST = ZoneInfo("Asia/Kolkata")
|
| 10 |
from logger import logger
|
| 11 |
-
from models import
|
| 12 |
from db_helpers import (
|
| 13 |
-
get_stock_for_enrichment,
|
| 14 |
-
mark_clean_headlines_past_7d,
|
| 15 |
read_sources,
|
| 16 |
update_last_fetched,
|
| 17 |
record_failure,
|
| 18 |
store_raw_html,
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
store_headlines,
|
| 22 |
-
get_unanalyzed_article,
|
| 23 |
-
update_article_analysis,
|
| 24 |
-
soft_delete_invalid_headlines,
|
| 25 |
-
get_analyzed_article,
|
| 26 |
-
mark_article_cleaned,
|
| 27 |
-
store_sentiment,
|
| 28 |
-
update_sector_table,
|
| 29 |
-
mark_clean_headlines_past_7d,
|
| 30 |
-
update_stock_data,
|
| 31 |
-
update_stock_table,
|
| 32 |
-
)
|
| 33 |
-
from llm import (
|
| 34 |
-
extract_headlines,
|
| 35 |
-
analyze_article,
|
| 36 |
-
extract_company_sector,
|
| 37 |
-
extract_stock_metrics_from_html,
|
| 38 |
-
predict_own_sentiment,
|
| 39 |
)
|
| 40 |
from config import (
|
| 41 |
SCRAPER_TICK_SECONDS,
|
| 42 |
SCRAPE_INTERVAL_MS,
|
| 43 |
MAX_CONSECUTIVE_FAILURES,
|
| 44 |
-
HEADLINE_IDLE_SECONDS,
|
| 45 |
-
SENTIMENT_IDLE_SECONDS,
|
| 46 |
-
SENTIMENT_TABLE_IDLE_SECONDS,
|
| 47 |
WORKER_ERROR_BACKOFF_SECONDS,
|
| 48 |
-
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
)
|
| 51 |
-
from utils import fetch_html, seconds_until_next_monday
|
| 52 |
|
| 53 |
-
last_nse_top_fetched = 0
|
| 54 |
|
| 55 |
# =========================
|
| 56 |
# WORKER LIFECYCLE STATE
|
|
@@ -62,65 +37,6 @@ workers_lock = Lock()
|
|
| 62 |
_workers: dict[str, dict] = {}
|
| 63 |
|
| 64 |
|
| 65 |
-
# =========================
|
| 66 |
-
# HELPER - FINANCIAL SPECIFIC
|
| 67 |
-
# =========================
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
def parse_market_number(value):
|
| 71 |
-
|
| 72 |
-
if value is None:
|
| 73 |
-
return None
|
| 74 |
-
|
| 75 |
-
value = str(value).strip()
|
| 76 |
-
|
| 77 |
-
if not value:
|
| 78 |
-
return None
|
| 79 |
-
|
| 80 |
-
if value in {"—", "-", "N/A", "NA", "null", "None"}:
|
| 81 |
-
return None
|
| 82 |
-
|
| 83 |
-
value = value.replace(",", "")
|
| 84 |
-
value = value.replace("₹", "")
|
| 85 |
-
value = value.replace("$", "")
|
| 86 |
-
|
| 87 |
-
match = re.match(
|
| 88 |
-
r"^([-+]?\d*\.?\d+)\s*([a-zA-Z]*)$",
|
| 89 |
-
value,
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
if not match:
|
| 93 |
-
return None
|
| 94 |
-
|
| 95 |
-
number = float(match.group(1))
|
| 96 |
-
|
| 97 |
-
suffix = match.group(2).lower()
|
| 98 |
-
|
| 99 |
-
multipliers = {
|
| 100 |
-
"k": 1_000,
|
| 101 |
-
"m": 1_000_000,
|
| 102 |
-
"mn": 1_000_000,
|
| 103 |
-
"million": 1_000_000,
|
| 104 |
-
"b": 1_000_000_000,
|
| 105 |
-
"bn": 1_000_000_000,
|
| 106 |
-
"billion": 1_000_000_000,
|
| 107 |
-
"t": 1_000_000_000_000,
|
| 108 |
-
"tn": 1_000_000_000_000,
|
| 109 |
-
"l": 100_000,
|
| 110 |
-
"lac": 100_000,
|
| 111 |
-
"lakh": 100_000,
|
| 112 |
-
"cr": 10_000_000,
|
| 113 |
-
"crore": 10_000_000,
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
multiplier = multipliers.get(
|
| 117 |
-
suffix,
|
| 118 |
-
1,
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
return number * multiplier
|
| 122 |
-
|
| 123 |
-
|
| 124 |
# =========================
|
| 125 |
# WORKER LOOP HARNESS
|
| 126 |
# =========================
|
|
@@ -128,9 +44,6 @@ def parse_market_number(value):
|
|
| 128 |
# private *_iteration that runs exactly one pass. The harness loops the
|
| 129 |
# iteration, catching unhandled exceptions so a surprise error in any one
|
| 130 |
# pass logs + backs off + continues, rather than killing the thread silently.
|
| 131 |
-
#
|
| 132 |
-
# Each worker receives its own stop_event so it can be halted independently
|
| 133 |
-
# without affecting any other running worker.
|
| 134 |
|
| 135 |
|
| 136 |
def _safe_worker_loop(name: str, iteration_fn, stop_event: Event):
|
|
@@ -148,14 +61,13 @@ def _safe_worker_loop(name: str, iteration_fn, stop_event: Event):
|
|
| 148 |
|
| 149 |
|
| 150 |
# =========================
|
| 151 |
-
# SCRAPER WORKER
|
| 152 |
# =========================
|
| 153 |
|
| 154 |
|
| 155 |
def _scraping_iteration(stop_event: Event):
|
| 156 |
-
"""
|
| 157 |
-
|
| 158 |
-
"""
|
| 159 |
logger.info("scraper tick: reading source list")
|
| 160 |
sources = read_sources("news")
|
| 161 |
now_ms = int(time.time() * 1000)
|
|
@@ -194,7 +106,6 @@ def _scraping_iteration(stop_event: Event):
|
|
| 194 |
|
| 195 |
source_name = source.name
|
| 196 |
url = source.website
|
| 197 |
-
type = source.type
|
| 198 |
attempted += 1
|
| 199 |
|
| 200 |
logger.info(f"scraper: fetching {source_name} ({url})")
|
|
@@ -205,10 +116,10 @@ def _scraping_iteration(stop_event: Event):
|
|
| 205 |
if html is None:
|
| 206 |
logger.error(f"{source_name} fetch failed")
|
| 207 |
logger.error(f"due to {error_reason}")
|
| 208 |
-
record_failure(source.id, str(
|
| 209 |
continue
|
| 210 |
|
| 211 |
-
raw_id = store_raw_html(source_name, html,
|
| 212 |
update_last_fetched(source.id)
|
| 213 |
logger.info(f"Stored {source_name} -> {raw_id}")
|
| 214 |
|
|
@@ -230,387 +141,143 @@ def scraping_worker(stop_event: Event):
|
|
| 230 |
|
| 231 |
|
| 232 |
# =========================
|
| 233 |
-
#
|
| 234 |
# =========================
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
|
| 237 |
-
def
|
| 238 |
-
"""
|
| 239 |
-
This worker is supposed to query database for unparsed RAW HTML from news sources and get headlines out of it and store in Parsed Article table
|
| 240 |
-
"""
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
|
|
|
| 244 |
|
| 245 |
-
if not
|
| 246 |
-
logger.
|
| 247 |
-
|
| 248 |
-
)
|
| 249 |
-
stop_event.wait(HEADLINE_IDLE_SECONDS)
|
| 250 |
return
|
| 251 |
|
| 252 |
-
logger.info(
|
| 253 |
-
f"headline tick: processing raw_html {record.id} "
|
| 254 |
-
f"(source={record.source}, {len(record.html)} chars)"
|
| 255 |
-
)
|
| 256 |
|
| 257 |
try:
|
| 258 |
-
|
| 259 |
-
headlines = extract_headlines(record.html)
|
| 260 |
-
logger.info(
|
| 261 |
-
f"headline: extract_headlines returned {len(headlines)} headlines "
|
| 262 |
-
f"in {time.time() - started:.1f}s for {record.id}"
|
| 263 |
-
)
|
| 264 |
-
store_headlines(record.id, headlines)
|
| 265 |
-
mark_as_parsed(record.id)
|
| 266 |
-
logger.info(f"headline tick done: {record.id} parsed")
|
| 267 |
-
|
| 268 |
-
except Exception:
|
| 269 |
-
logger.exception(f"Headline extraction failed for {record.id}")
|
| 270 |
-
# Mark as parsed so a poison record can't hot-loop the worker.
|
| 271 |
-
mark_as_parsed(record.id)
|
| 272 |
-
stop_event.wait(WORKER_ERROR_BACKOFF_SECONDS)
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
def headline_worker(stop_event: Event):
|
| 276 |
-
_safe_worker_loop("headline_worker", _headline_iteration, stop_event)
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
# =========================
|
| 280 |
-
# SENTIMENT WORKER
|
| 281 |
-
# =========================
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
def _sentiment_iteration(stop_event: Event):
|
| 285 |
-
"""
|
| 286 |
-
This worker is suppossed to query database for unanalyzed article and get sentiment out of it and update the Parsed Article table
|
| 287 |
-
"""
|
| 288 |
-
logger.info("sentiment tick: looking for unanalyzed article")
|
| 289 |
-
article = get_unanalyzed_article()
|
| 290 |
-
|
| 291 |
-
if not article:
|
| 292 |
-
logger.info(
|
| 293 |
-
f"sentiment tick: nothing to do; " f"sleeping {SENTIMENT_IDLE_SECONDS}s"
|
| 294 |
-
)
|
| 295 |
-
stop_event.wait(SENTIMENT_IDLE_SECONDS)
|
| 296 |
-
return
|
| 297 |
-
|
| 298 |
-
preview = (article.title or "")[:80]
|
| 299 |
-
|
| 300 |
-
logger.info(f"sentiment tick: analyzing article " f"{article.id} title={preview!r}")
|
| 301 |
-
|
| 302 |
-
try:
|
| 303 |
-
started = time.time()
|
| 304 |
-
|
| 305 |
-
result = analyze_article(article.title)
|
| 306 |
-
|
| 307 |
-
elapsed = time.time() - started
|
| 308 |
-
|
| 309 |
-
logger.info(
|
| 310 |
-
f"sentiment: result for {article.id} "
|
| 311 |
-
f"is_valid={result['is_valid']} "
|
| 312 |
-
f"({elapsed:.1f}s)"
|
| 313 |
-
)
|
| 314 |
-
|
| 315 |
-
if not result["is_valid"]:
|
| 316 |
-
|
| 317 |
-
logger.info(f"sentiment: soft deleting invalid headline " f"{article.id}")
|
| 318 |
-
|
| 319 |
-
# soft delete invalid headlines
|
| 320 |
-
soft_delete_invalid_headlines(article.id)
|
| 321 |
-
|
| 322 |
-
logger.info(f"sentiment tick done: {article.id} soft deleted")
|
| 323 |
|
|
|
|
|
|
|
|
|
|
| 324 |
return
|
| 325 |
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
f"category={result['category']}"
|
| 330 |
-
)
|
| 331 |
-
|
| 332 |
-
update_article_analysis(article.id, result["sentiment"], result["category"])
|
| 333 |
-
|
| 334 |
-
logger.info(f"sentiment tick done: {article.id} analyzed")
|
| 335 |
|
| 336 |
except Exception:
|
|
|
|
|
|
|
| 337 |
|
| 338 |
-
logger.exception(f"Sentiment analysis failed for {article.id}")
|
| 339 |
-
|
| 340 |
-
stop_event.wait(WORKER_ERROR_BACKOFF_SECONDS)
|
| 341 |
|
|
|
|
|
|
|
| 342 |
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
|
|
|
| 345 |
|
| 346 |
|
| 347 |
# =========================
|
| 348 |
-
#
|
| 349 |
# =========================
|
| 350 |
-
#
|
| 351 |
-
#
|
| 352 |
-
#
|
| 353 |
-
# sector. The source clean_headlines row is flagged is_analyzed=True so the
|
| 354 |
-
# cleanup worker can retire it once it has also been fetched enough times.
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
def _sentiment_table_iteration(stop_event: Event):
|
| 358 |
-
|
| 359 |
-
logger.info("sentiment_table tick: looking for unscored headline")
|
| 360 |
-
headline = get_analyzed_article()
|
| 361 |
-
|
| 362 |
-
if not headline:
|
| 363 |
-
logger.info(
|
| 364 |
-
f"sentiment_table tick: nothing to do; "
|
| 365 |
-
f"sleeping {SENTIMENT_TABLE_IDLE_SECONDS}s"
|
| 366 |
-
)
|
| 367 |
-
stop_event.wait(SENTIMENT_TABLE_IDLE_SECONDS)
|
| 368 |
-
else:
|
| 369 |
-
headline_id = headline.id
|
| 370 |
-
text = headline.title
|
| 371 |
-
result = extract_company_sector(text)
|
| 372 |
-
|
| 373 |
-
is_valid, ticker, company, sector = (
|
| 374 |
-
result["is_valid"],
|
| 375 |
-
result["ticker"],
|
| 376 |
-
result["company"],
|
| 377 |
-
result["sector"],
|
| 378 |
-
)
|
| 379 |
|
| 380 |
-
if is_valid:
|
| 381 |
-
preview = (text or "")[:80]
|
| 382 |
-
logger.info(f"sentiment_table tick: scoring {headline_id} text={preview!r}")
|
| 383 |
|
| 384 |
-
|
| 385 |
-
started = time.time()
|
| 386 |
|
| 387 |
-
|
| 388 |
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
confidence=result["confidence"],
|
| 393 |
-
sector=sector,
|
| 394 |
-
headline_id=headline_id,
|
| 395 |
-
)
|
| 396 |
|
| 397 |
-
|
|
|
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
)
|
| 405 |
|
| 406 |
-
|
| 407 |
-
# Transient failure (model/DB) must not lose the row — leave it
|
| 408 |
-
# unscored so it is retried on the next tick.
|
| 409 |
-
mark_article_cleaned(headline_id)
|
| 410 |
-
logger.exception(f"sentiment_table failed for {headline_id}")
|
| 411 |
-
stop_event.wait(WORKER_ERROR_BACKOFF_SECONDS)
|
| 412 |
-
else:
|
| 413 |
-
mark_article_cleaned(headline_id)
|
| 414 |
-
logger.exception(f"sentiment_table failed for {headline_id}")
|
| 415 |
|
| 416 |
|
| 417 |
-
def
|
| 418 |
-
_safe_worker_loop("
|
| 419 |
|
| 420 |
|
| 421 |
# =========================
|
| 422 |
-
#
|
| 423 |
# =========================
|
| 424 |
-
#
|
| 425 |
-
# (
|
| 426 |
-
#
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
def _sector_iteration(stop_event: Event):
|
| 430 |
-
|
| 431 |
-
logger.info("sector tick: recomputing sector rollup from sentiment table")
|
| 432 |
-
result = update_sector_table()
|
| 433 |
-
# Flag clean_headlines whose sentiment has aged out of the 7-day window so
|
| 434 |
-
# the cleanup worker can retire them (they won't be read again).
|
| 435 |
-
flagged = mark_clean_headlines_past_7d()
|
| 436 |
-
|
| 437 |
-
logger.info(
|
| 438 |
-
f"sector tick done: inserted={result['inserted']} "
|
| 439 |
-
f"updated={result['updated']} clean_headlines_flagged={flagged}; "
|
| 440 |
-
f"sleeping {SECTOR_TICK_SECONDS}s"
|
| 441 |
-
)
|
| 442 |
-
stop_event.wait(SECTOR_TICK_SECONDS)
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
def sector_worker(stop_event: Event):
|
| 446 |
-
_safe_worker_loop("sector_worker", _sector_iteration, stop_event)
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
def get_indian_stock_detail_from_google(
|
| 450 |
-
stock: Stock,
|
| 451 |
-
):
|
| 452 |
-
|
| 453 |
-
if not stock:
|
| 454 |
-
return
|
| 455 |
|
| 456 |
-
ticker = stock.ticker
|
| 457 |
-
stock_id = stock.id
|
| 458 |
-
logger.info(f"enriching stock " f"{ticker}")
|
| 459 |
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
url = f"https://www.google.com/finance/quote/{ticker}:NSE?tab=overview"
|
| 463 |
-
|
| 464 |
-
stock_data_raw, error = fetch_html(url)
|
| 465 |
-
if not stock_data_raw:
|
| 466 |
-
update_stock_table(stock_id, error)
|
| 467 |
-
logger.error(f"unable to get stock info due to error:{error}")
|
| 468 |
-
return
|
| 469 |
|
| 470 |
-
|
| 471 |
-
raw_html_id = store_raw_html("Google Finance", stock_data_raw, "STOCK")
|
| 472 |
-
parsed = extract_stock_metrics_from_html(stock_data_raw)
|
| 473 |
-
|
| 474 |
-
if not parsed:
|
| 475 |
-
|
| 476 |
-
logger.warning(f"no google metrics " f"for {ticker}")
|
| 477 |
-
|
| 478 |
-
return
|
| 479 |
-
|
| 480 |
-
logger.debug(f"Returning metrics from html are {parsed}")
|
| 481 |
-
|
| 482 |
-
open_val = parse_market_number(parsed.get("Open"))
|
| 483 |
-
high_val = parse_market_number(parsed.get("High"))
|
| 484 |
-
low_val = parse_market_number(parsed.get("Low"))
|
| 485 |
-
close_val = parse_market_number(parsed.get("Close"))
|
| 486 |
-
volume_val = parse_market_number(parsed.get("volume"))
|
| 487 |
-
|
| 488 |
-
is_complete = all(
|
| 489 |
-
x is not None
|
| 490 |
-
for x in (
|
| 491 |
-
open_val,
|
| 492 |
-
high_val,
|
| 493 |
-
low_val,
|
| 494 |
-
close_val,
|
| 495 |
-
volume_val,
|
| 496 |
-
)
|
| 497 |
-
)
|
| 498 |
-
|
| 499 |
-
update_stock_data(
|
| 500 |
-
stock.id,
|
| 501 |
-
{
|
| 502 |
-
"open": open_val,
|
| 503 |
-
"high": high_val,
|
| 504 |
-
"low": low_val,
|
| 505 |
-
"close": close_val,
|
| 506 |
-
"volume": volume_val,
|
| 507 |
-
"is_complete": is_complete,
|
| 508 |
-
"raw_html_id": raw_html_id,
|
| 509 |
-
},
|
| 510 |
-
)
|
| 511 |
-
|
| 512 |
-
except Exception:
|
| 513 |
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
day_map = {
|
| 518 |
-
0: "Monday",
|
| 519 |
-
1: "Tuesday",
|
| 520 |
-
2: "Wednesday",
|
| 521 |
-
3: "Thrusday",
|
| 522 |
-
4: "Friday",
|
| 523 |
-
5: "Saturday",
|
| 524 |
-
6: "Sunday",
|
| 525 |
-
}
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
def stock_enrichment_iteration(
|
| 529 |
-
stop_event: Event,
|
| 530 |
-
):
|
| 531 |
-
now = datetime.now(IST)
|
| 532 |
-
day = day_map[now.weekday()]
|
| 533 |
-
|
| 534 |
-
#
|
| 535 |
-
# Sunday
|
| 536 |
-
#
|
| 537 |
-
if now.weekday() in (5, 6):
|
| 538 |
-
sleep_seconds = seconds_until_next_monday()
|
| 539 |
-
|
| 540 |
-
logger.info(
|
| 541 |
-
f"Today is {day}. Markets are closed. "
|
| 542 |
-
f"Sleeping stock enrichment worker for "
|
| 543 |
-
f"{sleep_seconds / 3600:.1f} hours."
|
| 544 |
-
)
|
| 545 |
-
|
| 546 |
-
stop_event.wait(sleep_seconds)
|
| 547 |
-
return
|
| 548 |
-
|
| 549 |
-
logger.info(f"Today is {day}. ")
|
| 550 |
-
stock = get_stock_for_enrichment()
|
| 551 |
-
|
| 552 |
-
if not stock:
|
| 553 |
-
|
| 554 |
-
logger.info(f"No stock found for enrichment")
|
| 555 |
-
stop_event.wait(HEADLINE_IDLE_SECONDS)
|
| 556 |
-
|
| 557 |
-
return
|
| 558 |
-
|
| 559 |
-
try:
|
| 560 |
-
|
| 561 |
-
get_indian_stock_detail_from_google(stock)
|
| 562 |
-
|
| 563 |
-
except Exception:
|
| 564 |
|
| 565 |
-
|
|
|
|
| 566 |
|
| 567 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
|
|
|
|
| 569 |
|
| 570 |
-
def stock_enrichment_worker(
|
| 571 |
-
stop_event: Event,
|
| 572 |
-
):
|
| 573 |
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
stock_enrichment_iteration,
|
| 577 |
-
stop_event,
|
| 578 |
-
)
|
| 579 |
|
| 580 |
|
| 581 |
# =========================
|
| 582 |
# WORKER REGISTRY
|
| 583 |
# =========================
|
| 584 |
# Maps a stable name → the worker's entry-point function.
|
| 585 |
-
# Add new workers here and they automatically work with all control functions.
|
| 586 |
# Registered actions:
|
| 587 |
-
# START_SCRAPER
|
| 588 |
-
#
|
| 589 |
-
#
|
| 590 |
-
# START_CLEANUP / STOP_CLEANUP / RESTART_CLEANUP (Retired)
|
| 591 |
-
# START_PURGE / STOP_PURGE / RESTART_PURGE (Retired)
|
| 592 |
|
| 593 |
_WORKER_REGISTRY: dict[str, callable] = {
|
| 594 |
"scraper": scraping_worker,
|
| 595 |
-
"headline": headline_worker,
|
| 596 |
-
"sentiment": sentiment_worker,
|
| 597 |
-
# Exposed as START_SENTIMENT_TABLE / STOP_* / RESTART_*
|
| 598 |
-
"sentiment_table": sentiment_table_worker,
|
| 599 |
-
# Exposed as START_SECTOR / STOP_SECTOR / RESTART_SECTOR
|
| 600 |
-
# "sector": sector_worker,
|
| 601 |
"stock_enhancer": stock_enrichment_worker,
|
|
|
|
| 602 |
}
|
| 603 |
|
|
|
|
| 604 |
# =========================
|
| 605 |
# INTERNAL HELPERS
|
| 606 |
# =========================
|
| 607 |
|
| 608 |
|
| 609 |
def _launch_worker(name: str):
|
| 610 |
-
"""
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
"""
|
| 614 |
if name not in _WORKER_REGISTRY:
|
| 615 |
raise ValueError(
|
| 616 |
f"Unknown worker: {name!r}. Valid names: {list(_WORKER_REGISTRY)}"
|
|
@@ -630,6 +297,7 @@ def _launch_worker(name: str):
|
|
| 630 |
|
| 631 |
def _signal_and_join(name: str, stop_event: Event, thread: Thread, timeout: float):
|
| 632 |
"""Signal a worker's event and wait for its thread to exit."""
|
|
|
|
| 633 |
logger.info(f"stop: signaling '{name}'")
|
| 634 |
stop_event.set()
|
| 635 |
thread.join(timeout=timeout)
|
|
@@ -645,15 +313,9 @@ def _signal_and_join(name: str, stop_event: Event, thread: Thread, timeout: floa
|
|
| 645 |
|
| 646 |
|
| 647 |
def start_workers(names: list[str] | None = None):
|
| 648 |
-
"""
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
Examples
|
| 653 |
-
--------
|
| 654 |
-
start_workers() # start everything
|
| 655 |
-
start_workers(["scraper", "headline"]) # start a subset
|
| 656 |
-
"""
|
| 657 |
targets = names or list(_WORKER_REGISTRY.keys())
|
| 658 |
|
| 659 |
with workers_lock:
|
|
@@ -668,14 +330,8 @@ def start_workers(names: list[str] | None = None):
|
|
| 668 |
|
| 669 |
|
| 670 |
def stop_worker(name: str, timeout: float = 5.0):
|
| 671 |
-
"""
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
Examples
|
| 675 |
-
--------
|
| 676 |
-
stop_worker("scraper")
|
| 677 |
-
stop_worker("headline", timeout=10.0)
|
| 678 |
-
"""
|
| 679 |
with workers_lock:
|
| 680 |
entry = _workers.get(name)
|
| 681 |
if not entry:
|
|
@@ -687,14 +343,8 @@ def stop_worker(name: str, timeout: float = 5.0):
|
|
| 687 |
|
| 688 |
|
| 689 |
def stop_workers(names: list[str] | None = None, timeout: float = 5.0):
|
| 690 |
-
"""
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
Examples
|
| 694 |
-
--------
|
| 695 |
-
stop_workers() # full shutdown
|
| 696 |
-
stop_workers(["scraper", "sentiment"]) # stop a subset
|
| 697 |
-
"""
|
| 698 |
targets = names or list(_workers.keys())
|
| 699 |
for name in targets:
|
| 700 |
stop_worker(name, timeout=timeout)
|
|
@@ -702,13 +352,8 @@ def stop_workers(names: list[str] | None = None, timeout: float = 5.0):
|
|
| 702 |
|
| 703 |
|
| 704 |
def restart_worker(name: str, timeout: float = 5.0):
|
| 705 |
-
"""
|
| 706 |
-
Stop a worker (if running) then immediately restart it.
|
| 707 |
|
| 708 |
-
Examples
|
| 709 |
-
--------
|
| 710 |
-
restart_worker("scraper")
|
| 711 |
-
"""
|
| 712 |
with workers_lock:
|
| 713 |
entry = _workers.get(name)
|
| 714 |
if entry:
|
|
@@ -719,14 +364,8 @@ def restart_worker(name: str, timeout: float = 5.0):
|
|
| 719 |
|
| 720 |
|
| 721 |
def worker_status() -> dict[str, bool]:
|
| 722 |
-
"""
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
Examples
|
| 726 |
-
--------
|
| 727 |
-
status = worker_status()
|
| 728 |
-
# {"scraper": True, "headline": True, "sentiment": False, ...}
|
| 729 |
-
"""
|
| 730 |
with workers_lock:
|
| 731 |
return {name: entry["thread"].is_alive() for name, entry in _workers.items()}
|
| 732 |
|
|
|
|
| 1 |
import os
|
| 2 |
import signal
|
| 3 |
import time
|
|
|
|
| 4 |
from threading import Thread, Lock, Event
|
|
|
|
|
|
|
| 5 |
|
|
|
|
| 6 |
from logger import logger
|
| 7 |
+
from models import StockSources, MacroSource, NEWS, STOCK
|
| 8 |
from db_helpers import (
|
|
|
|
|
|
|
| 9 |
read_sources,
|
| 10 |
update_last_fetched,
|
| 11 |
record_failure,
|
| 12 |
store_raw_html,
|
| 13 |
+
get_sources_due_for_market,
|
| 14 |
+
update_source_table,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
)
|
| 16 |
from config import (
|
| 17 |
SCRAPER_TICK_SECONDS,
|
| 18 |
SCRAPE_INTERVAL_MS,
|
| 19 |
MAX_CONSECUTIVE_FAILURES,
|
|
|
|
|
|
|
|
|
|
| 20 |
WORKER_ERROR_BACKOFF_SECONDS,
|
| 21 |
+
MARKET_CONFIG,
|
| 22 |
+
)
|
| 23 |
+
from utils import (
|
| 24 |
+
fetch_html,
|
| 25 |
+
seconds_until_next_market_close,
|
| 26 |
+
market_already_closed_today,
|
| 27 |
)
|
|
|
|
| 28 |
|
|
|
|
| 29 |
|
| 30 |
# =========================
|
| 31 |
# WORKER LIFECYCLE STATE
|
|
|
|
| 37 |
_workers: dict[str, dict] = {}
|
| 38 |
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
# =========================
|
| 41 |
# WORKER LOOP HARNESS
|
| 42 |
# =========================
|
|
|
|
| 44 |
# private *_iteration that runs exactly one pass. The harness loops the
|
| 45 |
# iteration, catching unhandled exceptions so a surprise error in any one
|
| 46 |
# pass logs + backs off + continues, rather than killing the thread silently.
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def _safe_worker_loop(name: str, iteration_fn, stop_event: Event):
|
|
|
|
| 61 |
|
| 62 |
|
| 63 |
# =========================
|
| 64 |
+
# SCRAPER WORKER (news)
|
| 65 |
# =========================
|
| 66 |
|
| 67 |
|
| 68 |
def _scraping_iteration(stop_event: Event):
|
| 69 |
+
"""Scrape news sources and store their RAW HTML into the database."""
|
| 70 |
+
|
|
|
|
| 71 |
logger.info("scraper tick: reading source list")
|
| 72 |
sources = read_sources("news")
|
| 73 |
now_ms = int(time.time() * 1000)
|
|
|
|
| 106 |
|
| 107 |
source_name = source.name
|
| 108 |
url = source.website
|
|
|
|
| 109 |
attempted += 1
|
| 110 |
|
| 111 |
logger.info(f"scraper: fetching {source_name} ({url})")
|
|
|
|
| 116 |
if html is None:
|
| 117 |
logger.error(f"{source_name} fetch failed")
|
| 118 |
logger.error(f"due to {error_reason}")
|
| 119 |
+
record_failure(source.id, str(error_reason))
|
| 120 |
continue
|
| 121 |
|
| 122 |
+
raw_id = store_raw_html(source_name, html, NEWS)
|
| 123 |
update_last_fetched(source.id)
|
| 124 |
logger.info(f"Stored {source_name} -> {raw_id}")
|
| 125 |
|
|
|
|
| 141 |
|
| 142 |
|
| 143 |
# =========================
|
| 144 |
+
# SHARED FETCH HELPER (stock + macro)
|
| 145 |
# =========================
|
| 146 |
+
# Both the stock and macro workers fetch a pre-built URL and store the raw HTML;
|
| 147 |
+
# only the source label and raw_html.type differ. update_source_table stamps
|
| 148 |
+
# last_fetched_at so the row drops out of "due" for the rest of the market day.
|
| 149 |
|
| 150 |
|
| 151 |
+
def _fetch_and_store(row, model, source_label: str, type_: str):
|
| 152 |
+
"""Fetch row.url and store it as raw_html of `type_`. Stamps the source row."""
|
|
|
|
|
|
|
| 153 |
|
| 154 |
+
ident = getattr(row, "name", None) or getattr(row, "ticker", None) or row.id
|
| 155 |
+
country = getattr(row, "country", None)
|
| 156 |
+
url = row.url
|
| 157 |
|
| 158 |
+
if not url:
|
| 159 |
+
logger.error(f"{ident} has no pre-built url; skipping")
|
| 160 |
+
update_source_table(model, row.id, "missing url")
|
|
|
|
|
|
|
| 161 |
return
|
| 162 |
|
| 163 |
+
logger.info(f"fetching {ident} ({type_})")
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
try:
|
| 166 |
+
html, error = fetch_html(url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
+
if not html:
|
| 169 |
+
update_source_table(model, row.id, error)
|
| 170 |
+
logger.error(f"fetch failed for {ident}: {error}")
|
| 171 |
return
|
| 172 |
|
| 173 |
+
store_raw_html(source_label, html, type_, country=country)
|
| 174 |
+
update_source_table(model, row.id)
|
| 175 |
+
logger.info(f"{ident}: raw html stored ({type_})")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
|
| 177 |
except Exception:
|
| 178 |
+
logger.exception(f"fetch failed for {ident}")
|
| 179 |
+
update_source_table(model, row.id, "fetch exception")
|
| 180 |
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
def _sleep_until_next_close(stop_event: Event, label: str):
|
| 183 |
+
"""Sleep until the soonest market close across all markets (weekend-aware)."""
|
| 184 |
|
| 185 |
+
sleep_s = min(seconds_until_next_market_close(m) for m in MARKET_CONFIG)
|
| 186 |
+
logger.info(
|
| 187 |
+
f"{label} tick done; sleeping {sleep_s / 3600:.1f}h until next market close"
|
| 188 |
+
)
|
| 189 |
+
stop_event.wait(sleep_s)
|
| 190 |
|
| 191 |
|
| 192 |
# =========================
|
| 193 |
+
# STOCK ENHANCER WORKER (fetch-only)
|
| 194 |
# =========================
|
| 195 |
+
# Once per market close (weekday), fetch every stock for that market that hasn't
|
| 196 |
+
# been fetched today, store its Google Finance HTML (type=stock), then sleep to
|
| 197 |
+
# the next close. All metric extraction happens on the LLM side.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
+
def _stock_market_iteration(stop_event: Event):
|
|
|
|
| 201 |
|
| 202 |
+
for market in MARKET_CONFIG:
|
| 203 |
|
| 204 |
+
if not market_already_closed_today(market):
|
| 205 |
+
logger.info(f"stock: {market} not closed yet today (or weekend); skipping")
|
| 206 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
|
| 208 |
+
due = get_sources_due_for_market(StockSources, market)
|
| 209 |
+
logger.info(f"stock: {market} closed; {len(due)} stock(s) due")
|
| 210 |
|
| 211 |
+
for stock in due:
|
| 212 |
+
if stop_event.is_set():
|
| 213 |
+
logger.info("stock tick: shutdown signaled mid-loop, returning")
|
| 214 |
+
return
|
| 215 |
+
_fetch_and_store(stock, StockSources, "Google Finance", STOCK)
|
|
|
|
| 216 |
|
| 217 |
+
_sleep_until_next_close(stop_event, "stock")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
|
| 220 |
+
def stock_enrichment_worker(stop_event: Event):
|
| 221 |
+
_safe_worker_loop("stock_enrichment_worker", _stock_market_iteration, stop_event)
|
| 222 |
|
| 223 |
|
| 224 |
# =========================
|
| 225 |
+
# MACRO WORKER (fetch-only)
|
| 226 |
# =========================
|
| 227 |
+
# Same once-per-close cadence as the stock worker, but over MacroSource rows
|
| 228 |
+
# (FII/DII, VIX, commodities, indices, FX, yields). The raw_html.type is taken
|
| 229 |
+
# from each row so one worker covers every macro family.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
+
def _macro_iteration(stop_event: Event):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
+
for market in MARKET_CONFIG:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
+
if not market_already_closed_today(market):
|
| 237 |
+
logger.info(f"macro: {market} not closed yet today (or weekend); skipping")
|
| 238 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
+
due = get_sources_due_for_market(MacroSource, market)
|
| 241 |
+
logger.info(f"macro: {market} closed; {len(due)} series due")
|
| 242 |
|
| 243 |
+
for src in due:
|
| 244 |
+
if stop_event.is_set():
|
| 245 |
+
logger.info("macro tick: shutdown signaled mid-loop, returning")
|
| 246 |
+
return
|
| 247 |
+
_fetch_and_store(src, MacroSource, src.name, src.type)
|
| 248 |
|
| 249 |
+
_sleep_until_next_close(stop_event, "macro")
|
| 250 |
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
+
def macro_worker(stop_event: Event):
|
| 253 |
+
_safe_worker_loop("macro_worker", _macro_iteration, stop_event)
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
|
| 256 |
# =========================
|
| 257 |
# WORKER REGISTRY
|
| 258 |
# =========================
|
| 259 |
# Maps a stable name → the worker's entry-point function.
|
|
|
|
| 260 |
# Registered actions:
|
| 261 |
+
# START_SCRAPER / STOP_SCRAPER / RESTART_SCRAPER
|
| 262 |
+
# START_STOCK_ENHANCER / STOP_STOCK_ENHANCER / RESTART_STOCK_ENHANCER
|
| 263 |
+
# START_MACRO / STOP_MACRO / RESTART_MACRO
|
|
|
|
|
|
|
| 264 |
|
| 265 |
_WORKER_REGISTRY: dict[str, callable] = {
|
| 266 |
"scraper": scraping_worker,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
"stock_enhancer": stock_enrichment_worker,
|
| 268 |
+
"macro": macro_worker,
|
| 269 |
}
|
| 270 |
|
| 271 |
+
|
| 272 |
# =========================
|
| 273 |
# INTERNAL HELPERS
|
| 274 |
# =========================
|
| 275 |
|
| 276 |
|
| 277 |
def _launch_worker(name: str):
|
| 278 |
+
"""Spin up a single named worker and register it in _workers.
|
| 279 |
+
Caller must hold workers_lock."""
|
| 280 |
+
|
|
|
|
| 281 |
if name not in _WORKER_REGISTRY:
|
| 282 |
raise ValueError(
|
| 283 |
f"Unknown worker: {name!r}. Valid names: {list(_WORKER_REGISTRY)}"
|
|
|
|
| 297 |
|
| 298 |
def _signal_and_join(name: str, stop_event: Event, thread: Thread, timeout: float):
|
| 299 |
"""Signal a worker's event and wait for its thread to exit."""
|
| 300 |
+
|
| 301 |
logger.info(f"stop: signaling '{name}'")
|
| 302 |
stop_event.set()
|
| 303 |
thread.join(timeout=timeout)
|
|
|
|
| 313 |
|
| 314 |
|
| 315 |
def start_workers(names: list[str] | None = None):
|
| 316 |
+
"""Start workers. If *names* is omitted, all registered workers are started.
|
| 317 |
+
Already-running workers are skipped with a warning."""
|
| 318 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
targets = names or list(_WORKER_REGISTRY.keys())
|
| 320 |
|
| 321 |
with workers_lock:
|
|
|
|
| 330 |
|
| 331 |
|
| 332 |
def stop_worker(name: str, timeout: float = 5.0):
|
| 333 |
+
"""Stop a single named worker and remove it from the registry."""
|
| 334 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
with workers_lock:
|
| 336 |
entry = _workers.get(name)
|
| 337 |
if not entry:
|
|
|
|
| 343 |
|
| 344 |
|
| 345 |
def stop_workers(names: list[str] | None = None, timeout: float = 5.0):
|
| 346 |
+
"""Stop workers. If *names* is omitted, all running workers are stopped."""
|
| 347 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
targets = names or list(_workers.keys())
|
| 349 |
for name in targets:
|
| 350 |
stop_worker(name, timeout=timeout)
|
|
|
|
| 352 |
|
| 353 |
|
| 354 |
def restart_worker(name: str, timeout: float = 5.0):
|
| 355 |
+
"""Stop a worker (if running) then immediately restart it."""
|
|
|
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
with workers_lock:
|
| 358 |
entry = _workers.get(name)
|
| 359 |
if entry:
|
|
|
|
| 364 |
|
| 365 |
|
| 366 |
def worker_status() -> dict[str, bool]:
|
| 367 |
+
"""Return a snapshot of {worker_name: is_alive} for every running worker."""
|
| 368 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
with workers_lock:
|
| 370 |
return {name: entry["thread"].is_alive() for name, entry in _workers.items()}
|
| 371 |
|