Files
signal-platform/app/main.py
T
dennisthiessen 316226096b
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 22s
Fix score refresh, add granular fetch and live job status
Scores never updated ("101d ago"): get_score only recomputes stale/
missing dimensions, but nothing marked them stale on new data, and there
was no scheduled scoring job.
- Fetch endpoint force-recomputes dimensions + composite.
- Scheduled scan (scan_all_tickers) refreshes scores per ticker, so
  scores stay current globally, not just on manual fetch.

Granular fetch: /ingestion/fetch accepts a sources filter; the freshness
bar gets a per-row refresh button (OHLCV/Sentiment/Fundamentals fetch
that provider only — marked paid; S/R/Scores recompute for free). Header
button is now "Fetch All".

Job visibility: GET /jobs/running (any user) + sidebar live indicator
showing running scheduled jobs with progress, polled every 10s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 13:10:15 +02:00

163 lines
6.2 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)
# Only enable this if explicitly requested via environment variable.
if _os.environ.get("USE_CORP_PROXY", "0") == "1":
_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
from app.routers.jobs import router as jobs_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")
app.include_router(jobs_router, prefix="/api/v1")