159 lines
6.0 KiB
Python
159 lines
6.0 KiB
Python
"""FastAPI application entry point with lifespan management."""
|
|
# ruff: noqa: E402
|
|
# ---------------------------------------------------------------------------
|
|
# SSL + proxy injection — MUST happen before any HTTP client imports
|
|
# ---------------------------------------------------------------------------
|
|
import os as _os
|
|
import ssl as _ssl
|
|
from pathlib import Path as _Path
|
|
|
|
_COMBINED_CERT = _Path(__file__).resolve().parent.parent / "combined-ca-bundle.pem"
|
|
|
|
if _COMBINED_CERT.exists():
|
|
_cert_path = str(_COMBINED_CERT)
|
|
# Env vars for libraries that respect them (requests, urllib3)
|
|
_os.environ["SSL_CERT_FILE"] = _cert_path
|
|
_os.environ["REQUESTS_CA_BUNDLE"] = _cert_path
|
|
_os.environ["CURL_CA_BUNDLE"] = _cert_path
|
|
|
|
# Monkey-patch ssl.create_default_context so that ALL libraries
|
|
# (aiohttp, httpx, google-genai, alpaca-py, etc.) automatically
|
|
# use our combined CA bundle that includes the corporate root cert.
|
|
_original_create_default_context = _ssl.create_default_context
|
|
|
|
def _patched_create_default_context(
|
|
purpose=_ssl.Purpose.SERVER_AUTH, *, cafile=None, capath=None, cadata=None
|
|
):
|
|
ctx = _original_create_default_context(
|
|
purpose, cafile=cafile, capath=capath, cadata=cadata
|
|
)
|
|
# Always load our combined bundle on top of whatever was loaded
|
|
ctx.load_verify_locations(cafile=_cert_path)
|
|
return ctx
|
|
|
|
_ssl.create_default_context = _patched_create_default_context
|
|
|
|
# Also patch aiohttp's cached SSL context objects directly, since
|
|
# aiohttp creates them at import time and may have already cached
|
|
# a context without our corporate CA bundle.
|
|
try:
|
|
import aiohttp.connector as _aio_conn
|
|
if hasattr(_aio_conn, '_SSL_CONTEXT_VERIFIED') and _aio_conn._SSL_CONTEXT_VERIFIED is not None:
|
|
_aio_conn._SSL_CONTEXT_VERIFIED.load_verify_locations(cafile=_cert_path)
|
|
if hasattr(_aio_conn, '_SSL_CONTEXT_UNVERIFIED') and _aio_conn._SSL_CONTEXT_UNVERIFIED is not None:
|
|
_aio_conn._SSL_CONTEXT_UNVERIFIED.load_verify_locations(cafile=_cert_path)
|
|
except ImportError:
|
|
pass
|
|
|
|
# Corporate proxy — needed when Kiro spawns the process (no .zshrc sourced)
|
|
_PROXY = "http://aproxy.corproot.net:8080"
|
|
_NO_PROXY = "corproot.net,sharedtcs.net,127.0.0.1,localhost,bix.swisscom.com,swisscom.com"
|
|
_os.environ.setdefault("HTTP_PROXY", _PROXY)
|
|
_os.environ.setdefault("HTTPS_PROXY", _PROXY)
|
|
_os.environ.setdefault("NO_PROXY", _NO_PROXY)
|
|
|
|
import logging
|
|
import sys
|
|
from contextlib import asynccontextmanager
|
|
from collections.abc import AsyncGenerator
|
|
|
|
from fastapi import FastAPI
|
|
from passlib.hash import bcrypt
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import settings
|
|
from app.database import async_session_factory, engine
|
|
from app.middleware import register_exception_handlers
|
|
from app.models.user import User
|
|
from app.scheduler import configure_scheduler, scheduler
|
|
from app.routers.admin import router as admin_router
|
|
from app.routers.auth import router as auth_router
|
|
from app.routers.health import router as health_router
|
|
from app.routers.ingestion import router as ingestion_router
|
|
from app.routers.ohlcv import router as ohlcv_router
|
|
from app.routers.indicators import router as indicators_router
|
|
from app.routers.fundamentals import router as fundamentals_router
|
|
from app.routers.scores import router as scores_router
|
|
from app.routers.trades import router as trades_router
|
|
from app.routers.watchlist import router as watchlist_router
|
|
from app.routers.sentiment import router as sentiment_router
|
|
from app.routers.sr_levels import router as sr_levels_router
|
|
from app.routers.tickers import router as tickers_router
|
|
|
|
|
|
def _configure_logging() -> None:
|
|
"""Set up structured JSON-style logging."""
|
|
handler = logging.StreamHandler(sys.stdout)
|
|
handler.setFormatter(
|
|
logging.Formatter(
|
|
'{"time":"%(asctime)s","level":"%(levelname)s",'
|
|
'"logger":"%(name)s","message":"%(message)s"}'
|
|
)
|
|
)
|
|
root = logging.getLogger()
|
|
root.handlers.clear()
|
|
root.addHandler(handler)
|
|
root.setLevel(settings.log_level.upper())
|
|
|
|
|
|
async def _create_default_admin(session: AsyncSession) -> None:
|
|
"""Create the default admin account if no admin user exists."""
|
|
result = await session.execute(
|
|
select(User).where(User.role == "admin")
|
|
)
|
|
if result.scalar_one_or_none() is None:
|
|
admin = User(
|
|
username="admin",
|
|
password_hash=bcrypt.hash("admin"),
|
|
role="admin",
|
|
has_access=True,
|
|
)
|
|
session.add(admin)
|
|
await session.commit()
|
|
logging.getLogger(__name__).info("Default admin account created")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
|
|
"""Manage startup and shutdown lifecycle."""
|
|
logger = logging.getLogger(__name__)
|
|
_configure_logging()
|
|
logger.info("Starting Stock Data Backend")
|
|
|
|
async with async_session_factory() as session:
|
|
await _create_default_admin(session)
|
|
|
|
configure_scheduler()
|
|
scheduler.start()
|
|
logger.info("Scheduler started")
|
|
|
|
yield
|
|
|
|
scheduler.shutdown(wait=False)
|
|
logger.info("Scheduler stopped")
|
|
await engine.dispose()
|
|
logger.info("Shutting down")
|
|
|
|
|
|
app = FastAPI(
|
|
title="Stock Data Backend",
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
register_exception_handlers(app)
|
|
app.include_router(health_router, prefix="/api/v1")
|
|
app.include_router(auth_router, prefix="/api/v1")
|
|
app.include_router(admin_router, prefix="/api/v1")
|
|
app.include_router(tickers_router, prefix="/api/v1")
|
|
app.include_router(ohlcv_router, prefix="/api/v1")
|
|
app.include_router(ingestion_router, prefix="/api/v1")
|
|
app.include_router(indicators_router, prefix="/api/v1")
|
|
app.include_router(sr_levels_router, prefix="/api/v1")
|
|
app.include_router(sentiment_router, prefix="/api/v1")
|
|
app.include_router(fundamentals_router, prefix="/api/v1")
|
|
app.include_router(scores_router, prefix="/api/v1")
|
|
app.include_router(trades_router, prefix="/api/v1")
|
|
app.include_router(watchlist_router, prefix="/api/v1")
|