Hermes Agent commited on
Commit
9fc69ab
·
1 Parent(s): 6a60bcc

Baseline commit: full project state before audit fixes

Browse files
.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
- env/
10
- ENV/
11
-
12
- # Environment
13
  .env
14
- .env.local
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
- *.sqlite
 
 
 
 
 
 
 
 
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
- # Expose port
19
  EXPOSE 7860
20
 
21
- # Run the application
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
- 1. Create a new Space on HuggingFace (Docker type)
58
- 2. Push this directory to the Space repository
59
- 3. Set environment variables in Space settings:
60
- - `TURSO_URL`
61
- - `TURSO_TOKEN`
62
- 4. Space will automatically build and deploy
 
 
 
 
 
 
 
 
 
 
 
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-pytorch",
47
- device="cpu" # Use CPU for free tier compatibility
48
  )
49
  await timesfm_service.load_model()
50
- print("✅ TimesFM model loaded")
 
 
 
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
- stock_data = await data_service.get_stock_data(symbol, period="2y")
117
- if not stock_data or len(stock_data) < 100:
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=stock_data['close'].tolist(),
126
  horizon=horizon
127
  )
128
 
129
  # Generate chart
130
  chart_svg = chart_service.generate_forecast_chart(
131
  symbol=symbol,
132
- historical_prices=stock_data['close'].tolist()[-60:], # Last 60 days
133
  forecast=forecast_result,
134
- current_price=stock_data['close'].iloc[-1]
135
  )
136
 
137
  # Prepare response
138
  response = ForecastResponse(
139
  symbol=symbol,
140
- name=stock_data.get('name', symbol),
141
- exchange=stock_data.get('exchange', 'UNKNOWN'),
142
- currency=stock_data.get('currency', 'USD'),
143
- current_price=float(stock_data['close'].iloc[-1]),
144
- last_updated=stock_data['date'].iloc[-1].isoformat(),
145
  horizon_days=horizon,
146
  point_forecast=float(forecast_result['point_forecast'][-1]),
147
  percentage_change=float(
148
- ((forecast_result['point_forecast'][-1] - stock_data['close'].iloc[-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
- await self.check_trading_status_sample(limit=100)
 
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
- # Try to get stock info first (lighter than historical data)
205
- info = await self.data_service.get_stock_info(yf_symbol)
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
- logger.debug(f" ✅ {stock['symbol']} - active (price: {info['current_price']})")
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
- if self._is_cache_fresh(cache_path):
 
 
 
68
  try:
69
- df = pd.read_parquet(cache_path)
 
 
 
 
 
 
70
  logger.debug(f"Disk cache hit for {symbol} ({period})")
71
  return df
72
  except Exception as e:
73
- logger.warning(f"Failed to read disk cache for {symbol}: {e}")
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
- df.to_parquet(cache_path)
 
 
 
 
81
  logger.debug(f"Cached {symbol} ({period}) to disk")
82
  except Exception as e:
83
- logger.warning(f"Failed to write disk cache for {symbol}: {e}")
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"No data found for {symbol}")
112
- return None
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 None
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
- raise RuntimeError("TimesFM 2.5 not available. Install latest transformers or check import")
 
 
 
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 _ in range(horizon):
148
- current_price = current_price * (1 + daily_drift + np.random.normal(0, volatility * 0.5))
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 = 10000 # Set high enough to cover US all stocks (~6,076)
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(clean_symbol, horizon, forecast_data)
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())