将项目文件整理到 refer 目录
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
"""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"}
|
||||
Reference in New Issue
Block a user