You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

148 lines
4.9 KiB
Python

"""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 backtest, docs, experiments, leaderboard, paper_trading, runs, sqs
_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)
# Auto-restart 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)
yield
# Graceful shutdown: cancel task but keep state so next restart can resume
if auto_scheduler.running:
auto_scheduler.shutdown()
await _asyncio.sleep(1)
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(paper_trading.router, prefix=api_prefix)
app.include_router(sqs.router, prefix=api_prefix)
app.include_router(docs.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()