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 @@
# Collector package
+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,
}
+1
View File
@@ -0,0 +1 @@
# OKX market collector
+173
View File
@@ -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()
+154
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""WebSocket 占位(P1 用 RESTWS 后期可接)。"""
from __future__ import annotations
# 第一期采集走 REST 轮询;此模块预留多路订阅入口。
+157
View File
@@ -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"],
)
+1
View File
@@ -0,0 +1 @@
# worker package — 到期回填 / 日终聚合
+82
View File
@@ -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()
+187
View File
@@ -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",
]