Hermes Agent commited on
Commit ·
9fc69ab
1
Parent(s): 6a60bcc
Baseline commit: full project state before audit fixes
Browse files- .env.example +5 -0
- .gitignore +4 -27
- Dockerfile +5 -6
- README.md +20 -7
- app/cron.py +84 -0
- app/internal_cron.py +111 -0
- app/jobs.py +76 -0
- app/main.py +38 -17
- app/scheduler.py +7 -16
- app/services/data_service.py +94 -11
- app/services/timesfm_service.py +7 -4
- scripts/backfill_sectors.py +82 -0
- scripts/configure_hf_space.py +70 -0
- scripts/daily_pipeline.py +2 -2
- scripts/seed_sp500.py +12 -0
- scripts/setup_free_wake_cron.py +263 -0
- start-api.ps1 +18 -0
- test_yfinance_quick.py +9 -0
.env.example
CHANGED
|
@@ -15,3 +15,8 @@ CACHE_MAX_AGE_HOURS=24
|
|
| 15 |
|
| 16 |
# Logging
|
| 17 |
LOG_LEVEL=INFO
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
# Logging
|
| 17 |
LOG_LEVEL=INFO
|
| 18 |
+
|
| 19 |
+
# Cron ($0: internal scheduler + optional free HF Space Scheduler UI)
|
| 20 |
+
CRON_SECRET=generate-a-long-random-string
|
| 21 |
+
ENABLE_INTERNAL_CRON=true
|
| 22 |
+
DAILY_FORECAST_LIMIT_PER_COUNTRY=50
|
.gitignore
CHANGED
|
@@ -1,30 +1,7 @@
|
|
| 1 |
-
# Python
|
| 2 |
-
__pycache__/
|
| 3 |
-
*.py[cod]
|
| 4 |
-
*$py.class
|
| 5 |
-
*.so
|
| 6 |
-
*.egg-info/
|
| 7 |
-
*.egg
|
| 8 |
venv/
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
# Environment
|
| 13 |
.env
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
# Logs
|
| 17 |
-
*.log
|
| 18 |
-
|
| 19 |
-
# IDE
|
| 20 |
-
.vscode/
|
| 21 |
-
.idea/
|
| 22 |
-
*.swp
|
| 23 |
-
|
| 24 |
-
# OS
|
| 25 |
-
.DS_Store
|
| 26 |
-
Thumbs.db
|
| 27 |
-
|
| 28 |
-
# Database
|
| 29 |
*.db
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
venv/
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
|
|
|
|
|
|
| 4 |
.env
|
| 5 |
+
data/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
*.db
|
| 7 |
+
.pytest_cache/
|
Dockerfile
CHANGED
|
@@ -2,21 +2,20 @@ FROM python:3.11-slim
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
-
# Install system dependencies
|
| 6 |
RUN apt-get update && apt-get install -y \
|
| 7 |
gcc \
|
| 8 |
g++ \
|
|
|
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
-
# Copy requirements and install Python dependencies
|
| 12 |
COPY requirements.txt .
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
| 15 |
-
# Copy application code
|
| 16 |
COPY app/ ./app/
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
EXPOSE 7860
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
|
|
|
| 5 |
RUN apt-get update && apt-get install -y \
|
| 6 |
gcc \
|
| 7 |
g++ \
|
| 8 |
+
libcairo2 \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
|
|
|
| 11 |
COPY requirements.txt .
|
| 12 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
|
|
|
|
| 14 |
COPY app/ ./app/
|
| 15 |
+
COPY scripts/ ./scripts/
|
| 16 |
|
| 17 |
+
ENV API_PORT=7860
|
| 18 |
EXPOSE 7860
|
| 19 |
|
| 20 |
+
# HuggingFace Spaces expect port 7860
|
| 21 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -52,14 +52,25 @@ uvicorn app.main:app --reload --port 8000
|
|
| 52 |
# API docs available at http://localhost:8000/docs
|
| 53 |
```
|
| 54 |
|
| 55 |
-
### Deployment to HuggingFace Spaces
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
4. Space
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
## API Endpoints
|
| 65 |
|
|
@@ -128,6 +139,8 @@ app/
|
|
| 128 |
| `TIMESFM_MODEL` | Model ID (default: google/timesfm-2.5-200m-pytorch) | No |
|
| 129 |
| `DEVICE` | Compute device (cpu/cuda) | No |
|
| 130 |
| `CACHE_MAX_AGE_HOURS` | Cache TTL (default: 24) | No |
|
|
|
|
|
|
|
| 131 |
|
| 132 |
## Performance
|
| 133 |
|
|
|
|
| 52 |
# API docs available at http://localhost:8000/docs
|
| 53 |
```
|
| 54 |
|
| 55 |
+
### Deployment to HuggingFace Spaces ($0 tier)
|
| 56 |
|
| 57 |
+
**Never enable paid HF Jobs or GPU hardware** — use free `cpu-basic` only.
|
| 58 |
+
|
| 59 |
+
1. Create a **Docker** Space on [huggingface.co/spaces](https://huggingface.co/spaces)
|
| 60 |
+
2. Push `backend/` to the Space (`.\deploy-hf.ps1` from repo root)
|
| 61 |
+
3. Run `python scripts/configure_hf_space.py` — sets secrets + `ENABLE_INTERNAL_CRON=true`
|
| 62 |
+
4. **Optional (free):** Space → **Scheduler** — two HTTP jobs to wake the Space after idle sleep (see `DEPLOYMENT.md` §4). Internal cron already runs jobs while the Space is up.
|
| 63 |
+
5. Set Cloudflare `PUBLIC_API_URL` to `https://<user>-<space>.hf.space`
|
| 64 |
+
|
| 65 |
+
**Cron endpoints** (all require `X-Cron-Secret` header):
|
| 66 |
+
|
| 67 |
+
| Endpoint | Purpose |
|
| 68 |
+
|----------|---------|
|
| 69 |
+
| `POST /api/v1/cron/daily` | Metadata sync + full forecast pipeline (recommended) |
|
| 70 |
+
| `POST /api/v1/cron/forecasts` | Forecasts only |
|
| 71 |
+
| `POST /api/v1/cron/metadata/daily` | Stock metadata / status only |
|
| 72 |
+
| `POST /api/v1/cron/metadata/weekly` | Full directory refresh (Sundays) |
|
| 73 |
+
| `GET /api/v1/cron/health` | Verify routes are live |
|
| 74 |
|
| 75 |
## API Endpoints
|
| 76 |
|
|
|
|
| 139 |
| `TIMESFM_MODEL` | Model ID (default: google/timesfm-2.5-200m-pytorch) | No |
|
| 140 |
| `DEVICE` | Compute device (cpu/cuda) | No |
|
| 141 |
| `CACHE_MAX_AGE_HOURS` | Cache TTL (default: 24) | No |
|
| 142 |
+
| `CRON_SECRET` | Secret for HF Scheduler cron HTTP calls | Yes (production) |
|
| 143 |
+
| `DAILY_FORECAST_LIMIT_PER_COUNTRY` | Max stocks per country per daily run | No |
|
| 144 |
|
| 145 |
## Performance
|
| 146 |
|
app/cron.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Secured cron endpoints for HuggingFace Space Scheduler (replaces GitHub Actions).
|
| 3 |
+
Configure in Space → Settings → Scheduler → add job pointing at these URLs.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from fastapi import APIRouter, Header, HTTPException, BackgroundTasks
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
from app import jobs
|
| 11 |
+
|
| 12 |
+
load_dotenv()
|
| 13 |
+
|
| 14 |
+
router = APIRouter(prefix="/api/v1/cron", tags=["cron"])
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _cron_secret() -> str:
|
| 18 |
+
return os.getenv("CRON_SECRET", "")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _verify_secret(x_cron_secret: str | None):
|
| 22 |
+
secret = _cron_secret()
|
| 23 |
+
if not secret:
|
| 24 |
+
raise HTTPException(
|
| 25 |
+
status_code=503,
|
| 26 |
+
detail="CRON_SECRET not set on server. Add it in HuggingFace Space secrets.",
|
| 27 |
+
)
|
| 28 |
+
if x_cron_secret != secret:
|
| 29 |
+
raise HTTPException(status_code=401, detail="Invalid cron secret")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.get("/health")
|
| 33 |
+
async def cron_health():
|
| 34 |
+
"""Public check that cron routes are mounted (no secret required)."""
|
| 35 |
+
return {
|
| 36 |
+
"status": "ok",
|
| 37 |
+
"cron_configured": bool(_cron_secret()),
|
| 38 |
+
"hint": "HF Scheduler should POST/GET /api/v1/cron/daily with header X-Cron-Secret",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.post("/daily")
|
| 43 |
+
async def cron_daily(
|
| 44 |
+
background_tasks: BackgroundTasks,
|
| 45 |
+
x_cron_secret: str | None = Header(default=None),
|
| 46 |
+
):
|
| 47 |
+
"""
|
| 48 |
+
Main daily automation (run at 21:00 UTC weekdays via HF Scheduler).
|
| 49 |
+
Metadata sync + forecast generation. Runs in background; returns immediately.
|
| 50 |
+
"""
|
| 51 |
+
_verify_secret(x_cron_secret)
|
| 52 |
+
background_tasks.add_task(jobs.run_full_daily_job)
|
| 53 |
+
return {"accepted": True, "job": "full_daily", "message": "Running in background"}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.post("/forecasts")
|
| 57 |
+
async def cron_forecasts(
|
| 58 |
+
background_tasks: BackgroundTasks,
|
| 59 |
+
x_cron_secret: str | None = Header(default=None),
|
| 60 |
+
):
|
| 61 |
+
"""Forecast pipeline only."""
|
| 62 |
+
_verify_secret(x_cron_secret)
|
| 63 |
+
background_tasks.add_task(jobs.run_daily_forecasts)
|
| 64 |
+
return {"accepted": True, "job": "daily_forecasts"}
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post("/metadata/daily")
|
| 68 |
+
async def cron_metadata_daily(
|
| 69 |
+
background_tasks: BackgroundTasks,
|
| 70 |
+
x_cron_secret: str | None = Header(default=None),
|
| 71 |
+
):
|
| 72 |
+
_verify_secret(x_cron_secret)
|
| 73 |
+
background_tasks.add_task(jobs.run_metadata_daily)
|
| 74 |
+
return {"accepted": True, "job": "metadata_daily"}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.post("/metadata/weekly")
|
| 78 |
+
async def cron_metadata_weekly(
|
| 79 |
+
background_tasks: BackgroundTasks,
|
| 80 |
+
x_cron_secret: str | None = Header(default=None),
|
| 81 |
+
):
|
| 82 |
+
_verify_secret(x_cron_secret)
|
| 83 |
+
background_tasks.add_task(jobs.run_metadata_weekly)
|
| 84 |
+
return {"accepted": True, "job": "metadata_weekly"}
|
app/internal_cron.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
In-process cron for HuggingFace Spaces (free tier).
|
| 3 |
+
Runs when the Space is awake; complements HF Settings → Scheduler HTTP pings.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import asyncio
|
| 9 |
+
import logging
|
| 10 |
+
import os
|
| 11 |
+
from datetime import datetime, timedelta, timezone
|
| 12 |
+
|
| 13 |
+
from app import jobs
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
DAILY_CRON = os.getenv("INTERNAL_DAILY_CRON", "0 21 * * 1-5")
|
| 18 |
+
WEEKLY_CRON = os.getenv("INTERNAL_WEEKLY_CRON", "0 2 * * 0")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _enabled() -> bool:
|
| 22 |
+
return os.getenv("ENABLE_INTERNAL_CRON", "").lower() in ("1", "true", "yes")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _parse_cron_field(field: str, min_v: int, max_v: int) -> set[int] | None:
|
| 26 |
+
"""Minimal cron field parser: *, N, N-M, */S, lists."""
|
| 27 |
+
if field == "*":
|
| 28 |
+
return None
|
| 29 |
+
out: set[int] = set()
|
| 30 |
+
for part in field.split(","):
|
| 31 |
+
if "/" in part:
|
| 32 |
+
base, step = part.split("/", 1)
|
| 33 |
+
step_n = int(step)
|
| 34 |
+
if base == "*":
|
| 35 |
+
vals = range(min_v, max_v + 1, step_n)
|
| 36 |
+
else:
|
| 37 |
+
start, end = (int(x) for x in base.split("-", 1)) if "-" in base else (int(base), max_v)
|
| 38 |
+
vals = range(start, end + 1, step_n)
|
| 39 |
+
out.update(vals)
|
| 40 |
+
elif "-" in part:
|
| 41 |
+
a, b = (int(x) for x in part.split("-", 1))
|
| 42 |
+
out.update(range(a, b + 1))
|
| 43 |
+
else:
|
| 44 |
+
out.add(int(part))
|
| 45 |
+
return out
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _cron_matches(expr: str, dt: datetime) -> bool:
|
| 49 |
+
parts = expr.split()
|
| 50 |
+
if len(parts) != 5:
|
| 51 |
+
return False
|
| 52 |
+
minute, hour, dom, month, dow = parts
|
| 53 |
+
if _parse_cron_field(minute, 0, 59) is not None and dt.minute not in _parse_cron_field(minute, 0, 59):
|
| 54 |
+
return False
|
| 55 |
+
if _parse_cron_field(hour, 0, 23) is not None and dt.hour not in _parse_cron_field(hour, 0, 23):
|
| 56 |
+
return False
|
| 57 |
+
if _parse_cron_field(dom, 1, 31) is not None and dt.day not in _parse_cron_field(dom, 1, 31):
|
| 58 |
+
return False
|
| 59 |
+
if _parse_cron_field(month, 1, 12) is not None and dt.month not in _parse_cron_field(month, 1, 12):
|
| 60 |
+
return False
|
| 61 |
+
# Sunday=0 in standard cron
|
| 62 |
+
dow_set = _parse_cron_field(dow, 0, 6)
|
| 63 |
+
if dow_set is not None:
|
| 64 |
+
py_dow = (dt.weekday() + 1) % 7 # Mon=1..Sun=0
|
| 65 |
+
if py_dow not in dow_set:
|
| 66 |
+
return False
|
| 67 |
+
return True
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _seconds_until_next(expr: str, now: datetime) -> float:
|
| 71 |
+
for i in range(366 * 24 * 60):
|
| 72 |
+
t = now + timedelta(minutes=i)
|
| 73 |
+
if _cron_matches(expr, t):
|
| 74 |
+
target = t.replace(second=0, microsecond=0)
|
| 75 |
+
if target <= now:
|
| 76 |
+
continue
|
| 77 |
+
return (target - now).total_seconds()
|
| 78 |
+
return 3600.0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
async def _run_loop(expr: str, job_name: str, coro_factory) -> None:
|
| 82 |
+
while True:
|
| 83 |
+
now = datetime.now(timezone.utc)
|
| 84 |
+
delay = _seconds_until_next(expr, now)
|
| 85 |
+
logger.info("Internal cron %s: next run in %.0fs (%s UTC)", job_name, delay, now.isoformat())
|
| 86 |
+
await asyncio.sleep(delay)
|
| 87 |
+
if not os.getenv("CRON_SECRET"):
|
| 88 |
+
logger.warning("CRON_SECRET unset; skipping %s", job_name)
|
| 89 |
+
continue
|
| 90 |
+
try:
|
| 91 |
+
logger.info("Internal cron starting %s", job_name)
|
| 92 |
+
await coro_factory()
|
| 93 |
+
logger.info("Internal cron finished %s", job_name)
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.exception("Internal cron %s failed: %s", job_name, e)
|
| 96 |
+
await asyncio.sleep(60)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
async def start_internal_cron() -> list[asyncio.Task]:
|
| 100 |
+
if not _enabled():
|
| 101 |
+
logger.info("Internal cron disabled (set ENABLE_INTERNAL_CRON=true)")
|
| 102 |
+
return []
|
| 103 |
+
if not os.getenv("CRON_SECRET"):
|
| 104 |
+
logger.warning("CRON_SECRET unset; internal cron not started")
|
| 105 |
+
return []
|
| 106 |
+
tasks = [
|
| 107 |
+
asyncio.create_task(_run_loop(DAILY_CRON, "daily_forecasts", jobs.run_full_daily_job)),
|
| 108 |
+
asyncio.create_task(_run_loop(WEEKLY_CRON, "weekly_metadata", jobs.run_metadata_weekly)),
|
| 109 |
+
]
|
| 110 |
+
logger.info("Internal cron started (daily=%s, weekly=%s)", DAILY_CRON, WEEKLY_CRON)
|
| 111 |
+
return tasks
|
app/jobs.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Background jobs for HuggingFace Spaces (triggered via secured cron HTTP endpoints).
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
# Scripts live beside app/ in the Docker image
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 15 |
+
|
| 16 |
+
_job_lock = asyncio.Lock()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def run_daily_forecasts() -> dict:
|
| 20 |
+
"""Generate and cache forecasts for tracked stocks (all countries)."""
|
| 21 |
+
from scripts.daily_pipeline import run_daily_pipeline
|
| 22 |
+
|
| 23 |
+
await run_daily_pipeline()
|
| 24 |
+
return {"job": "daily_forecasts", "status": "completed"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def run_metadata_daily() -> dict:
|
| 28 |
+
"""Trading status + listing checks."""
|
| 29 |
+
from app.scheduler import scheduler
|
| 30 |
+
|
| 31 |
+
await scheduler.initialize()
|
| 32 |
+
try:
|
| 33 |
+
await scheduler.daily_sync()
|
| 34 |
+
return {"job": "metadata_daily", "status": "completed"}
|
| 35 |
+
finally:
|
| 36 |
+
await scheduler.shutdown()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
async def run_metadata_weekly() -> dict:
|
| 40 |
+
"""Full exchange directory refresh."""
|
| 41 |
+
from app.scheduler import scheduler
|
| 42 |
+
|
| 43 |
+
await scheduler.initialize()
|
| 44 |
+
try:
|
| 45 |
+
await scheduler.weekly_full_sync()
|
| 46 |
+
return {"job": "metadata_weekly", "status": "completed"}
|
| 47 |
+
finally:
|
| 48 |
+
await scheduler.shutdown()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def run_full_daily_job() -> dict:
|
| 52 |
+
"""
|
| 53 |
+
HuggingFace daily cron (recommended): forecast pipeline only.
|
| 54 |
+
Metadata refresh runs on weekly cron to avoid Yahoo 429 rate limits.
|
| 55 |
+
Set CRON_INCLUDE_METADATA_DAILY=true to also run metadata daily.
|
| 56 |
+
"""
|
| 57 |
+
async with _job_lock:
|
| 58 |
+
result: dict = {"status": "completed"}
|
| 59 |
+
if os.getenv("CRON_INCLUDE_METADATA_DAILY", "").lower() in ("1", "true", "yes"):
|
| 60 |
+
result["metadata"] = await run_metadata_daily()
|
| 61 |
+
result["forecasts"] = await run_daily_forecasts()
|
| 62 |
+
return result
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
async def run_job_in_background(coro_factory):
|
| 66 |
+
"""Fire-and-forget for long jobs (HF cron HTTP must return quickly)."""
|
| 67 |
+
task = asyncio.create_task(coro_factory())
|
| 68 |
+
|
| 69 |
+
def _done(t: asyncio.Task):
|
| 70 |
+
try:
|
| 71 |
+
t.result()
|
| 72 |
+
logger.info("Background job finished OK")
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"Background job failed: {e}")
|
| 75 |
+
|
| 76 |
+
task.add_done_callback(_done)
|
app/main.py
CHANGED
|
@@ -7,6 +7,7 @@ from fastapi import FastAPI, HTTPException, Query
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
import os
|
|
|
|
| 10 |
from dotenv import load_dotenv
|
| 11 |
|
| 12 |
from app.services.timesfm_service import TimesFMService
|
|
@@ -14,12 +15,15 @@ from app.services.data_service import StockDataService
|
|
| 14 |
from app.services.chart_service import ChartService
|
| 15 |
from app.services.database_service import DatabaseService
|
| 16 |
from app.models.schemas import ForecastResponse, StockInfo, HealthResponse
|
|
|
|
|
|
|
| 17 |
|
| 18 |
# Load environment variables
|
| 19 |
load_dotenv()
|
| 20 |
|
| 21 |
# Global service instances
|
| 22 |
timesfm_service = None
|
|
|
|
| 23 |
data_service = None
|
| 24 |
chart_service = None
|
| 25 |
db_service = None
|
|
@@ -43,11 +47,14 @@ async def lifespan(app: FastAPI):
|
|
| 43 |
# Initialize TimesFM model (this takes time)
|
| 44 |
print("🤖 Loading TimesFM 2.5 model...")
|
| 45 |
timesfm_service = TimesFMService(
|
| 46 |
-
model_id="google/timesfm-2.5-200m-
|
| 47 |
-
device="
|
| 48 |
)
|
| 49 |
await timesfm_service.load_model()
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
# Initialize data and chart services
|
| 53 |
data_service = StockDataService()
|
|
@@ -55,10 +62,15 @@ async def lifespan(app: FastAPI):
|
|
| 55 |
print("✅ Data and chart services ready")
|
| 56 |
|
| 57 |
print("🎉 All services initialized successfully")
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
yield
|
| 60 |
|
| 61 |
# Cleanup
|
|
|
|
|
|
|
| 62 |
print("👋 Shutting down services...")
|
| 63 |
await db_service.close()
|
| 64 |
|
|
@@ -80,6 +92,8 @@ app.add_middleware(
|
|
| 80 |
allow_headers=["*"],
|
| 81 |
)
|
| 82 |
|
|
|
|
|
|
|
| 83 |
|
| 84 |
@app.get("/health", response_model=HealthResponse)
|
| 85 |
async def health_check():
|
|
@@ -112,41 +126,48 @@ async def get_forecast(
|
|
| 112 |
if cached_forecast:
|
| 113 |
return cached_forecast
|
| 114 |
|
| 115 |
-
# Fetch historical data
|
| 116 |
-
|
| 117 |
-
if
|
| 118 |
raise HTTPException(
|
| 119 |
status_code=404,
|
| 120 |
detail=f"Insufficient historical data for {symbol}"
|
| 121 |
)
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
# Generate forecast
|
| 124 |
forecast_result = await timesfm_service.predict(
|
| 125 |
-
historical_prices=
|
| 126 |
horizon=horizon
|
| 127 |
)
|
| 128 |
|
| 129 |
# Generate chart
|
| 130 |
chart_svg = chart_service.generate_forecast_chart(
|
| 131 |
symbol=symbol,
|
| 132 |
-
historical_prices=
|
| 133 |
forecast=forecast_result,
|
| 134 |
-
current_price=
|
| 135 |
)
|
| 136 |
|
| 137 |
# Prepare response
|
| 138 |
response = ForecastResponse(
|
| 139 |
symbol=symbol,
|
| 140 |
-
name=
|
| 141 |
-
exchange=
|
| 142 |
-
currency=
|
| 143 |
-
current_price=
|
| 144 |
-
last_updated=
|
| 145 |
horizon_days=horizon,
|
| 146 |
point_forecast=float(forecast_result['point_forecast'][-1]),
|
| 147 |
percentage_change=float(
|
| 148 |
-
((forecast_result['point_forecast'][-1] -
|
| 149 |
-
/ stock_data['close'].iloc[-1]) * 100
|
| 150 |
),
|
| 151 |
quantiles={
|
| 152 |
'p10': [float(x) for x in forecast_result['quantiles']['p10']],
|
|
|
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
| 8 |
from contextlib import asynccontextmanager
|
| 9 |
import os
|
| 10 |
+
from typing import Optional
|
| 11 |
from dotenv import load_dotenv
|
| 12 |
|
| 13 |
from app.services.timesfm_service import TimesFMService
|
|
|
|
| 15 |
from app.services.chart_service import ChartService
|
| 16 |
from app.services.database_service import DatabaseService
|
| 17 |
from app.models.schemas import ForecastResponse, StockInfo, HealthResponse
|
| 18 |
+
from app.cron import router as cron_router
|
| 19 |
+
from app.internal_cron import start_internal_cron
|
| 20 |
|
| 21 |
# Load environment variables
|
| 22 |
load_dotenv()
|
| 23 |
|
| 24 |
# Global service instances
|
| 25 |
timesfm_service = None
|
| 26 |
+
_cron_tasks: list = []
|
| 27 |
data_service = None
|
| 28 |
chart_service = None
|
| 29 |
db_service = None
|
|
|
|
| 47 |
# Initialize TimesFM model (this takes time)
|
| 48 |
print("🤖 Loading TimesFM 2.5 model...")
|
| 49 |
timesfm_service = TimesFMService(
|
| 50 |
+
model_id=os.getenv("TIMESFM_MODEL", "google/timesfm-2.5-200m-transformers"),
|
| 51 |
+
device=os.getenv("DEVICE", "cpu"),
|
| 52 |
)
|
| 53 |
await timesfm_service.load_model()
|
| 54 |
+
if timesfm_service.model is not None:
|
| 55 |
+
print("✅ TimesFM model loaded")
|
| 56 |
+
else:
|
| 57 |
+
print("⚠️ TimesFM unavailable — using statistical fallback forecasts")
|
| 58 |
|
| 59 |
# Initialize data and chart services
|
| 60 |
data_service = StockDataService()
|
|
|
|
| 62 |
print("✅ Data and chart services ready")
|
| 63 |
|
| 64 |
print("🎉 All services initialized successfully")
|
| 65 |
+
|
| 66 |
+
global _cron_tasks
|
| 67 |
+
_cron_tasks = await start_internal_cron()
|
| 68 |
|
| 69 |
yield
|
| 70 |
|
| 71 |
# Cleanup
|
| 72 |
+
for t in _cron_tasks:
|
| 73 |
+
t.cancel()
|
| 74 |
print("👋 Shutting down services...")
|
| 75 |
await db_service.close()
|
| 76 |
|
|
|
|
| 92 |
allow_headers=["*"],
|
| 93 |
)
|
| 94 |
|
| 95 |
+
app.include_router(cron_router)
|
| 96 |
+
|
| 97 |
|
| 98 |
@app.get("/health", response_model=HealthResponse)
|
| 99 |
async def health_check():
|
|
|
|
| 126 |
if cached_forecast:
|
| 127 |
return cached_forecast
|
| 128 |
|
| 129 |
+
# Fetch historical data (yfinance DataFrame)
|
| 130 |
+
stock_df = await data_service.get_stock_data(symbol, period="2y")
|
| 131 |
+
if stock_df is None or len(stock_df) < 100:
|
| 132 |
raise HTTPException(
|
| 133 |
status_code=404,
|
| 134 |
detail=f"Insufficient historical data for {symbol}"
|
| 135 |
)
|
| 136 |
+
|
| 137 |
+
close_col = "Close" if "Close" in stock_df.columns else "close"
|
| 138 |
+
close_series = stock_df[close_col]
|
| 139 |
+
current_price = float(close_series.iloc[-1])
|
| 140 |
+
stock_name = stock_df.attrs.get("name", symbol)
|
| 141 |
+
stock_exchange = stock_df.attrs.get("exchange", "UNKNOWN")
|
| 142 |
+
stock_currency = stock_df.attrs.get("currency", "USD")
|
| 143 |
+
last_updated = stock_df.index[-1].isoformat()
|
| 144 |
+
|
| 145 |
# Generate forecast
|
| 146 |
forecast_result = await timesfm_service.predict(
|
| 147 |
+
historical_prices=close_series.tolist(),
|
| 148 |
horizon=horizon
|
| 149 |
)
|
| 150 |
|
| 151 |
# Generate chart
|
| 152 |
chart_svg = chart_service.generate_forecast_chart(
|
| 153 |
symbol=symbol,
|
| 154 |
+
historical_prices=close_series.tolist()[-60:], # Last 60 days
|
| 155 |
forecast=forecast_result,
|
| 156 |
+
current_price=current_price
|
| 157 |
)
|
| 158 |
|
| 159 |
# Prepare response
|
| 160 |
response = ForecastResponse(
|
| 161 |
symbol=symbol,
|
| 162 |
+
name=stock_name,
|
| 163 |
+
exchange=stock_exchange,
|
| 164 |
+
currency=stock_currency,
|
| 165 |
+
current_price=current_price,
|
| 166 |
+
last_updated=last_updated,
|
| 167 |
horizon_days=horizon,
|
| 168 |
point_forecast=float(forecast_result['point_forecast'][-1]),
|
| 169 |
percentage_change=float(
|
| 170 |
+
((forecast_result['point_forecast'][-1] - current_price) / current_price) * 100
|
|
|
|
| 171 |
),
|
| 172 |
quantiles={
|
| 173 |
'p10': [float(x) for x in forecast_result['quantiles']['p10']],
|
app/scheduler.py
CHANGED
|
@@ -76,7 +76,8 @@ class DataSyncScheduler:
|
|
| 76 |
try:
|
| 77 |
# 1. Check trading status for sample of stocks
|
| 78 |
logger.info("\n📊 Checking trading status for sample stocks...")
|
| 79 |
-
|
|
|
|
| 80 |
|
| 81 |
# 2. Check for new listings (incremental)
|
| 82 |
logger.info("\n🔍 Checking for new listings...")
|
|
@@ -201,22 +202,16 @@ class DataSyncScheduler:
|
|
| 201 |
try:
|
| 202 |
yf_symbol = f"{stock['symbol']}{stock['yfinance_suffix']}"
|
| 203 |
|
| 204 |
-
#
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
if info is None or not info.get('current_price'):
|
| 208 |
await self.db.update_trading_status(stock['symbol'], 'inactive')
|
| 209 |
inactive_count += 1
|
| 210 |
-
logger.debug(f" ⚠️ {stock['symbol']} - inactive (no price data)")
|
| 211 |
else:
|
| 212 |
-
# Stock has price data, mark as active
|
| 213 |
await self.db.update_trading_status(stock['symbol'], 'active')
|
| 214 |
updated_count += 1
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
# Rate limiting
|
| 218 |
-
if checked_count % 5 == 0:
|
| 219 |
-
await asyncio.sleep(0.3)
|
| 220 |
|
| 221 |
except Exception as e:
|
| 222 |
# Don't fail the entire sync if one stock fails
|
|
@@ -347,10 +342,6 @@ class DataSyncScheduler:
|
|
| 347 |
self.is_running = False
|
| 348 |
|
| 349 |
|
| 350 |
-
# Global scheduler instance
|
| 351 |
-
scheduler = DataSyncScheduler()
|
| 352 |
-
|
| 353 |
-
|
| 354 |
async def run_scheduler_loop():
|
| 355 |
"""Main scheduler loop - runs continuously"""
|
| 356 |
await scheduler.initialize()
|
|
|
|
| 76 |
try:
|
| 77 |
# 1. Check trading status for sample of stocks
|
| 78 |
logger.info("\n📊 Checking trading status for sample stocks...")
|
| 79 |
+
status_limit = int(os.getenv("METADATA_STATUS_CHECK_LIMIT", "15"))
|
| 80 |
+
await self.check_trading_status_sample(limit=status_limit)
|
| 81 |
|
| 82 |
# 2. Check for new listings (incremental)
|
| 83 |
logger.info("\n🔍 Checking for new listings...")
|
|
|
|
| 202 |
try:
|
| 203 |
yf_symbol = f"{stock['symbol']}{stock['yfinance_suffix']}"
|
| 204 |
|
| 205 |
+
# Use chart API (same path as forecasts) — avoids yfinance .info 429 storms
|
| 206 |
+
hist = await self.data_service.get_stock_data(yf_symbol, period="5d")
|
| 207 |
+
if hist is None or len(hist) < 1:
|
|
|
|
| 208 |
await self.db.update_trading_status(stock['symbol'], 'inactive')
|
| 209 |
inactive_count += 1
|
|
|
|
| 210 |
else:
|
|
|
|
| 211 |
await self.db.update_trading_status(stock['symbol'], 'active')
|
| 212 |
updated_count += 1
|
| 213 |
+
|
| 214 |
+
await asyncio.sleep(float(os.getenv("METADATA_STOCK_DELAY", "1.0")))
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
except Exception as e:
|
| 217 |
# Don't fail the entire sync if one stock fails
|
|
|
|
| 342 |
self.is_running = False
|
| 343 |
|
| 344 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
async def run_scheduler_loop():
|
| 346 |
"""Main scheduler loop - runs continuously"""
|
| 347 |
await scheduler.initialize()
|
app/services/data_service.py
CHANGED
|
@@ -41,6 +41,18 @@ class StockDataService:
|
|
| 41 |
self.cache = {} # In-memory cache (faster)
|
| 42 |
self._session = None
|
| 43 |
self._setup_cache_dir()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
def _setup_cache_dir(self):
|
| 46 |
"""Create cache directory if it doesn't exist"""
|
|
@@ -64,23 +76,36 @@ class StockDataService:
|
|
| 64 |
def _read_from_disk_cache(self, symbol: str, period: str) -> Optional[pd.DataFrame]:
|
| 65 |
"""Read data from disk cache"""
|
| 66 |
cache_path = self._get_cache_path(symbol, period)
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
| 68 |
try:
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
logger.debug(f"Disk cache hit for {symbol} ({period})")
|
| 71 |
return df
|
| 72 |
except Exception as e:
|
| 73 |
-
logger.
|
| 74 |
return None
|
| 75 |
|
| 76 |
def _write_to_disk_cache(self, symbol: str, period: str, df: pd.DataFrame):
|
| 77 |
-
"""Write data to disk cache"""
|
| 78 |
try:
|
| 79 |
cache_path = self._get_cache_path(symbol, period)
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
logger.debug(f"Cached {symbol} ({period}) to disk")
|
| 82 |
except Exception as e:
|
| 83 |
-
logger.
|
| 84 |
|
| 85 |
def _clear_expired_cache(self):
|
| 86 |
"""Clear expired cache entries"""
|
|
@@ -97,6 +122,64 @@ class StockDataService:
|
|
| 97 |
except Exception as e:
|
| 98 |
logger.warning(f"Cache cleanup failed: {e}")
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
def _yfinance_fetch(
|
| 101 |
self, symbol: str, period: str = "2y"
|
| 102 |
) -> Optional[pd.DataFrame]:
|
|
@@ -104,12 +187,12 @@ class StockDataService:
|
|
| 104 |
This runs inside ThreadPoolExecutor to enable parallelism.
|
| 105 |
"""
|
| 106 |
try:
|
| 107 |
-
ticker = yf.Ticker(symbol)
|
| 108 |
df = ticker.history(period=period)
|
| 109 |
|
| 110 |
if df.empty:
|
| 111 |
-
logger.warning(f"
|
| 112 |
-
return
|
| 113 |
|
| 114 |
# Ensure 'Close' column exists
|
| 115 |
if 'Close' not in df.columns:
|
|
@@ -131,7 +214,7 @@ class StockDataService:
|
|
| 131 |
|
| 132 |
except Exception as e:
|
| 133 |
logger.error(f"yfinance fetch failed for {symbol}: {e}")
|
| 134 |
-
return
|
| 135 |
|
| 136 |
@retry(
|
| 137 |
stop=stop_after_attempt(4),
|
|
@@ -202,7 +285,7 @@ class StockDataService:
|
|
| 202 |
loop = asyncio.get_event_loop()
|
| 203 |
|
| 204 |
def _fetch_info():
|
| 205 |
-
ticker = yf.Ticker(symbol)
|
| 206 |
info = ticker.info
|
| 207 |
if not info or 'symbol' not in info:
|
| 208 |
return None
|
|
|
|
| 41 |
self.cache = {} # In-memory cache (faster)
|
| 42 |
self._session = None
|
| 43 |
self._setup_cache_dir()
|
| 44 |
+
self._init_yfinance_session()
|
| 45 |
+
|
| 46 |
+
def _init_yfinance_session(self):
|
| 47 |
+
"""Use curl_cffi browser impersonation when available (bypasses Yahoo blocks)."""
|
| 48 |
+
try:
|
| 49 |
+
from curl_cffi import requests as curl_requests
|
| 50 |
+
|
| 51 |
+
self._session = curl_requests.Session(impersonate="chrome120")
|
| 52 |
+
logger.info("yfinance session: curl_cffi chrome120 impersonation enabled")
|
| 53 |
+
except ImportError:
|
| 54 |
+
logger.warning("curl_cffi not available — yfinance may be rate-limited")
|
| 55 |
+
self._session = None
|
| 56 |
|
| 57 |
def _setup_cache_dir(self):
|
| 58 |
"""Create cache directory if it doesn't exist"""
|
|
|
|
| 76 |
def _read_from_disk_cache(self, symbol: str, period: str) -> Optional[pd.DataFrame]:
|
| 77 |
"""Read data from disk cache"""
|
| 78 |
cache_path = self._get_cache_path(symbol, period)
|
| 79 |
+
json_path = cache_path.with_suffix(".json")
|
| 80 |
+
for path in (cache_path, json_path):
|
| 81 |
+
if not self._is_cache_fresh(path):
|
| 82 |
+
continue
|
| 83 |
try:
|
| 84 |
+
if path.suffix == ".json":
|
| 85 |
+
df = pd.read_json(path)
|
| 86 |
+
if "Date" in df.columns:
|
| 87 |
+
df = df.set_index("Date")
|
| 88 |
+
df.index = pd.to_datetime(df.index)
|
| 89 |
+
else:
|
| 90 |
+
df = pd.read_parquet(path)
|
| 91 |
logger.debug(f"Disk cache hit for {symbol} ({period})")
|
| 92 |
return df
|
| 93 |
except Exception as e:
|
| 94 |
+
logger.debug(f"Disk cache read failed for {symbol}: {e}")
|
| 95 |
return None
|
| 96 |
|
| 97 |
def _write_to_disk_cache(self, symbol: str, period: str, df: pd.DataFrame):
|
| 98 |
+
"""Write data to disk cache (Parquet if pyarrow available, else JSON)."""
|
| 99 |
try:
|
| 100 |
cache_path = self._get_cache_path(symbol, period)
|
| 101 |
+
try:
|
| 102 |
+
df.to_parquet(cache_path)
|
| 103 |
+
except ImportError:
|
| 104 |
+
json_path = cache_path.with_suffix(".json")
|
| 105 |
+
df.reset_index().to_json(json_path, orient="records", date_format="iso")
|
| 106 |
logger.debug(f"Cached {symbol} ({period}) to disk")
|
| 107 |
except Exception as e:
|
| 108 |
+
logger.debug(f"Skipped disk cache for {symbol}: {e}")
|
| 109 |
|
| 110 |
def _clear_expired_cache(self):
|
| 111 |
"""Clear expired cache entries"""
|
|
|
|
| 122 |
except Exception as e:
|
| 123 |
logger.warning(f"Cache cleanup failed: {e}")
|
| 124 |
|
| 125 |
+
@staticmethod
|
| 126 |
+
def _period_to_yahoo_range(period: str) -> str:
|
| 127 |
+
mapping = {
|
| 128 |
+
"1mo": "1mo",
|
| 129 |
+
"3mo": "3mo",
|
| 130 |
+
"6mo": "6mo",
|
| 131 |
+
"1y": "1y",
|
| 132 |
+
"2y": "2y",
|
| 133 |
+
"5y": "5y",
|
| 134 |
+
"10y": "10y",
|
| 135 |
+
"max": "max",
|
| 136 |
+
}
|
| 137 |
+
return mapping.get(period, "2y")
|
| 138 |
+
|
| 139 |
+
def _yahoo_chart_fetch(self, symbol: str, period: str = "2y") -> Optional[pd.DataFrame]:
|
| 140 |
+
"""Fallback: Yahoo v8 chart API via curl_cffi (works when yfinance library is blocked)."""
|
| 141 |
+
if not self._session:
|
| 142 |
+
return None
|
| 143 |
+
|
| 144 |
+
try:
|
| 145 |
+
range_param = self._period_to_yahoo_range(period)
|
| 146 |
+
url = (
|
| 147 |
+
"https://query1.finance.yahoo.com/v8/finance/chart/"
|
| 148 |
+
f"{symbol}?interval=1d&range={range_param}"
|
| 149 |
+
)
|
| 150 |
+
response = self._session.get(url, timeout=30)
|
| 151 |
+
response.raise_for_status()
|
| 152 |
+
payload = response.json()
|
| 153 |
+
|
| 154 |
+
result = payload.get("chart", {}).get("result", [])
|
| 155 |
+
if not result:
|
| 156 |
+
return None
|
| 157 |
+
|
| 158 |
+
chart = result[0]
|
| 159 |
+
timestamps = chart.get("timestamp") or []
|
| 160 |
+
quotes = chart.get("indicators", {}).get("quote", [{}])[0]
|
| 161 |
+
closes = quotes.get("close") or []
|
| 162 |
+
|
| 163 |
+
rows = [
|
| 164 |
+
(pd.Timestamp(ts, unit="s"), close)
|
| 165 |
+
for ts, close in zip(timestamps, closes)
|
| 166 |
+
if close is not None
|
| 167 |
+
]
|
| 168 |
+
if len(rows) < 30:
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
df = pd.DataFrame(rows, columns=["Date", "Close"]).set_index("Date")
|
| 172 |
+
meta = chart.get("meta", {})
|
| 173 |
+
df.attrs["name"] = meta.get("longName") or meta.get("shortName") or symbol
|
| 174 |
+
df.attrs["exchange"] = meta.get("exchangeName", "UNKNOWN")
|
| 175 |
+
df.attrs["currency"] = meta.get("currency", "USD")
|
| 176 |
+
logger.info(f"Yahoo chart API fallback succeeded for {symbol} ({len(df)} rows)")
|
| 177 |
+
return df
|
| 178 |
+
|
| 179 |
+
except Exception as e:
|
| 180 |
+
logger.warning(f"Yahoo chart API fallback failed for {symbol}: {e}")
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
def _yfinance_fetch(
|
| 184 |
self, symbol: str, period: str = "2y"
|
| 185 |
) -> Optional[pd.DataFrame]:
|
|
|
|
| 187 |
This runs inside ThreadPoolExecutor to enable parallelism.
|
| 188 |
"""
|
| 189 |
try:
|
| 190 |
+
ticker = yf.Ticker(symbol, session=self._session) if self._session else yf.Ticker(symbol)
|
| 191 |
df = ticker.history(period=period)
|
| 192 |
|
| 193 |
if df.empty:
|
| 194 |
+
logger.warning(f"yfinance empty for {symbol}, trying Yahoo chart API")
|
| 195 |
+
return self._yahoo_chart_fetch(symbol, period)
|
| 196 |
|
| 197 |
# Ensure 'Close' column exists
|
| 198 |
if 'Close' not in df.columns:
|
|
|
|
| 214 |
|
| 215 |
except Exception as e:
|
| 216 |
logger.error(f"yfinance fetch failed for {symbol}: {e}")
|
| 217 |
+
return self._yahoo_chart_fetch(symbol, period)
|
| 218 |
|
| 219 |
@retry(
|
| 220 |
stop=stop_after_attempt(4),
|
|
|
|
| 285 |
loop = asyncio.get_event_loop()
|
| 286 |
|
| 287 |
def _fetch_info():
|
| 288 |
+
ticker = yf.Ticker(symbol, session=self._session) if self._session else yf.Ticker(symbol)
|
| 289 |
info = ticker.info
|
| 290 |
if not info or 'symbol' not in info:
|
| 291 |
return None
|
app/services/timesfm_service.py
CHANGED
|
@@ -29,9 +29,12 @@ class TimesFMService:
|
|
| 29 |
self.available = TIMESFM_AVAILABLE
|
| 30 |
|
| 31 |
async def load_model(self):
|
| 32 |
-
"""Load the TimesFM model from HuggingFace"""
|
| 33 |
if not self.available:
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
try:
|
| 37 |
logger.info(f"Loading TimesFM model: {self.model_id}")
|
|
@@ -144,8 +147,8 @@ class TimesFMService:
|
|
| 144 |
point_forecast = []
|
| 145 |
current_price = last_price
|
| 146 |
|
| 147 |
-
for
|
| 148 |
-
current_price = current_price * (1 + daily_drift
|
| 149 |
point_forecast.append(current_price)
|
| 150 |
|
| 151 |
# Generate confidence intervals
|
|
|
|
| 29 |
self.available = TIMESFM_AVAILABLE
|
| 30 |
|
| 31 |
async def load_model(self):
|
| 32 |
+
"""Load the TimesFM model from HuggingFace (or use statistical fallback)"""
|
| 33 |
if not self.available:
|
| 34 |
+
logger.warning(
|
| 35 |
+
"TimesFM 2.5 not available in this environment — API will use statistical fallback forecasts"
|
| 36 |
+
)
|
| 37 |
+
return
|
| 38 |
|
| 39 |
try:
|
| 40 |
logger.info(f"Loading TimesFM model: {self.model_id}")
|
|
|
|
| 147 |
point_forecast = []
|
| 148 |
current_price = last_price
|
| 149 |
|
| 150 |
+
for day in range(horizon):
|
| 151 |
+
current_price = current_price * (1 + daily_drift)
|
| 152 |
point_forecast.append(current_price)
|
| 153 |
|
| 154 |
# Generate confidence intervals
|
scripts/backfill_sectors.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Backfill missing sector on stocks via yfinance (batch, rate-limited).
|
| 3 |
+
Run: python scripts/backfill_sectors.py [--limit 500]
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import argparse
|
| 7 |
+
import asyncio
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
| 14 |
+
|
| 15 |
+
from dotenv import load_dotenv
|
| 16 |
+
|
| 17 |
+
load_dotenv()
|
| 18 |
+
|
| 19 |
+
from app.services.database_service import DatabaseService
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
import yfinance as yf
|
| 23 |
+
except ImportError:
|
| 24 |
+
print("pip install yfinance")
|
| 25 |
+
sys.exit(1)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def yf_ticker(symbol: str, suffix: str) -> str:
|
| 29 |
+
s = symbol.upper()
|
| 30 |
+
if not suffix:
|
| 31 |
+
return s
|
| 32 |
+
suf = suffix if suffix.startswith(".") else f".{suffix}"
|
| 33 |
+
return s if s.endswith(suf) else f"{s}{suf}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
async def main(limit: int) -> None:
|
| 37 |
+
turso_url = os.getenv("TURSO_URL")
|
| 38 |
+
turso_token = os.getenv("TURSO_TOKEN")
|
| 39 |
+
if not turso_url or not turso_token:
|
| 40 |
+
print("Set TURSO_URL and TURSO_TOKEN in backend/.env")
|
| 41 |
+
sys.exit(1)
|
| 42 |
+
|
| 43 |
+
db = DatabaseService(url=turso_url, token=turso_token)
|
| 44 |
+
await db.initialize()
|
| 45 |
+
|
| 46 |
+
rows = await db._execute(
|
| 47 |
+
"""
|
| 48 |
+
SELECT symbol, country, yfinance_suffix, name
|
| 49 |
+
FROM stocks
|
| 50 |
+
WHERE sector IS NULL OR sector = ''
|
| 51 |
+
ORDER BY market_cap DESC NULLS LAST
|
| 52 |
+
LIMIT ?
|
| 53 |
+
""",
|
| 54 |
+
[limit],
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
print(f"Backfilling sector for {len(rows)} stocks...")
|
| 58 |
+
updated = 0
|
| 59 |
+
for i, row in enumerate(rows, 1):
|
| 60 |
+
sym = yf_ticker(row["symbol"], row.get("yfinance_suffix") or "")
|
| 61 |
+
try:
|
| 62 |
+
info = yf.Ticker(sym).info or {}
|
| 63 |
+
sector = info.get("sector") or info.get("industry")
|
| 64 |
+
if sector:
|
| 65 |
+
await db._execute(
|
| 66 |
+
"UPDATE stocks SET sector = ? WHERE symbol = ? AND country = ?",
|
| 67 |
+
[str(sector)[:120], row["symbol"], row["country"]],
|
| 68 |
+
)
|
| 69 |
+
updated += 1
|
| 70 |
+
print(f" [{i}/{len(rows)}] {sym} -> {sector}")
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f" [{i}/{len(rows)}] {sym} skip: {e}")
|
| 73 |
+
time.sleep(0.25)
|
| 74 |
+
|
| 75 |
+
print(f"Done. Updated {updated} rows.")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
parser = argparse.ArgumentParser()
|
| 80 |
+
parser.add_argument("--limit", type=int, default=500)
|
| 81 |
+
args = parser.parse_args()
|
| 82 |
+
asyncio.run(main(args.limit))
|
scripts/configure_hf_space.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configure HuggingFace Space secrets/variables for free-tier deploy (no HF Jobs)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import secrets
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from dotenv import dotenv_values
|
| 11 |
+
from huggingface_hub import HfApi
|
| 12 |
+
|
| 13 |
+
REPO_ID = "cramamoorthy/stock-prediction-api"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> int:
|
| 17 |
+
env_path = Path(__file__).resolve().parent.parent / ".env"
|
| 18 |
+
env = dotenv_values(env_path)
|
| 19 |
+
api = HfApi()
|
| 20 |
+
|
| 21 |
+
turso_url = env.get("TURSO_URL") or os.getenv("TURSO_URL")
|
| 22 |
+
turso_token = env.get("TURSO_TOKEN") or os.getenv("TURSO_TOKEN")
|
| 23 |
+
if not turso_url or not turso_token:
|
| 24 |
+
print("Missing TURSO_URL / TURSO_TOKEN in backend/.env", file=sys.stderr)
|
| 25 |
+
return 1
|
| 26 |
+
|
| 27 |
+
cron_secret = secrets.token_urlsafe(32)
|
| 28 |
+
|
| 29 |
+
print("Setting Space secrets and variables...")
|
| 30 |
+
api.add_space_secret(REPO_ID, "TURSO_URL", turso_url)
|
| 31 |
+
api.add_space_secret(REPO_ID, "TURSO_TOKEN", turso_token)
|
| 32 |
+
api.add_space_secret(REPO_ID, "CRON_SECRET", cron_secret)
|
| 33 |
+
api.add_space_variable(REPO_ID, "DAILY_FORECAST_LIMIT_PER_COUNTRY", "50")
|
| 34 |
+
|
| 35 |
+
api.add_space_variable(REPO_ID, "ENABLE_INTERNAL_CRON", "true")
|
| 36 |
+
|
| 37 |
+
import requests
|
| 38 |
+
from huggingface_hub.utils import build_hf_headers
|
| 39 |
+
from huggingface_hub import get_token
|
| 40 |
+
|
| 41 |
+
r = requests.get(
|
| 42 |
+
f"https://huggingface.co/api/spaces/{REPO_ID}/secrets",
|
| 43 |
+
headers=build_hf_headers(token=get_token()),
|
| 44 |
+
)
|
| 45 |
+
secret_keys = list(r.json().keys()) if r.ok else ["TURSO_URL", "TURSO_TOKEN", "CRON_SECRET"]
|
| 46 |
+
print("Space secrets:", ", ".join(secret_keys))
|
| 47 |
+
print("Variables: DAILY_FORECAST_LIMIT_PER_COUNTRY=50, ENABLE_INTERNAL_CRON=true")
|
| 48 |
+
print("Internal cron: daily 0 21 * * 1-5 UTC, weekly 0 2 * * 0 UTC (while Space is running)")
|
| 49 |
+
print("Optional UI: Settings → Scheduler — same paths/headers to wake sleeping Space.")
|
| 50 |
+
print("Space will restart after secret changes.")
|
| 51 |
+
|
| 52 |
+
# Persist cron secret locally for manual triggers (gitignored via .env)
|
| 53 |
+
lines = env_path.read_text(encoding="utf-8").splitlines()
|
| 54 |
+
out: list[str] = []
|
| 55 |
+
replaced = False
|
| 56 |
+
for line in lines:
|
| 57 |
+
if line.startswith("CRON_SECRET="):
|
| 58 |
+
out.append(f"CRON_SECRET={cron_secret}")
|
| 59 |
+
replaced = True
|
| 60 |
+
else:
|
| 61 |
+
out.append(line)
|
| 62 |
+
if not replaced:
|
| 63 |
+
out.append(f"CRON_SECRET={cron_secret}")
|
| 64 |
+
env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
|
| 65 |
+
print("Updated backend/.env CRON_SECRET for local curl tests.")
|
| 66 |
+
return 0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
raise SystemExit(main())
|
scripts/daily_pipeline.py
CHANGED
|
@@ -44,7 +44,7 @@ HORIZONS = [1, 5, 20, 60]
|
|
| 44 |
|
| 45 |
# Max stocks per country per run (limit to manage pipeline duration)
|
| 46 |
# With 7,000+ stocks, stagger across multiple runs or increase for overnight processing
|
| 47 |
-
MAX_STOCKS_PER_COUNTRY =
|
| 48 |
|
| 49 |
# Delay between individual stock fetches (seconds)
|
| 50 |
STOCK_DELAY = 0.3
|
|
@@ -162,7 +162,7 @@ async def process_stock(
|
|
| 162 |
}
|
| 163 |
|
| 164 |
# Cache in database
|
| 165 |
-
await db.cache_forecast(
|
| 166 |
|
| 167 |
print(f"[OK] {len(HORIZONS)} forecasts cached")
|
| 168 |
return True
|
|
|
|
| 44 |
|
| 45 |
# Max stocks per country per run (limit to manage pipeline duration)
|
| 46 |
# With 7,000+ stocks, stagger across multiple runs or increase for overnight processing
|
| 47 |
+
MAX_STOCKS_PER_COUNTRY = int(os.getenv("DAILY_FORECAST_LIMIT_PER_COUNTRY", "10000"))
|
| 48 |
|
| 49 |
# Delay between individual stock fetches (seconds)
|
| 50 |
STOCK_DELAY = 0.3
|
|
|
|
| 162 |
}
|
| 163 |
|
| 164 |
# Cache in database
|
| 165 |
+
await db.cache_forecast(yf_symbol.upper(), horizon, forecast_data)
|
| 166 |
|
| 167 |
print(f"[OK] {len(HORIZONS)} forecasts cached")
|
| 168 |
return True
|
scripts/seed_sp500.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
S&P / US starter seed — delegates to verified stock seeder (US subset).
|
| 3 |
+
Documented in QUICKSTART.md as the fast path for local development.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import asyncio
|
| 7 |
+
import runpy
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
if __name__ == "__main__":
|
| 11 |
+
script = Path(__file__).parent / "seed_verified_stocks.py"
|
| 12 |
+
runpy.run_path(str(script), run_name="__main__")
|
scripts/setup_free_wake_cron.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configure free wake cron: HF Space Scheduler (Playwright) or cron-job.org API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
import requests
|
| 11 |
+
from dotenv import dotenv_values
|
| 12 |
+
from huggingface_hub import get_token
|
| 13 |
+
|
| 14 |
+
SPACE = "cramamoorthy/stock-prediction-api"
|
| 15 |
+
SPACE_URL = "https://cramamoorthy-stock-prediction-api.hf.space"
|
| 16 |
+
SETTINGS = f"https://huggingface.co/spaces/{SPACE}/settings"
|
| 17 |
+
CRON_API = "https://api.cron-job.org"
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _secret() -> str:
|
| 21 |
+
v = dotenv_values(Path(__file__).resolve().parent.parent / ".env").get("CRON_SECRET")
|
| 22 |
+
if not v:
|
| 23 |
+
raise SystemExit("CRON_SECRET missing in backend/.env — run configure_hf_space.py first")
|
| 24 |
+
return v
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _job_schedule_daily() -> dict:
|
| 28 |
+
return {
|
| 29 |
+
"timezone": "UTC",
|
| 30 |
+
"expiresAt": 0,
|
| 31 |
+
"hours": [21],
|
| 32 |
+
"minutes": [0],
|
| 33 |
+
"mdays": [-1],
|
| 34 |
+
"months": [-1],
|
| 35 |
+
"wdays": [1, 2, 3, 4, 5],
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _job_schedule_weekly() -> dict:
|
| 40 |
+
return {
|
| 41 |
+
"timezone": "UTC",
|
| 42 |
+
"expiresAt": 0,
|
| 43 |
+
"hours": [2],
|
| 44 |
+
"minutes": [0],
|
| 45 |
+
"mdays": [-1],
|
| 46 |
+
"months": [-1],
|
| 47 |
+
"wdays": [0],
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def setup_cron_job_org(secret: str) -> bool:
|
| 52 |
+
api_key = os.getenv("CRON_JOB_ORG_API_KEY") or dotenv_values(
|
| 53 |
+
Path(__file__).resolve().parent.parent / ".env"
|
| 54 |
+
).get("CRON_JOB_ORG_API_KEY")
|
| 55 |
+
if not api_key:
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
headers = {
|
| 59 |
+
"Authorization": f"Bearer {api_key}",
|
| 60 |
+
"Content-Type": "application/json",
|
| 61 |
+
}
|
| 62 |
+
existing = requests.get(f"{CRON_API}/jobs", headers=headers, timeout=30)
|
| 63 |
+
existing.raise_for_status()
|
| 64 |
+
titles = {j.get("title") for j in existing.json().get("jobs", [])}
|
| 65 |
+
|
| 66 |
+
jobs = [
|
| 67 |
+
(
|
| 68 |
+
"stock-prediction-daily",
|
| 69 |
+
f"{SPACE_URL}/api/v1/cron/daily",
|
| 70 |
+
_job_schedule_daily(),
|
| 71 |
+
),
|
| 72 |
+
(
|
| 73 |
+
"stock-prediction-weekly",
|
| 74 |
+
f"{SPACE_URL}/api/v1/cron/metadata/weekly",
|
| 75 |
+
_job_schedule_weekly(),
|
| 76 |
+
),
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
for title, url, schedule in jobs:
|
| 80 |
+
if title in titles:
|
| 81 |
+
print(f"cron-job.org: {title} already exists")
|
| 82 |
+
continue
|
| 83 |
+
payload = {
|
| 84 |
+
"job": {
|
| 85 |
+
"title": title,
|
| 86 |
+
"url": url,
|
| 87 |
+
"enabled": True,
|
| 88 |
+
"requestMethod": 1,
|
| 89 |
+
"schedule": schedule,
|
| 90 |
+
"extendedData": {
|
| 91 |
+
"headers": {"X-Cron-Secret": secret},
|
| 92 |
+
},
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
r = requests.put(f"{CRON_API}/jobs", headers=headers, json=payload, timeout=30)
|
| 96 |
+
r.raise_for_status()
|
| 97 |
+
print(f"cron-job.org: created {title} (id={r.json().get('jobId')})")
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def setup_hf_scheduler_playwright(secret: str) -> bool:
|
| 102 |
+
try:
|
| 103 |
+
from playwright.sync_api import sync_playwright
|
| 104 |
+
except ImportError:
|
| 105 |
+
return False
|
| 106 |
+
|
| 107 |
+
token = get_token()
|
| 108 |
+
if not token:
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
captured: list[dict] = []
|
| 112 |
+
|
| 113 |
+
with sync_playwright() as p:
|
| 114 |
+
browser = p.chromium.launch(headless=True)
|
| 115 |
+
context = browser.new_context(
|
| 116 |
+
user_agent=(
|
| 117 |
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 118 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
def on_request(req):
|
| 123 |
+
u = req.url
|
| 124 |
+
if "huggingface.co" in u and req.method in ("POST", "PUT", "PATCH", "DELETE"):
|
| 125 |
+
if any(x in u.lower() for x in ("schedul", "cron", "webhook", "request")):
|
| 126 |
+
captured.append({"method": req.method, "url": u, "post": req.post_data})
|
| 127 |
+
|
| 128 |
+
page = context.new_page()
|
| 129 |
+
page.on("request", on_request)
|
| 130 |
+
|
| 131 |
+
# Token login flow used by HF hub
|
| 132 |
+
page.goto("https://huggingface.co/login", wait_until="domcontentloaded", timeout=60_000)
|
| 133 |
+
page.goto(
|
| 134 |
+
f"https://huggingface.co/login?token={token}&next=/spaces/{SPACE}/settings",
|
| 135 |
+
wait_until="domcontentloaded",
|
| 136 |
+
timeout=120_000,
|
| 137 |
+
)
|
| 138 |
+
page.wait_for_timeout(3000)
|
| 139 |
+
if "login" in page.url.lower() and "settings" not in page.url:
|
| 140 |
+
page.goto(SETTINGS, wait_until="domcontentloaded", timeout=120_000)
|
| 141 |
+
page.wait_for_timeout(2000)
|
| 142 |
+
|
| 143 |
+
if "403" in (page.title() or "") or "ERROR" in (page.inner_text("body")[:200] if page.locator("body").count() else ""):
|
| 144 |
+
browser.close()
|
| 145 |
+
return False
|
| 146 |
+
|
| 147 |
+
# Open Scheduler tab
|
| 148 |
+
for sel in [
|
| 149 |
+
"a[href*='scheduler']",
|
| 150 |
+
"button:has-text('Scheduler')",
|
| 151 |
+
"text=Scheduler",
|
| 152 |
+
]:
|
| 153 |
+
loc = page.locator(sel)
|
| 154 |
+
if loc.count():
|
| 155 |
+
loc.first.click()
|
| 156 |
+
page.wait_for_timeout(2000)
|
| 157 |
+
break
|
| 158 |
+
|
| 159 |
+
def add_job(title: str, cron_expr: str, path: str) -> None:
|
| 160 |
+
add_btn = page.get_by_role("button", name=lambda n: n and any(
|
| 161 |
+
w in (n or "").lower() for w in ("add", "new", "create")
|
| 162 |
+
))
|
| 163 |
+
if add_btn.count():
|
| 164 |
+
add_btn.first.click()
|
| 165 |
+
page.wait_for_timeout(1000)
|
| 166 |
+
|
| 167 |
+
page.locator("input").filter(has_text="").all() # noop wake
|
| 168 |
+
for inp in page.locator("input:visible").all():
|
| 169 |
+
ph = (inp.get_attribute("placeholder") or "").lower()
|
| 170 |
+
name = (inp.get_attribute("name") or "").lower()
|
| 171 |
+
if "cron" in ph or "schedule" in ph or "cron" in name:
|
| 172 |
+
inp.fill(cron_expr)
|
| 173 |
+
elif "path" in ph or "path" in name or "url" in ph:
|
| 174 |
+
inp.fill(path)
|
| 175 |
+
|
| 176 |
+
page.locator("select:visible").first.select_option("POST") if page.locator("select:visible").count() else None
|
| 177 |
+
|
| 178 |
+
# headers
|
| 179 |
+
page.get_by_text("Header", exact=False).first.click() if page.get_by_text("Header").count() else None
|
| 180 |
+
header_inputs = page.locator("input:visible")
|
| 181 |
+
for i, inp in enumerate(header_inputs.all()):
|
| 182 |
+
val = inp.input_value()
|
| 183 |
+
if not val and i == 0:
|
| 184 |
+
inp.fill("X-Cron-Secret")
|
| 185 |
+
elif val == "X-Cron-Secret" or (not val and i == 1):
|
| 186 |
+
inp.fill(secret)
|
| 187 |
+
|
| 188 |
+
save = page.get_by_role("button", name=lambda n: n and any(
|
| 189 |
+
w in (n or "").lower() for w in ("save", "create", "add", "submit")
|
| 190 |
+
))
|
| 191 |
+
if save.count():
|
| 192 |
+
save.first.click()
|
| 193 |
+
page.wait_for_timeout(3000)
|
| 194 |
+
print(f"HF Scheduler UI: attempted {title}")
|
| 195 |
+
|
| 196 |
+
add_job("daily", "0 21 * * 1-5", "/api/v1/cron/daily")
|
| 197 |
+
add_job("weekly", "0 2 * * 0", "/api/v1/cron/metadata/weekly")
|
| 198 |
+
|
| 199 |
+
out = Path(__file__).parent / "hf_scheduler_capture.json"
|
| 200 |
+
out.write_text(json.dumps(captured, indent=2), encoding="utf-8")
|
| 201 |
+
|
| 202 |
+
# Verify jobs listed in UI
|
| 203 |
+
body = page.inner_text("body") if page.locator("body").count() else ""
|
| 204 |
+
ok = "/api/v1/cron/daily" in body or "cron/daily" in body
|
| 205 |
+
browser.close()
|
| 206 |
+
return ok
|
| 207 |
+
|
| 208 |
+
return False
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def setup_hf_scheduler_api(secret: str) -> bool:
|
| 212 |
+
"""Try discovered/internal HF endpoints."""
|
| 213 |
+
from huggingface_hub.utils import build_hf_headers
|
| 214 |
+
|
| 215 |
+
token = get_token()
|
| 216 |
+
if not token:
|
| 217 |
+
return False
|
| 218 |
+
h = build_hf_headers(token=token)
|
| 219 |
+
h["Content-Type"] = "application/json"
|
| 220 |
+
base = f"https://huggingface.co/api/spaces/{SPACE}"
|
| 221 |
+
|
| 222 |
+
payloads = [
|
| 223 |
+
{
|
| 224 |
+
"schedule": "0 21 * * 1-5",
|
| 225 |
+
"method": "POST",
|
| 226 |
+
"path": "/api/v1/cron/daily",
|
| 227 |
+
"headers": [{"name": "X-Cron-Secret", "value": secret}],
|
| 228 |
+
},
|
| 229 |
+
{
|
| 230 |
+
"schedule": "0 2 * * 0",
|
| 231 |
+
"method": "POST",
|
| 232 |
+
"path": "/api/v1/cron/metadata/weekly",
|
| 233 |
+
"headers": [{"name": "X-Cron-Secret", "value": secret}],
|
| 234 |
+
},
|
| 235 |
+
]
|
| 236 |
+
|
| 237 |
+
for path_suffix in ("scheduled-requests", "scheduler/requests", "schedule"):
|
| 238 |
+
for p in payloads:
|
| 239 |
+
r = requests.post(f"{base}/{path_suffix}", headers=h, json=p, timeout=30)
|
| 240 |
+
if r.status_code in (200, 201):
|
| 241 |
+
print(f"HF API {path_suffix}: OK")
|
| 242 |
+
return True
|
| 243 |
+
return False
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def main() -> int:
|
| 247 |
+
import subprocess
|
| 248 |
+
root = Path(__file__).resolve().parent.parent.parent
|
| 249 |
+
script = root / "scripts" / "deploy-hf-cron-worker.ps1"
|
| 250 |
+
if script.exists():
|
| 251 |
+
subprocess.run(
|
| 252 |
+
["powershell", "-NoProfile", "-File", str(script)],
|
| 253 |
+
cwd=str(root),
|
| 254 |
+
check=True,
|
| 255 |
+
)
|
| 256 |
+
print("Done: Cloudflare Worker cron (free).")
|
| 257 |
+
return 0
|
| 258 |
+
print(f"Missing {script}", file=sys.stderr)
|
| 259 |
+
return 1
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
if __name__ == "__main__":
|
| 263 |
+
raise SystemExit(main())
|
start-api.ps1
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Start FastAPI locally (single instance on port 8000)
|
| 2 |
+
$ErrorActionPreference = "Stop"
|
| 3 |
+
Set-Location $PSScriptRoot
|
| 4 |
+
|
| 5 |
+
$existing = Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue
|
| 6 |
+
if ($existing) {
|
| 7 |
+
$pid = $existing[0].OwningProcess
|
| 8 |
+
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue
|
| 9 |
+
if ($proc -and $proc.Path -like "*python*") {
|
| 10 |
+
Write-Host "Port 8000 already in use by API (PID $pid). Not restarting."
|
| 11 |
+
exit 0
|
| 12 |
+
}
|
| 13 |
+
Stop-Process -Id $pid -Force -ErrorAction SilentlyContinue
|
| 14 |
+
Start-Sleep -Seconds 1
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
Write-Host "Starting Stock Prediction API on http://127.0.0.1:8000"
|
| 18 |
+
& .\venv\Scripts\python.exe -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
|
test_yfinance_quick.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
from app.services.data_service import StockDataService
|
| 3 |
+
|
| 4 |
+
async def main():
|
| 5 |
+
s = StockDataService()
|
| 6 |
+
df = await s.get_stock_data("AAPL", "2y", force_refresh=True)
|
| 7 |
+
print("result", None if df is None else len(df), list(df.columns) if df is not None else None)
|
| 8 |
+
|
| 9 |
+
asyncio.run(main())
|