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.
216 lines
8.4 KiB
Python
216 lines
8.4 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 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()
|