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 @@
# 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,
}