DockerSpace / main.py
DennisChan0909's picture
Backup current stock predictor strategies
ee37d63
Raw
History Blame Contribute Delete
9.27 kB
"""
Stock Prediction API — FastAPI application entry point.
Run with:
uvicorn main:app --reload --host 0.0.0.0 --port 8000
"""
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
except ImportError:
pass
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI
from fastapi import HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from limiter import limiter
from routers.stock import router as stock_router
from routers.line_webhook import router as line_router
from routers.metal import router as metal_router
from routers.oil import router as oil_router
from routers.paper_trading import router as paper_trading_router
APP_VERSION = "1.1.0"
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# ---------------------------------------------------------------------------
# Startup warmup — pre-train popular stock models in background to avoid
# Render cold-start timeout (first request needs 45s+, Render limit is 30s).
# ---------------------------------------------------------------------------
_WARMUP_STOCKS = [
code.strip()
for code in os.getenv("WARMUP_STOCKS", "2330,0050").split(",")
if code.strip()
]
async def _warmup_models() -> None:
"""Pre-train models for popular stocks at startup (background task)."""
from services.predictor_service import get_prediction
_logger = logging.getLogger("startup.warmup")
if not _WARMUP_STOCKS:
_logger.info("Startup warmup disabled")
return
_logger.info("Starting warmup for stocks: %s", _WARMUP_STOCKS)
for code in _WARMUP_STOCKS:
try:
await get_prediction(code, raise_http=False)
_logger.info("Warmup complete for %s", code)
except Exception as exc:
_logger.warning("Warmup failed for %s: %s", code, exc)
# Small delay between stocks to avoid hammering data sources
await asyncio.sleep(1)
_logger.info("Warmup finished")
async def _maybe_setup_rich_menu():
if os.environ.get("RICH_MENU_SETUP_NOW") != "1":
return
_log = logging.getLogger("startup.rich_menu")
try:
import subprocess
result = subprocess.run(
["python", "setup_rich_menu.py"],
capture_output=True, text=True, timeout=90,
cwd=os.path.dirname(os.path.abspath(__file__)),
)
_log.warning("rich_menu stdout:\n%s", result.stdout)
if result.stderr:
_log.warning("rich_menu stderr:\n%s", result.stderr)
_log.warning("rich_menu exit=%d — remove RICH_MENU_SETUP_NOW secret now", result.returncode)
except Exception as e:
_log.error("rich_menu setup failed: %s", e)
async def _warmup_from_queue():
"""On HF Space: pre-load all Mac 'done' results from queue.json into prediction cache."""
from services.hf_queue_service import is_on_hf_space, read_queue
from services.predictor_service import _set_cached_prediction, is_publishable_result
if not is_on_hf_space():
return
try:
queue = await asyncio.to_thread(read_queue)
loaded = 0
for stock_no, item in queue.items():
if item.get("status") != "done" or not is_publishable_result(item.get("result")):
continue
result = dict(item["result"])
result["optimized"] = True
result["source"] = "mac_worker"
_set_cached_prediction(stock_no, result)
loaded += 1
logging.getLogger(__name__).info("Queue warmup: loaded %d stocks from HF Dataset", loaded)
except Exception as e:
logging.getLogger(__name__).warning("Queue warmup failed: %s", e)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan handler: launch warmup background task at startup."""
# Queue warmup is awaited — loads Mac results into cache BEFORE accepting requests
await _warmup_from_queue()
asyncio.create_task(_warmup_models())
asyncio.create_task(_maybe_setup_rich_menu())
yield
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(
title="Taiwan Stock Predictor API",
description=(
"Fetches OHLCV data from TWSE/TPEX official APIs, computes technical "
"indicators, and uses a RandomForest ML model to generate directional "
"signals. NOT financial advice."
),
version=APP_VERSION,
lifespan=lifespan,
)
# ---------------------------------------------------------------------------
# CORS — allow Vite dev server and any localhost origin
# ---------------------------------------------------------------------------
_extra = os.getenv("FRONTEND_URL", "")
_render_url = os.getenv("RENDER_EXTERNAL_URL", "")
ALLOWED_ORIGINS = [
"http://localhost:5173",
"http://localhost:5174",
"http://127.0.0.1:5173",
"http://127.0.0.1:5174",
# Render production domain
"https://stock-predictor-api.onrender.com",
]
if _extra:
ALLOWED_ORIGINS.append(_extra)
if _render_url and _render_url not in ALLOWED_ORIGINS:
ALLOWED_ORIGINS.append(_render_url)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Routers (must be registered BEFORE static file catch-all)
# ---------------------------------------------------------------------------
app.include_router(stock_router)
app.include_router(line_router)
app.include_router(metal_router)
app.include_router(oil_router)
app.include_router(paper_trading_router)
# ---------------------------------------------------------------------------
# System endpoints
# ---------------------------------------------------------------------------
@app.get("/health", tags=["system"])
async def health():
return {"status": "ok", "version": APP_VERSION, "timestamp": datetime.now(timezone.utc).isoformat()}
REPORTS_DIR = Path(__file__).parent / "docs" / "validation_runs"
@app.get("/reports/validation/{file_path:path}", tags=["system"])
async def validation_report(file_path: str):
"""Serve generated validation reports through the app server."""
target = (REPORTS_DIR / file_path).resolve()
try:
target.relative_to(REPORTS_DIR.resolve())
except ValueError:
raise HTTPException(status_code=404, detail="Report not found")
if not target.is_file():
raise HTTPException(status_code=404, detail="Report not found")
return FileResponse(str(target), headers={"Cache-Control": "no-cache"})
# ---------------------------------------------------------------------------
# API 404 handler — ensures /api/* typos return JSON 404 instead of being
# swallowed by the SPA catch-all route below.
# ---------------------------------------------------------------------------
@app.get("/api/{rest_of_path:path}", include_in_schema=False)
async def api_not_found(rest_of_path: str):
"""Return a proper 404 for any unmatched /api/* route."""
return JSONResponse(
status_code=404,
content={"detail": f"API endpoint not found: /api/{rest_of_path}"},
)
# ---------------------------------------------------------------------------
# Serve React frontend static files (only when dist/ folder exists)
# This is used in production (Docker / HuggingFace Spaces)
# ---------------------------------------------------------------------------
STATIC_DIR = Path(__file__).parent / "static"
if STATIC_DIR.exists():
# Serve JS/CSS/image assets
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
"""Catch-all: serve React index.html for all non-API routes (SPA routing)."""
return FileResponse(
str(STATIC_DIR / "index.html"),
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
)
else:
@app.get("/", tags=["system"])
async def root():
return {
"name": "Taiwan Stock Predictor API",
"version": APP_VERSION,
"docs": "/docs",
"endpoints": {
"history": "/api/stock/{stock_no}/history?months=6",
"predict": "/api/stock/{stock_no}/predict",
"info": "/api/stock/{stock_no}/info",
"search": "/api/stock/search?q={query}",
},
}