sbasu2512 commited on
Commit
2a09d06
·
1 Parent(s): 76e5b29

Change it to only a scrapper

Browse files
Files changed (15) hide show
  1. .dockerignore +36 -0
  2. Dockerfile +67 -44
  3. actions.py +36 -170
  4. config.py +47 -44
  5. database.py +6 -2
  6. db_helpers.py +289 -714
  7. llm.py +0 -1087
  8. logger.py +24 -8
  9. macro_sources.csv +17 -0
  10. main.py +10 -9
  11. models.py +61 -128
  12. requirements.txt +19 -75
  13. us_stocks_list.csv +41 -0
  14. utils.py +63 -19
  15. 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 wget git build-essential ca-certificates \
8
- libnss3 libatk-bridge2.0-0 libdrm2 libxkbcommon0 \
9
- libgtk-3-0 libgbm1 libasound2 libxshmfence1 \
10
- libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
11
- libxext6 libx11-6 libxcb1 libxrender1 libxi6 \
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
- # =========================
15
- # User (HF Spaces requirement)
16
- # =========================
17
  RUN useradd -m -u 1000 user
18
 
19
- # =========================
20
- # Directories (IMPORTANT: /data persists)
21
- # =========================
22
- RUN mkdir -p /home/user/app \
 
 
 
23
  /home/user/app/models \
24
  /home/user/app/ollama \
25
- /data/hf-cache \
26
- /data/logs \
27
- && chown -R user:user /home/user /data
 
 
 
 
 
28
 
29
- # =========================
30
- # Env
31
- # =========================
32
  ENV HOME=/home/user
33
  ENV PATH=/home/user/.local/bin:$PATH
34
  ENV PYTHONUNBUFFERED=1
 
35
 
36
- ENV HF_HOME=/data/hf-cache
37
- ENV HF_HUB_CACHE=/data/hf-cache
38
- ENV TRANSFORMERS_CACHE=/data/hf-cache
39
- ENV PLAYWRIGHT_BROWSERS_PATH=/data/hf-cache/playwright
40
 
41
  WORKDIR /home/user/app
42
 
43
- # =========================
44
- # COPY requirements FIRST (IMPORTANT)
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 install
56
- # =========================
57
- RUN python -m playwright install --with-deps chromium
58
- RUN pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
59
 
60
- # =========================
61
- # Copy app code
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
- get_analyzed_articles,
15
- get_raw_html,
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
- # BULK ACTION HANDLERS
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. The server process keeps running so the
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
- # PER-WORKER ACTION HANDLERS
167
  # =========================
168
 
169
 
170
- def handle_start_worker(payload=None, *, name: str):
171
- """Start a single named worker. No-op (with warning) if already running."""
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
- # OTHER ACTION HANDLERS
245
- # =========================
246
 
 
247
 
248
- def handle_get_analyzed(payload=None):
249
- """Return all analyzed (non-soft-deleted) articles as JSON."""
 
250
 
251
- logger.info("handle_get_analyzed: invoked")
252
- articles = get_analyzed_articles()
253
- return {
254
- "success": True,
255
- "count": len(articles),
256
- "articles": articles,
257
- }
258
 
259
 
260
- def handle_get_raw(payload=None):
261
- "Return all raw html stored in the database"
262
 
263
- logger.info("handle_get_raw: invoked")
264
- raw_html = get_raw_html()
265
- return {"success": True, "count": len(raw_html), "data": raw_html}
266
 
 
267
 
268
- def handle_get_sector(payload=None):
269
- """Return the sector rollup table (one row per sector) as JSON."""
270
 
271
- logger.info("handle_get_sector: invoked")
272
- sectors = get_sector_sentiments()
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
- actions = {
338
- # --- lifecycle ---
 
 
 
 
339
  "START": handle_start,
340
  "SHUTDOWN": handle_shutdown,
341
- "EXIT": handle_exit,
342
- # --- data ---
343
- # this is for sentiment model - pipeline
344
- "GET_ANALYZED": handle_get_analyzed,
345
- # add news sources
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
- # MODEL_PATH = os.path.join(
13
- # BACKEND_DIR, "model", "Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q4_k_m.gguf"
14
- # )
15
-
16
- # Our own sentiment model is an ONNX graph run directly via ONNX Runtime
17
- # (NOT through Ollama). MODEL_SENTIMENT_OWN is the .onnx file; its tokenizer
18
- # files (HuggingFace export layout) are expected alongside it in the same dir.
19
- MODEL_REPO = "sbasu2512/financial_sentiment_model/finbert_financial_model"
20
- # MODEL_SENTIMENT_OWN = hf_hub_download(
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
- HF_model_cache = "/data/hf-cache"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  # =========================
34
  # TIMING / THRESHOLDS
35
  # =========================
36
 
37
- # Re-fetch each source 3 hours after its last attempt; check every 60s.
38
- SCRAPE_INTERVAL_MS = 3 * 60 * 60 * 1000
39
- SCRAPER_TICK_SECONDS = 60 * 10
 
 
40
 
41
  # Disable a source after this many consecutive failures.
42
  MAX_CONSECUTIVE_FAILURES = 5
43
 
44
- # Hard-delete soft-deleted rows once they've been marked for this long.
45
- PURGE_AFTER_MS = 24 * 60 * 60 * 1000
46
- PURGE_TICK_SECONDS = 60 * 60
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
- # A clean_headlines row is eligible for soft-delete once it has been analyzed
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:////data/webscrapper.db"
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(autocommit=False, autoflush=False, bind=engine)
 
 
 
 
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, timezone
5
- from bs4 import BeautifulSoup
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
- SECTOR_TODAY_WINDOW_MS,
25
- SECTOR_7D_WINDOW_MS,
26
- STOCK_PATH,
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
- "parsed_articles": [
112
- ("is_cleaned", "BOOLEAN NOT NULL DEFAULT 0"),
113
- ("is_deleted", "BOOLEAN NOT NULL DEFAULT 0"),
114
- ("deleted_at", "INTEGER"),
115
- ],
116
- "raw_html": [
117
- ("is_deleted", "BOOLEAN NOT NULL DEFAULT 0"),
118
- ("deleted_at", "INTEGER"),
119
  ],
120
- "sector": [
121
- ("sector", "TEXT NOT NULL UNIQUE"),
122
- ("sector_sentiment", "REAL"),
123
- ("sector_sentiment_7d_avg", "REAL"),
124
- ("sector_momentum", "REAL"),
125
- ("is_analyzed", "BOOLEAN NOT NULL DEFAULT 0"),
 
 
 
 
 
 
 
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
- # LOAD SOURCES JSON TO DB
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  # =========================
156
 
157
 
@@ -203,62 +165,171 @@ def load_sources_to_db():
203
 
204
 
205
  # =========================
206
- # LOAD SOURCES JSON TO DB
207
  # =========================
208
 
209
 
210
  def load_stocks_to_db():
 
211
 
212
- logger.info(f"load_stocks_to_db: reading {STOCK_PATH}")
 
 
 
213
 
214
  db = SessionLocal()
215
 
216
  try:
217
 
218
- stocks_df = pd.read_csv(STOCK_PATH)
219
-
220
  inserted = 0
221
 
222
- for _, row in stocks_df.iterrows():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
 
224
- company_name = str(row["Company Name"]).strip().lower()
 
 
 
 
 
 
225
 
226
- ticker = str(row["Symbol"]).strip().upper()
227
 
228
- sector = str(row["Industry"]).strip().upper()
229
 
230
- country = str(row["Country"]).strip().upper()
 
 
 
 
 
 
231
 
232
- exchange = str(row["Exchange"]).strip().upper()
 
 
 
 
233
 
234
  existing = (
235
- db.query(StockSources).filter(StockSources.ticker == ticker).first()
 
 
236
  )
237
 
238
  if existing:
 
 
 
239
  continue
240
 
241
- stock = StockSources(
242
- company_name=company_name,
243
- ticker=ticker,
244
- sector=sector,
245
- country=country,
246
- exchange=exchange,
 
 
 
247
  )
248
 
249
- db.add(stock)
250
-
251
  inserted += 1
252
 
253
  db.commit()
254
 
255
- logger.info(f"load_stocks_to_db: inserted " f"{inserted} stocks")
256
 
257
  except Exception:
258
 
259
  db.rollback()
260
 
261
- logger.exception("load_stocks_to_db failed")
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
- db = SessionLocal()
411
- try:
412
- soup = BeautifulSoup(html, "lxml")
413
 
414
- for tag in soup(
415
- ["script", "style", "nav", "footer", "header", "noscript", "svg"]
416
- ):
417
- tag.decompose()
418
 
419
- cleaned_html = soup.get_text(" ", strip=True)
420
- entry = RawHTML(source=source, html=cleaned_html, type=type)
 
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(cleaned_html)} chars)"
428
  )
429
  return entry.id
430
  finally:
431
  db.close()
432
 
433
 
434
- def get_unparsed_record(type: str):
435
- db = SessionLocal()
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
- def get_analyzed_article():
574
- """Return one analyzed, not-yet-cleaned, non soft deleted article.
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
- article = db.query(ParsedArticle).filter(ParsedArticle.id == article_id).first()
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
- def update_article_analysis(article_id: str, sentiment: str, category: str):
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
- # SENTIMENT TABLE
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"store_sentiment: inserted {entry.id} "
679
- f"(company={company!r}, score={sentiment_score:.4f}, "
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
- "raw_html_pending": db.query(RawHTML)
692
- .filter(RawHTML.parsed == False)
693
- .count(),
694
- "articles_pending": db.query(ParsedArticle)
695
- .filter(ParsedArticle.analyzed == False)
696
- .count(),
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
- # SECTOR ROLLUP
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
- Returns {sector: sum(score*confidence)/sum(confidence)}. Sectors with a
728
- non-positive confidence sum (e.g. all-neutral) are skipped to avoid div-by-zero.
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
- cutoff_ms = int(time.time() * 1000) - SECTOR_7D_WINDOW_MS
824
-
825
- # ---> FIX: Chain an .update() call and set synchronize_session=False <---
826
- count = (
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
- # This now commits the actual UPDATE mutation safely to your database
836
  db.commit()
837
-
838
- logger.info(f"mark_clean_headlines_past_7d: flagged {count} row(s)")
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
- def get_sector_sentiments():
850
- """Return all sector rollup rows as a list of dicts (highest momentum first)."""
 
 
 
 
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
- logger.info(f"get_sector_sentiments: returning {len(sectors)} rows")
871
- return sectors
872
- finally:
873
- db.close()
874
 
 
 
 
 
875
 
876
- def update_stock_data(source_id: str, data: dict):
 
 
 
877
 
878
- logger.debug(f"Incoming dict to save to db: {data}")
879
 
880
  db = SessionLocal()
881
-
882
  try:
 
883
 
884
- source = db.query(StockSources).filter(StockSources.id == source_id).first()
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
- stock = Stock(
927
- ticker=source.ticker,
928
- company_name=source.company_name,
929
- country=source.country,
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
- db.add(stock)
942
 
943
- logger.info(f"created stock row for ticker:{source.ticker}")
 
944
 
945
- db.commit()
946
-
947
- except Exception:
948
- db.rollback()
949
- raise
950
 
951
  finally:
952
  db.close()
953
 
954
 
955
- def get_today_bounds():
956
-
957
- now = datetime.now()
958
 
959
- start = datetime(
960
- now.year,
961
- now.month,
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
- stock = db.query(StockSources).filter(StockSources.id == id).first()
1038
 
1039
- if not stock:
1040
  return
1041
 
1042
  now_ms = int(time.time() * 1000)
1043
 
1044
- stock.last_attempted_at = now_ms
1045
 
1046
  if error is not None:
1047
- stock.failure_reason = error
1048
- stock.failure_count = (stock.failure_count or 0) + 1
1049
  else:
1050
- stock.last_fetched_at = now_ms
1051
- stock.failure_reason = None
1052
- stock.failure_count = 0
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
- def get_raw_html():
1065
- db = SessionLocal()
 
 
1066
 
 
 
1067
  try:
1068
- rows = (
1069
- db.query(
1070
- RawHTML,
1071
- ParsedArticle.title,
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
- result = {}
1083
-
1084
- for raw_html, title in rows:
1085
-
1086
- if raw_html.id not in result:
1087
- result[raw_html.id] = {
1088
- "id": raw_html.id,
1089
- "source": raw_html.source,
1090
- "type": raw_html.type.name if raw_html.type else None,
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 = os.path.join("/data", "logs")
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("%Y-%m-%d_%H-%M-%S")
 
 
45
 
46
- filename = f"backend_logs_{timestamp}_{self.file_index}.txt"
 
 
47
 
48
- self.current_file_path = os.path.join(self.logs_folder, filename)
 
 
 
49
 
50
- self.current_file = open(self.current_file_path, "a", encoding="utf-8")
 
 
 
 
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("%Y-%m-%d %H:%M:%S")
 
 
69
 
70
- formatted = f"[{timestamp}] " f"[{level.upper()}] " f"{message}"
 
 
 
 
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 Depends, FastAPI
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 load_stocks_to_db, migrate_schema, load_sources_to_db
10
- from llm import load_models
11
- from utils import create_ollama_modelfile
 
 
 
12
  from workers import stop_workers
13
- from actions import ActionRequest, verify_auth, ACTIONS
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
- # RAW HTML TABLE
24
- # =========================
25
- class SourceType(str, enum.Enum):
26
- NEWS = "news"
27
- STOCK = "stock"
 
 
 
 
 
 
 
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
- parsed = Column(Boolean, nullable=False, default=False)
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
- created_at = Column(Integer, default=lambda: int(time.time() * 1000))
 
 
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
- # SENTIMENT TABLE
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
- class Stock(Base):
 
 
 
 
 
 
 
205
 
206
- __tablename__ = "stock"
 
 
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
- raw_html_id = Column(String, nullable=False)
212
- company_name = Column(String, nullable=False)
213
- ticker = Column(String, nullable=False)
214
- close = Column(Float)
215
- volume = Column(Float)
216
- country = Column(String)
217
- open = Column(Float)
218
- high = Column(Float)
219
- low = Column(Float)
220
- is_complete = Column(
221
- Boolean, nullable=False, default=False, server_default=text_func("0")
 
 
 
 
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
- # CORE API
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
- # HTTP / SCRAPING
13
- # =========================
14
- requests==2.34.2
15
- httpx>=0.26.0,<0.27.0
 
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
- # WEB / SERVER UTILITIES
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
- from config import HTTP_REQUEST_TIMEOUT_SECONDS, MODEL_PATH
 
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
- def create_ollama_modelfile():
150
- path = "/home/user/app/ollama/Modelfile-q4"
151
- with open(path, "w") as f:
152
- f.write(f"FROM {MODEL_PATH}\n")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Stock
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
- get_unparsed_record,
20
- mark_as_parsed,
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
- HTTP_REQUEST_TIMEOUT_SECONDS,
49
- SECTOR_TICK_SECONDS,
 
 
 
 
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
- This worker is suppposed to scrape news sources for news. Stores RAW HTML into the database.
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(e))
209
  continue
210
 
211
- raw_id = store_raw_html(source_name, html, type)
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
- # HEADLINE WORKER
234
  # =========================
 
 
 
235
 
236
 
237
- def _headline_iteration(stop_event: Event):
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
- logger.info("Headline tick: looking for unparsed raw_html")
243
- record = get_unparsed_record("NEWS")
 
244
 
245
- if not record:
246
- logger.info(
247
- f"headline tick: nothing to do; " f"sleeping {HEADLINE_IDLE_SECONDS}s"
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
- started = time.time()
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
- logger.info(
327
- f"sentiment: article {article.id} "
328
- f"sentiment={result['sentiment']} "
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
- def sentiment_worker(stop_event: Event):
344
- _safe_worker_loop("sentiment_worker", _sentiment_iteration, stop_event)
 
 
 
345
 
346
 
347
  # =========================
348
- # SENTIMENT TABLE WORKER (own model)
349
  # =========================
350
- # Reads parsed articles, scores each with our own sentiment model (probabilities
351
- # for {0: negative, 1: neutral, 2: positive}), and writes a row to the sentiment
352
- # table: company, sentiment_score = P(pos)-P(neg), confidence = 1-P(neutral),
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
- try:
385
- started = time.time()
386
 
387
- result = predict_own_sentiment(text)
388
 
389
- store_sentiment(
390
- company=company,
391
- sentiment_score=result["sentiment_score"],
392
- confidence=result["confidence"],
393
- sector=sector,
394
- headline_id=headline_id,
395
- )
396
 
397
- mark_article_cleaned(headline_id)
 
398
 
399
- logger.info(
400
- f"sentiment_table tick done: {headline_id} -> sentiment "
401
- f"(score={result['sentiment_score']:.4f} "
402
- f"confidence={result['confidence']:.4f}, "
403
- f"{time.time() - started:.1f}s)"
404
- )
405
 
406
- except Exception:
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 sentiment_table_worker(stop_event: Event):
418
- _safe_worker_loop("sentiment_table_worker", _sentiment_table_iteration, stop_event)
419
 
420
 
421
  # =========================
422
- # SECTOR ROLLUP WORKER (24h)
423
  # =========================
424
- # Once per 24h, recompute the sector rollup table from the sentiment table
425
- # (confidence-weighted sentiment over the trailing 24h and 7 days, plus
426
- # momentum). Upserts one row per sector. Runs immediately on start, then waits.
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
- try:
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
- update_stock_table(stock_id)
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
- logger.exception(f"google enrichment failed " f"for {ticker}")
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
- logger.exception("stock enrichment failed")
 
566
 
567
- stop_event.wait(WORKER_ERROR_BACKOFF_SECONDS)
 
 
 
 
568
 
 
569
 
570
- def stock_enrichment_worker(
571
- stop_event: Event,
572
- ):
573
 
574
- _safe_worker_loop(
575
- "stock_enrichment_worker",
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 / STOP_SCRAPER / RESTART_SCRAPER
588
- # START_HEADLINE / STOP_HEADLINE / RESTART_HEADLINE
589
- # START_SENTIMENT / STOP_SENTIMENT / RESTART_SENTIMENT
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
- Spin up a single named worker and register it in _workers.
612
- Caller must hold workers_lock.
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
- Start workers. If *names* is omitted, all registered workers are started.
650
- Already-running workers are skipped with a warning.
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
- Stop a single named worker and remove it from the registry.
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
- Stop workers. If *names* is omitted, all running workers are stopped.
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
- Return a snapshot of {worker_name: is_alive} for every registered worker.
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