first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# API package
|
||||
@@ -0,0 +1,87 @@
|
||||
"""简单 Token 鉴权(对齐策略仓:密码换 HMAC token)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from packages.config import get_settings
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
COOKIE_NAME = "mi_token"
|
||||
|
||||
|
||||
def auth_disabled() -> bool:
|
||||
s = get_settings()
|
||||
return (s.auth_secret or "").strip().lower() in ("", "disabled", "off", "none")
|
||||
|
||||
|
||||
def _token_for_password(password: str, secret: str) -> str:
|
||||
return hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
password.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def expected_token() -> str:
|
||||
s = get_settings()
|
||||
return _token_for_password(s.admin_password, s.auth_secret)
|
||||
|
||||
|
||||
def issue_token(password: str) -> str | None:
|
||||
s = get_settings()
|
||||
if not secrets.compare_digest(password, s.admin_password):
|
||||
return None
|
||||
return _token_for_password(password, s.auth_secret)
|
||||
|
||||
|
||||
def _extract_token(
|
||||
request: Request,
|
||||
authorization: str | None,
|
||||
x_mi_token: str | None,
|
||||
creds: HTTPAuthorizationCredentials | None,
|
||||
) -> str | None:
|
||||
if creds and creds.credentials:
|
||||
return creds.credentials.strip()
|
||||
if authorization and authorization.lower().startswith("bearer "):
|
||||
return authorization[7:].strip()
|
||||
if x_mi_token:
|
||||
return x_mi_token.strip()
|
||||
# 查询参数兜底(方便内网脚本;生产建议只用 Header)
|
||||
q = request.query_params.get("token")
|
||||
if q:
|
||||
return q.strip()
|
||||
cookie = request.cookies.get(COOKIE_NAME)
|
||||
if cookie:
|
||||
return cookie.strip()
|
||||
return None
|
||||
|
||||
|
||||
def require_auth(
|
||||
request: Request,
|
||||
authorization: Annotated[str | None, Header()] = None,
|
||||
x_mi_token: Annotated[str | None, Header(alias="X-MI-Token")] = None,
|
||||
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
|
||||
) -> None:
|
||||
"""
|
||||
AUTH_SECRET=disabled 时跳过。
|
||||
否则需要 Bearer / X-MI-Token / Cookie / ?token=。
|
||||
"""
|
||||
if auth_disabled():
|
||||
return
|
||||
token = _extract_token(request, authorization, x_mi_token, creds)
|
||||
if not token or not secrets.compare_digest(token, expected_token()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="unauthorized",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
AuthDep = Depends(require_auth)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""FastAPI 入口:健康检查 + 只读 API + 静态看板。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from apps.api.routes import auth, health, meta, notify, samples, stats
|
||||
|
||||
app = FastAPI(
|
||||
title="比特骆驼行情采集分析",
|
||||
description="market_intel — 只读行情采集与统计",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
app.include_router(meta.router, prefix="/api")
|
||||
app.include_router(samples.router, prefix="/api")
|
||||
app.include_router(stats.router, prefix="/api")
|
||||
app.include_router(notify.router, prefix="/api")
|
||||
|
||||
_WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
|
||||
|
||||
_FALLBACK_HTML = """<!doctype html><html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/><title>比特骆驼行情采集分析</title>
|
||||
<style>
|
||||
body{font-family:system-ui;background:#0f1419;color:#e7ecf1;padding:2rem}
|
||||
a{color:#5b9fd4}
|
||||
</style></head><body>
|
||||
<h1>比特骆驼行情采集分析</h1>
|
||||
<p>API 已就绪。<a href="/health">/health</a> · <a href="/api/meta/latest">/api/meta/latest</a></p>
|
||||
<p>构建前端:<code>cd web && npm i && npm run build</code></p>
|
||||
</body></html>"""
|
||||
|
||||
|
||||
def _index_response() -> Response:
|
||||
index_html = _WEB_DIST / "index.html"
|
||||
if index_html.is_file():
|
||||
return FileResponse(index_html)
|
||||
return HTMLResponse(_FALLBACK_HTML)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> Response:
|
||||
return _index_response()
|
||||
|
||||
|
||||
@app.get("/ops-map")
|
||||
def ops_map_page() -> Response:
|
||||
"""SPA / 静态看板入口(hash 或 React Router)。"""
|
||||
return _index_response()
|
||||
|
||||
|
||||
if _WEB_DIST.is_dir():
|
||||
assets = _WEB_DIST / "assets"
|
||||
if assets.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=str(assets)), name="assets")
|
||||
@@ -0,0 +1 @@
|
||||
# routes package
|
||||
@@ -0,0 +1,48 @@
|
||||
"""登录 / 鉴权状态。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from apps.api.auth import COOKIE_NAME, auth_disabled, issue_token
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=256)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def auth_status() -> dict:
|
||||
return {
|
||||
"auth_required": not auth_disabled(),
|
||||
"product": "比特骆驼行情采集分析",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(body: LoginBody, response: Response) -> dict:
|
||||
if auth_disabled():
|
||||
return {"ok": True, "auth_required": False, "token": None}
|
||||
token = issue_token(body.password)
|
||||
if not token:
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid password")
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=7 * 24 * 3600,
|
||||
path="/",
|
||||
)
|
||||
return {"ok": True, "auth_required": True, "token": token}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(response: Response) -> dict:
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from packages.config import get_settings
|
||||
from packages.db import Repository
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict:
|
||||
s = get_settings()
|
||||
repo = Repository(s.db_path)
|
||||
try:
|
||||
hb = repo.get_heartbeat()
|
||||
last_ok = hb.get("last_ok_ts_ms")
|
||||
lag_ms = None
|
||||
if last_ok:
|
||||
import time
|
||||
|
||||
lag_ms = max(0, int(time.time() * 1000) - int(last_ok))
|
||||
return {
|
||||
"ok": True,
|
||||
"service": "market_intel",
|
||||
"product": "比特骆驼行情采集分析",
|
||||
"collector_lag_ms": lag_ms,
|
||||
"consecutive_failures": hb.get("consecutive_failures", 0),
|
||||
"option_quotes": repo.count_option_quotes(),
|
||||
"index_ticks": repo.count_index_ticks(),
|
||||
}
|
||||
finally:
|
||||
repo.close()
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from apps.api.auth import require_auth
|
||||
from packages.config import get_settings
|
||||
from packages.db import Repository
|
||||
from packages.domain import LEVERAGE_FORMULA_VERSION
|
||||
|
||||
router = APIRouter(tags=["meta"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
@router.get("/meta/latest")
|
||||
def latest() -> dict:
|
||||
s = get_settings()
|
||||
repo = Repository(s.db_path)
|
||||
try:
|
||||
hb = repo.get_heartbeat()
|
||||
by_side = repo.latest_quotes_by_side()
|
||||
return {
|
||||
"formula_version": LEVERAGE_FORMULA_VERSION,
|
||||
"leverage_def": "index_px / ask",
|
||||
"timezone": s.tz,
|
||||
"underlying": s.underlying,
|
||||
"min_option_leverage": s.min_option_leverage,
|
||||
"heartbeat": {
|
||||
"last_ok_ts_ms": hb.get("last_ok_ts_ms"),
|
||||
"last_error": hb.get("last_error"),
|
||||
"consecutive_failures": hb.get("consecutive_failures"),
|
||||
"meta": hb.get("meta"),
|
||||
},
|
||||
"call": by_side.get("C"),
|
||||
"put": by_side.get("P"),
|
||||
}
|
||||
finally:
|
||||
repo.close()
|
||||
@@ -0,0 +1,34 @@
|
||||
"""通知相关 API(需鉴权)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from apps.api.auth import require_auth
|
||||
from packages.notify import wecom
|
||||
|
||||
router = APIRouter(prefix="/notify", tags=["notify"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
@router.get("/wecom/status")
|
||||
def wecom_status() -> dict:
|
||||
url = wecom.wecom_webhook_url()
|
||||
masked = None
|
||||
if url:
|
||||
if len(url) > 24:
|
||||
masked = url[:18] + "…" + url[-6:]
|
||||
else:
|
||||
masked = "***"
|
||||
return {
|
||||
"enabled": wecom.wecom_enabled(),
|
||||
"webhook_configured": bool(url),
|
||||
"webhook_url_masked": masked,
|
||||
"machine_name": wecom.wecom_machine_name(),
|
||||
"alert_fail_threshold": wecom.alert_fail_threshold(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/wecom/test")
|
||||
def wecom_test() -> dict:
|
||||
ok, msg = wecom.notify_test()
|
||||
return {"ok": ok, "message": msg}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""原始样本调试接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from apps.api.auth import require_auth
|
||||
from packages.config import get_settings
|
||||
from packages.db import Repository
|
||||
|
||||
router = APIRouter(tags=["samples"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
@router.get("/samples/recent")
|
||||
def recent_samples(limit: int = Query(default=20, ge=1, le=200)) -> dict:
|
||||
s = get_settings()
|
||||
repo = Repository(s.db_path)
|
||||
try:
|
||||
rows = repo.conn.execute(
|
||||
"""
|
||||
SELECT * FROM option_quotes
|
||||
ORDER BY ts_ms DESC, id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return {"items": [dict(r) for r in rows]}
|
||||
finally:
|
||||
repo.close()
|
||||
@@ -0,0 +1,113 @@
|
||||
"""日/周/月统计 API。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from apps.api.auth import require_auth
|
||||
from apps.worker.settle import ensure_settlements_for_ymds
|
||||
from packages.config import get_settings
|
||||
from packages.db import Repository
|
||||
from packages.domain.aggregate import leverage_stats_payload, move_points_stats_payload
|
||||
from packages.domain.range import resolve_range
|
||||
|
||||
router = APIRouter(prefix="/stats", tags=["stats"], dependencies=[Depends(require_auth)])
|
||||
|
||||
|
||||
def _range_info(range_name: str, date: str | None) -> dict:
|
||||
s = get_settings()
|
||||
try:
|
||||
return resolve_range(
|
||||
range_name,
|
||||
date,
|
||||
month_mode=s.month_range_mode,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
|
||||
@router.get("/leverage")
|
||||
def leverage_stats(
|
||||
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
||||
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD(上海)"),
|
||||
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||
bucket_minutes: int = Query(default=60, ge=15, le=120),
|
||||
) -> dict:
|
||||
s = get_settings()
|
||||
info = _range_info(range, date)
|
||||
repo = Repository(s.db_path)
|
||||
try:
|
||||
rows = repo.fetch_option_quotes(
|
||||
start_ms=info["start_ms"],
|
||||
end_ms=info["end_ms"],
|
||||
side=side,
|
||||
underlying=s.underlying,
|
||||
)
|
||||
return leverage_stats_payload(
|
||||
rows,
|
||||
range_info=info,
|
||||
bucket_minutes=bucket_minutes,
|
||||
min_leverage=float(s.min_option_leverage),
|
||||
side=side,
|
||||
)
|
||||
finally:
|
||||
repo.close()
|
||||
|
||||
|
||||
@router.get("/move_points")
|
||||
def move_points_stats(
|
||||
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
||||
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD"),
|
||||
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||
bucket_minutes: int = Query(default=60, ge=15, le=120),
|
||||
) -> dict:
|
||||
s = get_settings()
|
||||
info = _range_info(range, date)
|
||||
repo = Repository(s.db_path)
|
||||
try:
|
||||
rows = repo.fetch_option_quotes(
|
||||
start_ms=info["start_ms"],
|
||||
end_ms=info["end_ms"],
|
||||
side=side,
|
||||
underlying=s.underlying,
|
||||
)
|
||||
ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")})
|
||||
# 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过)
|
||||
try:
|
||||
ensure_settlements_for_ymds(
|
||||
repo,
|
||||
ymds,
|
||||
underlying=s.underlying,
|
||||
index_inst_id=s.index_inst_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 回填失败不阻断统计
|
||||
pass
|
||||
settlements = repo.list_settlements(ymds)
|
||||
return move_points_stats_payload(
|
||||
rows,
|
||||
settlements,
|
||||
range_info=info,
|
||||
bucket_minutes=bucket_minutes,
|
||||
side=side,
|
||||
)
|
||||
finally:
|
||||
repo.close()
|
||||
|
||||
|
||||
@router.get("/ops-map")
|
||||
def ops_map(
|
||||
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
||||
date: str | None = Query(default=None),
|
||||
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||
bucket_minutes: int = Query(default=60, ge=15, le=120),
|
||||
) -> dict:
|
||||
lev = leverage_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
|
||||
mov = move_points_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
|
||||
return {
|
||||
"range": range,
|
||||
"date": lev.get("date"),
|
||||
"side": side,
|
||||
"bucket_minutes": bucket_minutes,
|
||||
"leverage": lev,
|
||||
"move_points": mov,
|
||||
}
|
||||
Reference in New Issue
Block a user