first commit

This commit is contained in:
dekun
2026-08-01 10:33:19 +08:00
commit d9a34d4f20
72 changed files with 5499 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# API package
+87
View File
@@ -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)
+70
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
# routes package
+48
View File
@@ -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}
+33
View File
@@ -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()
+36
View File
@@ -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()
+34
View File
@@ -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}
+29
View File
@@ -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()
+113
View File
@@ -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,
}