first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Collector package
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# OKX market collector
|
||||
@@ -0,0 +1,173 @@
|
||||
"""采集入口:OKX 指数 + ATM Call/Put 周期采样落库。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from apps.collector.okx_rest import OkxRestClient
|
||||
from apps.collector.selectors import rows_to_contracts, select_atm_pair
|
||||
from packages.config import get_settings
|
||||
from packages.db import Repository
|
||||
from packages.db.repository import OptionQuoteRow
|
||||
from packages.domain import option_leverage
|
||||
from packages.notify import wecom
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [collector] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("collector")
|
||||
|
||||
_STOP = False
|
||||
|
||||
|
||||
def _handle_signal(signum: int, _frame: Any) -> None:
|
||||
global _STOP
|
||||
log.info("signal %s received, stopping…", signum)
|
||||
_STOP = True
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def sample_once(
|
||||
client: OkxRestClient,
|
||||
repo: Repository,
|
||||
*,
|
||||
contracts_cache: list[dict[str, Any]],
|
||||
settings: Any,
|
||||
) -> dict[str, Any]:
|
||||
ts_ms = _now_ms()
|
||||
index_px = client.fetch_index_ticker(settings.index_inst_id)
|
||||
if index_px is None or index_px <= 0:
|
||||
raise RuntimeError(f"index unavailable: {settings.index_inst_id}")
|
||||
|
||||
repo.insert_index_tick(
|
||||
ts_ms=ts_ms,
|
||||
exchange="okx",
|
||||
underlying=settings.underlying,
|
||||
index_px=float(index_px),
|
||||
)
|
||||
|
||||
pair = select_atm_pair(
|
||||
contracts_cache,
|
||||
index_px=float(index_px),
|
||||
min_hours=float(settings.min_option_hours),
|
||||
)
|
||||
if pair is None:
|
||||
raise RuntimeError("no eligible ATM option pair")
|
||||
|
||||
meta: dict[str, Any] = {
|
||||
"index_px": index_px,
|
||||
"expiry_ymd": pair.expiry_ymd,
|
||||
"strike": pair.strike,
|
||||
"call_inst_id": pair.call_inst_id,
|
||||
"put_inst_id": pair.put_inst_id,
|
||||
}
|
||||
|
||||
for side, inst_id in (("C", pair.call_inst_id), ("P", pair.put_inst_id)):
|
||||
ask, bid, ask_sz, bid_sz, book_ts = client.fetch_books(inst_id)
|
||||
lev = option_leverage(float(index_px), ask)
|
||||
repo.insert_option_quote(
|
||||
OptionQuoteRow(
|
||||
ts_ms=book_ts or ts_ms,
|
||||
exchange="okx",
|
||||
underlying=settings.underlying,
|
||||
inst_id=inst_id,
|
||||
expiry_ymd=pair.expiry_ymd,
|
||||
strike=pair.strike,
|
||||
side=side,
|
||||
index_px=float(index_px),
|
||||
ask=ask,
|
||||
bid=bid,
|
||||
ask_sz=ask_sz,
|
||||
bid_sz=bid_sz,
|
||||
leverage=lev,
|
||||
)
|
||||
)
|
||||
meta[f"{side}_ask"] = ask
|
||||
meta[f"{side}_leverage"] = lev
|
||||
|
||||
return meta
|
||||
|
||||
|
||||
def run() -> int:
|
||||
settings = get_settings()
|
||||
log.info(
|
||||
"start underlying=%s family=%s interval=%ss db=%s",
|
||||
settings.underlying,
|
||||
settings.option_inst_family,
|
||||
settings.sample_interval_sec,
|
||||
settings.db_path,
|
||||
)
|
||||
|
||||
repo = Repository(settings.db_path)
|
||||
client = OkxRestClient(
|
||||
base_url=settings.okx_base_url,
|
||||
proxy=settings.okx_proxy or None,
|
||||
)
|
||||
|
||||
contracts: list[dict[str, Any]] = []
|
||||
last_instruments_at = 0.0
|
||||
|
||||
try:
|
||||
while not _STOP:
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not contracts
|
||||
or now - last_instruments_at >= float(settings.instruments_refresh_sec)
|
||||
):
|
||||
raw = client.fetch_option_instruments(settings.option_inst_family)
|
||||
contracts = rows_to_contracts(raw)
|
||||
last_instruments_at = now
|
||||
log.info("instruments refreshed: %d contracts", len(contracts))
|
||||
|
||||
meta = sample_once(client, repo, contracts_cache=contracts, settings=settings)
|
||||
repo.upsert_heartbeat(ok=True, meta=meta)
|
||||
wecom.notify_collector_recovered()
|
||||
log.info(
|
||||
"sampled index=%.2f expiry=%s strike=%.0f C_lev=%s P_lev=%s",
|
||||
meta["index_px"],
|
||||
meta["expiry_ymd"],
|
||||
meta["strike"],
|
||||
f"{meta.get('C_leverage'):.1f}" if meta.get("C_leverage") else "-",
|
||||
f"{meta.get('P_leverage'):.1f}" if meta.get("P_leverage") else "-",
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — 单次失败记日志并跳过
|
||||
log.exception("sample failed: %s", e)
|
||||
repo.upsert_heartbeat(ok=False, error=str(e))
|
||||
hb = repo.get_heartbeat()
|
||||
wecom.notify_collector_fault(
|
||||
error=str(e),
|
||||
consecutive_failures=int(hb.get("consecutive_failures") or 0),
|
||||
)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
sleep_for = max(1.0, float(settings.sample_interval_sec) - elapsed)
|
||||
# 可中断 sleep
|
||||
end = time.monotonic() + sleep_for
|
||||
while not _STOP and time.monotonic() < end:
|
||||
time.sleep(min(0.5, end - time.monotonic()))
|
||||
finally:
|
||||
client.close()
|
||||
repo.close()
|
||||
log.info("stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
sys.exit(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""OKX REST 只读行情。禁止任何交易类接口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class OkxRestClient:
|
||||
"""仅调用公开行情 / 公共接口。"""
|
||||
|
||||
# 硬黑名单:防止误用交易路径
|
||||
_FORBIDDEN_PREFIXES = (
|
||||
"/api/v5/trade",
|
||||
"/api/v5/account",
|
||||
"/api/v5/asset",
|
||||
"/api/v5/users",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "https://www.okx.com",
|
||||
timeout: float = 15.0,
|
||||
proxy: str | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.proxy = (proxy or "").strip() or None
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=timeout,
|
||||
proxy=self.proxy,
|
||||
headers={"Accept": "application/json", "User-Agent": "market_intel/0.1"},
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> OkxRestClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def _get(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
for bad in self._FORBIDDEN_PREFIXES:
|
||||
if path.startswith(bad):
|
||||
raise RuntimeError(f"forbidden trading path: {path}")
|
||||
r = self._client.get(path, params=params or {})
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if str(body.get("code")) != "0":
|
||||
raise RuntimeError(f"OKX REST error code={body.get('code')} msg={body.get('msg')}")
|
||||
data = body.get("data") or []
|
||||
return [x for x in data if isinstance(x, dict)]
|
||||
|
||||
def _get_raw(self, path: str, params: dict[str, Any] | None = None) -> list[Any]:
|
||||
for bad in self._FORBIDDEN_PREFIXES:
|
||||
if path.startswith(bad):
|
||||
raise RuntimeError(f"forbidden trading path: {path}")
|
||||
r = self._client.get(path, params=params or {})
|
||||
r.raise_for_status()
|
||||
body = r.json()
|
||||
if str(body.get("code")) != "0":
|
||||
raise RuntimeError(f"OKX REST error code={body.get('code')} msg={body.get('msg')}")
|
||||
data = body.get("data") or []
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def fetch_option_instruments(self, inst_family: str) -> list[dict[str, Any]]:
|
||||
rows = self._get(
|
||||
"/api/v5/public/instruments",
|
||||
{"instType": "OPTION", "instFamily": inst_family},
|
||||
)
|
||||
return [r for r in rows if str(r.get("state") or "").lower() == "live"]
|
||||
|
||||
def fetch_index_ticker(self, inst_id: str) -> float | None:
|
||||
rows = self._get("/api/v5/market/index-tickers", {"instId": inst_id})
|
||||
if not rows:
|
||||
return None
|
||||
return safe_float(rows[0].get("idxPx"))
|
||||
|
||||
def fetch_index_at(
|
||||
self, inst_id: str, target_ts_ms: int
|
||||
) -> tuple[float | None, int | None]:
|
||||
"""
|
||||
用 1m 历史指数 K 线取最接近 target 的收盘价。
|
||||
OKX: /api/v5/market/history-index-candles
|
||||
candle: [ts, o, h, l, c, confirm, ...]
|
||||
"""
|
||||
# before = 请求此时间戳之前的数据;取到期前后窗口
|
||||
before = int(target_ts_ms) + 60_000
|
||||
after = int(target_ts_ms) - 10 * 60_000
|
||||
rows = self._get_raw(
|
||||
"/api/v5/market/history-index-candles",
|
||||
{
|
||||
"instId": inst_id,
|
||||
"bar": "1m",
|
||||
"before": str(before),
|
||||
"after": str(after),
|
||||
"limit": "20",
|
||||
},
|
||||
)
|
||||
best_px: float | None = None
|
||||
best_ts: int | None = None
|
||||
best_delta: int | None = None
|
||||
for row in rows:
|
||||
if not isinstance(row, (list, tuple)) or len(row) < 5:
|
||||
continue
|
||||
ts = safe_float(row[0])
|
||||
close = safe_float(row[4])
|
||||
if ts is None or close is None:
|
||||
continue
|
||||
ts_i = int(ts)
|
||||
delta = abs(ts_i - int(target_ts_ms))
|
||||
if best_delta is None or delta < best_delta:
|
||||
best_delta = delta
|
||||
best_px = close
|
||||
best_ts = ts_i
|
||||
if best_delta is not None and best_delta > 5 * 60_000:
|
||||
return None, None
|
||||
return best_px, best_ts
|
||||
|
||||
def fetch_books(
|
||||
self, inst_id: str, sz: int = 5
|
||||
) -> tuple[float | None, float | None, float | None, float | None, int | None]:
|
||||
"""返回 ask, bid, ask_sz, bid_sz, ts_ms。"""
|
||||
rows = self._get(
|
||||
"/api/v5/market/books",
|
||||
{"instId": inst_id, "sz": str(max(1, min(int(sz), 400)))},
|
||||
)
|
||||
if not rows:
|
||||
return None, None, None, None, None
|
||||
row = rows[0]
|
||||
ts = safe_float(row.get("ts"))
|
||||
ts_ms = int(ts) if ts is not None else None
|
||||
asks = row.get("asks") or []
|
||||
bids = row.get("bids") or []
|
||||
ask = ask_sz = bid = bid_sz = None
|
||||
if asks and isinstance(asks[0], (list, tuple)) and len(asks[0]) >= 2:
|
||||
ask = safe_float(asks[0][0])
|
||||
ask_sz = safe_float(asks[0][1])
|
||||
if bids and isinstance(bids[0], (list, tuple)) and len(bids[0]) >= 2:
|
||||
bid = safe_float(bids[0][0])
|
||||
bid_sz = safe_float(bids[0][1])
|
||||
return ask, bid, ask_sz, bid_sz, ts_ms
|
||||
@@ -0,0 +1,5 @@
|
||||
"""WebSocket 占位(P1 用 REST;WS 后期可接)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# 第一期采集走 REST 轮询;此模块预留多路订阅入口。
|
||||
@@ -0,0 +1,157 @@
|
||||
"""ATM / 合资格到期选择。规则:最接近指数的行权价;最近剩余时长 ≥ min_hours 的到期。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from packages.domain.expiry import expiry_ms_from_ymd
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
_DATE_RE = re.compile(r"^\d{6}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptionPair:
|
||||
expiry_ymd: str
|
||||
expiry_ms: int
|
||||
strike: float
|
||||
call_inst_id: str
|
||||
put_inst_id: str
|
||||
|
||||
|
||||
def safe_float(v: Any) -> float | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def parse_option_inst_id(inst_id: str) -> tuple[str | None, float | None, str | None]:
|
||||
"""ETH-USD_UM-YYMMDD-STRIKE-C → (YYMMDD, strike, C|P)."""
|
||||
parts = (inst_id or "").strip().split("-")
|
||||
if len(parts) < 5:
|
||||
return None, None, None
|
||||
ymd = parts[-3]
|
||||
strike = safe_float(parts[-2])
|
||||
opt = parts[-1].upper()
|
||||
if not _DATE_RE.fullmatch(ymd) or strike is None or opt not in ("C", "P"):
|
||||
return None, None, None
|
||||
return ymd, strike, opt
|
||||
|
||||
|
||||
def rows_to_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
state = str(row.get("state") or "live").lower()
|
||||
if state and state != "live":
|
||||
continue
|
||||
inst_id = str(row.get("instId") or "")
|
||||
y, stk, opt = parse_option_inst_id(inst_id)
|
||||
exp_ms: int | None = None
|
||||
if y is None or stk is None or opt is None:
|
||||
from datetime import timezone
|
||||
|
||||
exp = safe_float(row.get("expTime"))
|
||||
if exp:
|
||||
ms = int(exp) if exp > 10_000_000_000 else int(exp * 1000)
|
||||
y = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%y%m%d")
|
||||
exp_ms = ms
|
||||
stk = safe_float(row.get("stk"))
|
||||
opt_raw = str(row.get("optType") or "").upper()
|
||||
opt = opt_raw if opt_raw in ("C", "P") else None
|
||||
if not inst_id or not y or stk is None or opt not in ("C", "P"):
|
||||
continue
|
||||
if exp_ms is None:
|
||||
exp_ms = expiry_ms_from_ymd(y)
|
||||
out.append(
|
||||
{
|
||||
"inst_id": inst_id,
|
||||
"expiry_ymd": y,
|
||||
"expiry_ms": int(exp_ms),
|
||||
"strike": float(stk),
|
||||
"side": opt,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def hours_until_ms(expiry_ms: int, now: datetime | None = None) -> float:
|
||||
n = (now or datetime.now(tz=_SH)).astimezone(_SH)
|
||||
return (int(expiry_ms) - int(n.timestamp() * 1000)) / 3_600_000.0
|
||||
|
||||
|
||||
def pick_atm_strike(strikes: list[float], index_px: float) -> float | None:
|
||||
"""最接近指数的行权价(平值)。"""
|
||||
if not strikes or index_px <= 0:
|
||||
return None
|
||||
return min(strikes, key=lambda s: (abs(s - index_px), s))
|
||||
|
||||
|
||||
def _complete_by_expiry(
|
||||
contracts: list[dict[str, Any]],
|
||||
) -> dict[str, tuple[int, dict[float, dict[str, str]]]]:
|
||||
by_exp: dict[str, dict[float, dict[str, str]]] = {}
|
||||
ms_map: dict[str, int] = {}
|
||||
for c in contracts:
|
||||
y = str(c.get("expiry_ymd") or "")
|
||||
stk = c.get("strike")
|
||||
opt = str(c.get("side") or "").upper()
|
||||
inst_id = str(c.get("inst_id") or "")
|
||||
if not y or stk is None or opt not in ("C", "P") or not inst_id:
|
||||
continue
|
||||
by_exp.setdefault(y, {}).setdefault(float(stk), {})[opt] = inst_id
|
||||
if c.get("expiry_ms") is not None:
|
||||
ms_map[y] = int(c["expiry_ms"])
|
||||
out: dict[str, tuple[int, dict[float, dict[str, str]]]] = {}
|
||||
for ymd, strikes in by_exp.items():
|
||||
complete = {s: v for s, v in strikes.items() if "C" in v and "P" in v}
|
||||
if not complete:
|
||||
continue
|
||||
ems = ms_map.get(ymd) or expiry_ms_from_ymd(ymd)
|
||||
out[ymd] = (ems, complete)
|
||||
return out
|
||||
|
||||
|
||||
def select_atm_pair(
|
||||
contracts: list[dict[str, Any]],
|
||||
*,
|
||||
index_px: float,
|
||||
min_hours: float = 12.0,
|
||||
now: datetime | None = None,
|
||||
) -> OptionPair | None:
|
||||
"""
|
||||
选最近合资格到期(剩余 ≥ min_hours)+ ATM Call/Put。
|
||||
ATM = 行权价最接近指数。
|
||||
"""
|
||||
complete = _complete_by_expiry(contracts)
|
||||
if not complete or index_px <= 0:
|
||||
return None
|
||||
eligible = [
|
||||
ymd
|
||||
for ymd, (ems, _) in complete.items()
|
||||
if hours_until_ms(ems, now) + 1e-9 >= float(min_hours)
|
||||
]
|
||||
if not eligible:
|
||||
return None
|
||||
eligible.sort(key=lambda y: complete[y][0])
|
||||
ymd = eligible[0]
|
||||
ems, strikes_map = complete[ymd]
|
||||
strike = pick_atm_strike(list(strikes_map.keys()), index_px)
|
||||
if strike is None:
|
||||
return None
|
||||
legs = strikes_map[strike]
|
||||
return OptionPair(
|
||||
expiry_ymd=ymd,
|
||||
expiry_ms=ems,
|
||||
strike=float(strike),
|
||||
call_inst_id=legs["C"],
|
||||
put_inst_id=legs["P"],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
# worker package — 到期回填 / 日终聚合
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Worker 入口:周期回填到期结算指数。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from apps.collector.okx_rest import OkxRestClient
|
||||
from apps.worker.settle import backfill_settlements
|
||||
from packages.config import get_settings
|
||||
from packages.db import Repository
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s [worker] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("worker")
|
||||
|
||||
_STOP = False
|
||||
|
||||
|
||||
def _handle_signal(signum: int, _frame: Any) -> None:
|
||||
global _STOP
|
||||
log.info("signal %s received, stopping…", signum)
|
||||
_STOP = True
|
||||
|
||||
|
||||
def run() -> int:
|
||||
settings = get_settings()
|
||||
interval = max(60, int(settings.settle_backfill_interval_sec))
|
||||
log.info(
|
||||
"start settle backfill interval=%ss db=%s",
|
||||
interval,
|
||||
settings.db_path,
|
||||
)
|
||||
repo = Repository(settings.db_path)
|
||||
client = OkxRestClient(
|
||||
base_url=settings.okx_base_url,
|
||||
proxy=settings.okx_proxy or None,
|
||||
)
|
||||
try:
|
||||
while not _STOP:
|
||||
try:
|
||||
result = backfill_settlements(
|
||||
repo,
|
||||
underlying=settings.underlying,
|
||||
index_inst_id=settings.index_inst_id,
|
||||
client=client,
|
||||
)
|
||||
log.info(
|
||||
"backfill filled=%s skipped=%s errors=%s",
|
||||
len(result["filled"]),
|
||||
len(result["skipped"]),
|
||||
len(result["errors"]),
|
||||
)
|
||||
for err in result["errors"][:5]:
|
||||
log.warning(" %s", err)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("backfill loop failed: %s", e)
|
||||
|
||||
end = time.monotonic() + interval
|
||||
while not _STOP and time.monotonic() < end:
|
||||
time.sleep(min(1.0, end - time.monotonic()))
|
||||
finally:
|
||||
client.close()
|
||||
repo.close()
|
||||
log.info("stopped")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> None:
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
sys.exit(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,187 @@
|
||||
"""到期结算回填:从本地指数或 OKX 历史指数锚定 settle_index_px。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from apps.collector.okx_rest import OkxRestClient, safe_float
|
||||
from packages.db.repository import Repository
|
||||
from packages.domain.expiry import expiry_ms_from_ymd
|
||||
|
||||
log = logging.getLogger("worker.settle")
|
||||
|
||||
# 本地 index_ticks 与到期时刻的最大偏离
|
||||
_LOCAL_MAX_DELTA_MS = 15 * 60 * 1000
|
||||
|
||||
|
||||
def list_expiry_ymds_needing_settle(repo: Repository, *, now_ms: int | None = None) -> list[str]:
|
||||
"""option_quotes 中已到期且尚未写入 settlements 的 expiry_ymd。"""
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
rows = repo.conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT expiry_ymd FROM option_quotes
|
||||
WHERE expiry_ymd IS NOT NULL AND expiry_ymd != ''
|
||||
ORDER BY expiry_ymd ASC
|
||||
"""
|
||||
).fetchall()
|
||||
out: list[str] = []
|
||||
for r in rows:
|
||||
ymd = str(r["expiry_ymd"])
|
||||
try:
|
||||
settle_ts = expiry_ms_from_ymd(ymd)
|
||||
except ValueError:
|
||||
continue
|
||||
if settle_ts > now:
|
||||
continue
|
||||
if repo.get_settlement(ymd) is not None:
|
||||
continue
|
||||
out.append(ymd)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_settle_index(
|
||||
repo: Repository,
|
||||
*,
|
||||
expiry_ymd: str,
|
||||
underlying: str,
|
||||
index_inst_id: str,
|
||||
exchange: str = "okx",
|
||||
client: OkxRestClient | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
解析到期指数。优先本地 index_ticks 最近点;否则 OKX 历史指数 K 线。
|
||||
"""
|
||||
settle_ts = expiry_ms_from_ymd(expiry_ymd)
|
||||
local = repo.nearest_index_tick(
|
||||
underlying=underlying,
|
||||
target_ts_ms=settle_ts,
|
||||
max_delta_ms=_LOCAL_MAX_DELTA_MS,
|
||||
)
|
||||
if local is not None:
|
||||
return {
|
||||
"expiry_ymd": expiry_ymd,
|
||||
"settle_ts_ms": settle_ts,
|
||||
"settle_index_px": float(local["index_px"]),
|
||||
"exchange": exchange,
|
||||
"underlying": underlying,
|
||||
"source": "index_ticks",
|
||||
"source_ts_ms": int(local["ts_ms"]),
|
||||
}
|
||||
|
||||
own_client = client is None
|
||||
cli = client or OkxRestClient()
|
||||
try:
|
||||
px, src_ts = cli.fetch_index_at(index_inst_id, settle_ts)
|
||||
if px is None:
|
||||
return None
|
||||
return {
|
||||
"expiry_ymd": expiry_ymd,
|
||||
"settle_ts_ms": settle_ts,
|
||||
"settle_index_px": float(px),
|
||||
"exchange": exchange,
|
||||
"underlying": underlying,
|
||||
"source": "okx_history_index",
|
||||
"source_ts_ms": src_ts,
|
||||
}
|
||||
finally:
|
||||
if own_client:
|
||||
cli.close()
|
||||
|
||||
|
||||
def backfill_settlements(
|
||||
repo: Repository,
|
||||
*,
|
||||
underlying: str,
|
||||
index_inst_id: str,
|
||||
client: OkxRestClient | None = None,
|
||||
ymds: list[str] | None = None,
|
||||
now_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""回填到期锚点;返回 {filled, skipped, pending, errors}。"""
|
||||
targets = ymds if ymds is not None else list_expiry_ymds_needing_settle(repo, now_ms=now_ms)
|
||||
filled: list[str] = []
|
||||
skipped: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
own_client = client is None
|
||||
cli = client
|
||||
try:
|
||||
for ymd in targets:
|
||||
if repo.get_settlement(ymd) is not None:
|
||||
skipped.append(ymd)
|
||||
continue
|
||||
try:
|
||||
if cli is None:
|
||||
cli = OkxRestClient()
|
||||
row = resolve_settle_index(
|
||||
repo,
|
||||
expiry_ymd=ymd,
|
||||
underlying=underlying,
|
||||
index_inst_id=index_inst_id,
|
||||
client=cli,
|
||||
)
|
||||
if row is None:
|
||||
errors.append(f"{ymd}: settle index unavailable")
|
||||
continue
|
||||
repo.upsert_settlement(
|
||||
expiry_ymd=row["expiry_ymd"],
|
||||
settle_ts_ms=int(row["settle_ts_ms"]),
|
||||
settle_index_px=float(row["settle_index_px"]),
|
||||
exchange=str(row["exchange"]),
|
||||
underlying=str(row["underlying"]),
|
||||
)
|
||||
filled.append(ymd)
|
||||
log.info(
|
||||
"settled %s index=%.4f source=%s",
|
||||
ymd,
|
||||
row["settle_index_px"],
|
||||
row.get("source"),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{ymd}: {e}")
|
||||
log.exception("backfill %s failed", ymd)
|
||||
finally:
|
||||
if own_client and cli is not None:
|
||||
cli.close()
|
||||
|
||||
return {"filled": filled, "skipped": skipped, "errors": errors, "targets": targets}
|
||||
|
||||
|
||||
def ensure_settlements_for_ymds(
|
||||
repo: Repository,
|
||||
ymds: list[str],
|
||||
*,
|
||||
underlying: str,
|
||||
index_inst_id: str,
|
||||
client: OkxRestClient | None = None,
|
||||
now_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""对给定到期日尽量回填(未到期的跳过)。"""
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
due = []
|
||||
for ymd in sorted(set(ymds)):
|
||||
try:
|
||||
if expiry_ms_from_ymd(ymd) <= now:
|
||||
due.append(ymd)
|
||||
except ValueError:
|
||||
continue
|
||||
return backfill_settlements(
|
||||
repo,
|
||||
underlying=underlying,
|
||||
index_inst_id=index_inst_id,
|
||||
client=client,
|
||||
ymds=due,
|
||||
now_ms=now,
|
||||
)
|
||||
|
||||
|
||||
# re-export for typing clarity
|
||||
__all__ = [
|
||||
"backfill_settlements",
|
||||
"ensure_settlements_for_ymds",
|
||||
"list_expiry_ymds_needing_settle",
|
||||
"resolve_settle_index",
|
||||
"safe_float",
|
||||
]
|
||||
Reference in New Issue
Block a user