"""FastAPI web application for fithia2 GUI.""" from __future__ import annotations import os import signal import sys from contextlib import asynccontextmanager from pathlib import Path # ── Python 3.13 + uvicorn SIGINT workaround ─────────────────────────────────── # In Python 3.13 asyncio's Runner._on_sigint cancels the running task when it # sees a re-raised SIGINT from uvicorn's capture_signals(), causing a spurious # CancelledError traceback during lifespan shutdown. Suppressing the re-raise # is safe because uvicorn has already set should_exit=True before calling it. if sys.version_info >= (3, 13): _orig_raise = signal.raise_signal def _no_sigint_reraise(sig: int) -> None: if sig != signal.SIGINT: _orig_raise(sig) signal.raise_signal = _no_sigint_reraise # type: ignore[assignment] # ── Suppress uvicorn's "Interrupted by SIGINT" error log on clean shutdown ──── import logging as _logging class _SuppressSigintLog(_logging.Filter): def filter(self, record: _logging.LogRecord) -> bool: return "Interrupted by SIGINT" not in record.getMessage() _logging.getLogger("uvicorn.error").addFilter(_SuppressSigintLog()) try: from dotenv import load_dotenv load_dotenv() except ImportError: pass from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from apps.web.routers import advisor, backtest, docs, experiments, intraday, leaderboard, orb_trading, paper_trading, runs, sqs from apps.web.routers.events import router as events_router, health_router _STATIC_DIR = Path(__file__).parent / "static" @asynccontextmanager async def lifespan(app: FastAPI): # type: ignore[type-arg] import asyncio as _asyncio import logging as _logging # Change working directory to project root so relative paths (configs/, runs/, journal/) work project_root = Path(__file__).parent.parent.parent os.chdir(project_root) # ── Centralized event store: schema first, then sink + stdlib bridge ───── from apps.web.services.events_store import EventsStore, EventsLoggingHandler _events_store = EventsStore.get() _events_store.ensure_schema() # must precede configure_logging so table exists before first write from libs.common.logging import configure_logging configure_logging(enable_events_sink=True) _events_handler = EventsLoggingHandler() _events_handler.setLevel(_logging.INFO) _logging.root.addHandler(_events_handler) # Auto-restart PEAD auto scheduler if it was running before last shutdown from apps.web.paper_trading_service import auto_scheduler, load_saved_state _db_env = os.environ.get("PAPER_TRADER_DB", "paper_trading.db") _db_path = str(project_root / _db_env) if not Path(_db_env).is_absolute() else _db_env saved = load_saved_state(_db_path) if saved and saved.get("running"): try: auto_scheduler.start( sessions=saved.get("sessions", []), dry_run=saved.get("dry_run", False), db_path=saved.get("db_path") or _db_path, ) _logging.getLogger(__name__).info( "Auto scheduler resumed from saved state (sessions=%s, dry_run=%s)", saved.get("sessions"), saved.get("dry_run"), ) except Exception as exc: _logging.getLogger(__name__).warning("Auto scheduler auto-restart failed: %s", exc) # Set ORB db path so status endpoints work before any start() call from apps.web.orb_trading_service import orb_auto_scheduler, load_orb_saved_state _orb_db_env = os.environ.get("ORB_TRADER_DB", "data/paper/orb.db") _orb_db_path = str(project_root / _orb_db_env) if not Path(_orb_db_env).is_absolute() else _orb_db_env orb_auto_scheduler._db_path = _orb_db_path # always set so get_status() uses correct dir # Auto-restart ORB daemon if it was running before last shutdown orb_saved = load_orb_saved_state(_orb_db_path) if orb_saved and orb_saved.get("running"): try: _orb_sessions = orb_saved.get("sessions", []) # Validate saved session names against the DB; fall back to [] (= all active) # if ALL specified sessions have been deleted since last run. if _orb_sessions: try: from apps.orb_trader.state import ORBStateManager as _ORBState _orb_state = _ORBState(_orb_db_path) _existing = [ s for s in _orb_sessions if (session := _orb_state.get_session(s)) is not None and session.status == "active" ] if not _existing: _logging.getLogger(__name__).warning( "ORB auto-restart: saved sessions %s not active in DB; " "will use all active sessions instead", _orb_sessions ) _orb_sessions = [] else: _orb_sessions = _existing except Exception: pass orb_auto_scheduler.start( sessions=_orb_sessions, db_path=orb_saved.get("db_path") or _orb_db_path, dry_run=orb_saved.get("dry_run", False), ) if orb_auto_scheduler.running: _logging.getLogger(__name__).info( "ORB daemon resumed (sessions=%s)", _orb_sessions ) else: _logging.getLogger(__name__).warning( "ORB daemon start requested but process not detected yet" ) except Exception as exc: _logging.getLogger(__name__).warning("ORB daemon auto-restart failed: %s", exc) yield # Graceful shutdown — PEAD scheduler stops with the server if auto_scheduler.running: auto_scheduler.shutdown() await _asyncio.sleep(0.5) # ORB daemon runs as a separate process and survives server restarts; # shutdown() is intentionally a no-op for ORBDaemonController. orb_auto_scheduler.shutdown() def create_app() -> FastAPI: app = FastAPI( title="Fithia2 Strategy Dashboard", description="Web GUI for ACE-F trading system — strategy management, backtesting, paper trading", version="1.0.0", lifespan=lifespan, ) app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://localhost:8000"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # API routers api_prefix = "/api" app.include_router(experiments.router, prefix=api_prefix) app.include_router(leaderboard.router, prefix=api_prefix) app.include_router(runs.router, prefix=api_prefix) app.include_router(backtest.router, prefix=api_prefix) app.include_router(intraday.router, prefix=api_prefix) app.include_router(orb_trading.router, prefix=api_prefix) app.include_router(paper_trading.router, prefix=api_prefix) app.include_router(sqs.router, prefix=api_prefix) app.include_router(docs.router, prefix=api_prefix) app.include_router(advisor.router, prefix=api_prefix) app.include_router(events_router, prefix=api_prefix) app.include_router(health_router, prefix=api_prefix) # Serve built frontend (production) if _STATIC_DIR.exists(): app.mount("/assets", StaticFiles(directory=_STATIC_DIR / "assets"), name="assets") @app.get("/{full_path:path}", include_in_schema=False) async def serve_spa(full_path: str) -> FileResponse: # Serve index.html for all non-API routes (client-side routing) index = _STATIC_DIR / "index.html" if index.exists(): return FileResponse(index) return FileResponse(_STATIC_DIR / "index.html") return app app = create_app() def run_server() -> None: """Start the web server. Called by `fithia2 web`.""" import uvicorn from apps.web.config import settings uvicorn.run( "apps.web.main:app", host=settings.host, port=settings.port, reload=settings.reload, log_level="info", loop="asyncio", ) if __name__ == "__main__": run_server()