first commit
This commit is contained in:
@@ -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"],
|
||||
)
|
||||
Reference in New Issue
Block a user