"""FastAPI 入口。""" from __future__ import annotations import logging from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from app import __version__ from app import auth as auth_module from app.api import analysis, auth, backtest, data, ext_data, financials, indices, intraday, kline, monitor_rules, alerts, overview, pipeline, screener, settings as settings_api, signals, strategy, watchlist from app.api.routes import router as core_router from app.config import settings from app.jobs import daily_pipeline from app.services.quote_service import QuoteService from app.tickflow import client as tf_client from app.tickflow.policy import detect_capabilities from app.tickflow.repository import DataStore, KlineRepository logging.basicConfig( level=settings.log_level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", ) logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): logger.info( "Stock Panel v%s starting (mode=%s)", __version__, tf_client.current_mode(), ) # 数据层 store = DataStore() repo = KlineRepository(store) app.state.datastore = store app.state.repo = repo # Polars 缓存预热 repo.refresh_cache() # 能力探测 capset = detect_capabilities() app.state.capabilities = capset logger.info("ready; %d capabilities active", len(capset.all())) # 全局行情服务 qs = QuoteService() app.state.quote_service = qs qs.set_repo(repo) qs.boot_check() # QuoteService 需要访问 strategy_monitor 等单例 # 先创建 strategy_monitor,再注入 app.state from app.strategy.monitor import StrategyMonitorService strategy_monitor = StrategyMonitorService() app.state.strategy_monitor = strategy_monitor qs.set_app_state(app.state) # 五档盘口 sealed 服务(真假涨停/跌停, 独立旁路线) from app.services.depth_service import DepthService depth_service = DepthService() depth_service.set_repo(repo) depth_service.set_app_state(app.state) app.state.depth_service = depth_service # 启动调度器(若 enriched 数据为空,首次启动可手动 POST /api/pipeline/run) try: daily_pipeline.set_app_state(app.state) # 供 depth_finalize job 访问 depth_service scheduler = daily_pipeline.start_scheduler(repo, capset) app.state.scheduler = scheduler except Exception as e: # noqa: BLE001 logger.warning("scheduler not started: %s", e) app.state.scheduler = None # depth sealed: 启动补跑(当天文件不存在) + 盘中轮询(有能力时) try: depth_service.boot_check() depth_service.start_polling() except Exception as e: # noqa: BLE001 logger.warning("depth_service init failed: %s", e) # 扩展数据定时拉取 from app.services.ext_pull import pull_scheduler pull_scheduler.start(store.data_dir) pull_scheduler.refresh(store.data_dir) app.state.pull_scheduler = pull_scheduler # 财务数据独立调度 (需 Expert 套餐) from app.services.financial_sync import financial_scheduler financial_scheduler.start(store.data_dir, capset) app.state.financial_scheduler = financial_scheduler # 策略引擎 from app.strategy.engine import StrategyEngine from app.strategy.monitor import StrategyMonitorService from app.services.screener import ScreenerService _screener_svc = ScreenerService(repo) strategy_dirs = [ Path(__file__).resolve().parent / "strategy" / "builtin", store.data_dir / "strategies" / "custom", store.data_dir / "strategies" / "ai", ] strategy_engine = StrategyEngine( enriched_loader=_screener_svc._load_enriched_for_date, enriched_history_loader=_screener_svc._load_enriched_history, strategy_dirs=strategy_dirs, ) app.state.strategy_engine = strategy_engine logger.info("strategy engine loaded: %d strategies", len(strategy_engine.list_strategies())) # 通用监控规则引擎: 启动时 reload 规则到内存态 (修复重启后告警失效) from app.strategy.monitor import MonitorRuleEngine from app.strategy import monitor_rules as mr_store from app.services import preferences monitor_engine = MonitorRuleEngine() monitor_engine.set_strategy_engine(strategy_engine) monitor_engine.set_data_dir(store.data_dir) # 自动迁移: 把旧 strategy_monitor_ids 同步为 type=strategy 规则 (统一到监控页) try: if preferences.get_strategy_monitor_enabled(): ids = preferences.get_strategy_monitor_ids() if ids: names = {s.id: s.name for s in strategy_engine.list_strategies()} mr_store.migrate_strategy_monitors(store.data_dir, ids, names) logger.info("strategy monitor migrated: %d strategies", len(ids)) except Exception as e: # noqa: BLE001 logger.warning("strategy monitor migration failed: %s", e) try: rules = mr_store.load_all(store.data_dir) monitor_engine.set_rules(rules) logger.info("monitor engine loaded: %d rules", monitor_engine.rule_count) except Exception as e: # noqa: BLE001 logger.warning("monitor engine load failed: %s", e) app.state.monitor_engine = monitor_engine yield if app.state.scheduler: app.state.scheduler.shutdown(wait=False) ps = getattr(app.state, "pull_scheduler", None) if ps: ps.stop() fsc = getattr(app.state, "financial_scheduler", None) if fsc: fsc.stop() qs = getattr(app.state, "quote_service", None) if qs: qs.stop() dsvc = getattr(app.state, "depth_service", None) if dsvc: dsvc.stop_polling() logger.info("shutdown") app = FastAPI( title="Stock Panel", version=__version__, description="A 股选股 + 监控 + 回测面板", lifespan=lifespan, ) # CORS: 允许局域网访问 (自托管场景, 放开所有来源) # 注: allow_credentials=True 与 allow_origins=['*'] 不能共存 (浏览器规范), # 本项目认证走 header (API Key), 不依赖 cookie, 故关闭 credentials 换取通配来源。 app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) # 访问门控中间件:仅对 /api/* 路径校验,静态资源和前端路由放行, # 由前端 AccessGuard 控制 UI 展示。 @app.middleware("http") async def access_uuid_middleware(request: Request, call_next): if auth_module.access_control_enabled(): path = request.url.path # 白名单直接放行 if not auth_module.is_public_path(path): token = auth_module.get_access_token_from_request(request) role = auth_module.validate_access_token(token) # 管理员接口需 admin 角色 if auth_module.is_admin_path(path): if role != auth_module.AuthRole.ADMIN: return JSONResponse( status_code=403 if role else 401, content={"detail": "需要管理员权限"}, ) # 其它 API 调用需任意有效角色 elif path.startswith("/api/"): if role is None: return JSONResponse( status_code=401, content={"detail": "访问令牌无效或已过期,请先验证"}, ) return await call_next(request) # 路由 app.include_router(core_router) app.include_router(auth.router) app.include_router(auth.admin_router) app.include_router(kline.router) app.include_router(watchlist.router) app.include_router(screener.router) app.include_router(backtest.router) app.include_router(intraday.router) app.include_router(indices.router) app.include_router(overview.router) app.include_router(analysis.router) app.include_router(pipeline.router) app.include_router(data.router) app.include_router(ext_data.router) app.include_router(financials.router) app.include_router(settings_api.router) app.include_router(strategy.router) app.include_router(signals.router) app.include_router(monitor_rules.router) app.include_router(alerts.router) # 能力门控异常 → 403(而非默认 500) # 业务代码用 capset.require(Cap.X) 断言能力,缺失时抛 CapabilityDenied; # 若不注册 handler 会冒泡成 500 Internal Server Error,对前端不友好且语义错误。 from app.tickflow.capabilities import CapabilityDenied @app.exception_handler(CapabilityDenied) async def capability_denied_handler(request: Request, exc: CapabilityDenied) -> JSONResponse: return JSONResponse( status_code=403, content={"detail": str(exc), "suggestion": exc.suggestion}, ) # 生产期静态文件(前端 dist) _static = Path(settings.static_dir) if _static.exists(): if (_static / "assets").exists(): app.mount("/assets", StaticFiles(directory=_static / "assets"), name="assets") @app.get("/{full_path:path}", include_in_schema=False) def spa_fallback(full_path: str): # noqa: ARG001 """所有未匹配路径回退到 index.html — React Router 接管。 index.html 禁止缓存 (Cache-Control: no-store), 确保浏览器每次拿到 最新版本引用的 JS/CSS 文件名 (assets 带 hash, 可长缓存)。 """ index = _static / "index.html" if index.exists(): return FileResponse( index, headers={"Cache-Control": "no-store, must-revalidate"}, ) return {"error": "frontend not built"}