789ab43dbe
Configurable bilateral premium with exceed counts/ratios and settlement PnL for buying volatility. Co-authored-by: Cursor <cursoragent@cursor.com>
541 lines
18 KiB
Python
541 lines
18 KiB
Python
"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径.
|
|
|
|
仅只读行情;不触及下单链路.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import statistics
|
|
from datetime import date, datetime, timedelta
|
|
from typing import Any, Callable, Optional
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
|
|
APP_TZ = ZoneInfo("Asia/Shanghai")
|
|
END_HOUR = 16
|
|
EXCHANGE = "okx"
|
|
TIMEFRAME = "1H"
|
|
|
|
SYMBOLS: dict[str, dict[str, str]] = {
|
|
"eth": {
|
|
"label": "ETH",
|
|
"index_inst": "ETH-USD",
|
|
"swap_inst": "ETH-USDT-SWAP",
|
|
},
|
|
"btc": {
|
|
"label": "BTC",
|
|
"index_inst": "BTC-USD",
|
|
"swap_inst": "BTC-USDT-SWAP",
|
|
},
|
|
}
|
|
|
|
PERIOD_DAYS: dict[str, int] = {
|
|
"1m": 30,
|
|
"2m": 60,
|
|
"3m": 90,
|
|
"6m": 180,
|
|
"1y": 365,
|
|
}
|
|
|
|
OKX_INDEX_CANDLES = "https://www.okx.com/api/v5/market/index-candles"
|
|
OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles"
|
|
|
|
|
|
def normalize_symbol(raw: str) -> str:
|
|
s = (raw or "").strip().lower()
|
|
if s in ("eth", "ethereum"):
|
|
return "eth"
|
|
if s in ("btc", "bitcoin"):
|
|
return "btc"
|
|
raise ValueError("symbol 仅支持 eth / btc")
|
|
|
|
|
|
def resolve_sample_days(period: str, custom_days: Any = None) -> int:
|
|
p = (period or "2m").strip().lower()
|
|
if p == "custom":
|
|
try:
|
|
n = int(custom_days)
|
|
except (TypeError, ValueError):
|
|
raise ValueError("自定义天数无效") from None
|
|
return max(7, min(400, n))
|
|
if p not in PERIOD_DAYS:
|
|
raise ValueError("周期无效")
|
|
return PERIOD_DAYS[p]
|
|
|
|
|
|
def window_bounds_for_settlement(settlement: date, start_hour: int) -> tuple[datetime, datetime]:
|
|
"""返回 [start, end) 的本地时刻;end 为结算日 16:00."""
|
|
if not (0 <= int(start_hour) <= 23):
|
|
raise ValueError("起点须为 0-23 整点")
|
|
end = datetime(settlement.year, settlement.month, settlement.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
|
sh = int(start_hour)
|
|
if sh >= END_HOUR:
|
|
prev = settlement - timedelta(days=1)
|
|
start = datetime(prev.year, prev.month, prev.day, sh, 0, 0, tzinfo=APP_TZ)
|
|
else:
|
|
start = datetime(settlement.year, settlement.month, settlement.day, sh, 0, 0, tzinfo=APP_TZ)
|
|
return start, end
|
|
|
|
|
|
def list_settlement_dates(*, sample_days: int, now: Optional[datetime] = None) -> list[date]:
|
|
"""最近 sample_days 个已收窗结算日(不含进行中的今天未到 16:00)."""
|
|
now = now or datetime.now(APP_TZ)
|
|
if now.tzinfo is None:
|
|
now = now.replace(tzinfo=APP_TZ)
|
|
else:
|
|
now = now.astimezone(APP_TZ)
|
|
today = now.date()
|
|
today_end = datetime(today.year, today.month, today.day, END_HOUR, 0, 0, tzinfo=APP_TZ)
|
|
latest = today if now >= today_end else today - timedelta(days=1)
|
|
return [latest - timedelta(days=i) for i in range(int(sample_days))]
|
|
|
|
|
|
def _safe_float(v: Any) -> Optional[float]:
|
|
try:
|
|
if v is None or v == "":
|
|
return None
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def bars_to_map(bars: list[dict[str, Any]]) -> dict[int, dict[str, float]]:
|
|
"""open_time_ms -> {o,h,l,c}."""
|
|
m: dict[int, dict[str, float]] = {}
|
|
for b in bars or []:
|
|
if not isinstance(b, dict):
|
|
continue
|
|
ts = b.get("ts")
|
|
if ts is None:
|
|
ts = b.get("open_time_ms")
|
|
try:
|
|
ts_i = int(ts)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
o = _safe_float(b.get("o") if "o" in b else b.get("open"))
|
|
h = _safe_float(b.get("h") if "h" in b else b.get("high"))
|
|
l = _safe_float(b.get("l") if "l" in b else b.get("low"))
|
|
c = _safe_float(b.get("c") if "c" in b else b.get("close"))
|
|
if None in (o, h, l, c):
|
|
continue
|
|
m[ts_i] = {"o": float(o), "h": float(h), "l": float(l), "c": float(c)}
|
|
return m
|
|
|
|
|
|
def compute_day_row(
|
|
settlement: date,
|
|
start_hour: int,
|
|
bar_map: dict[int, dict[str, float]],
|
|
) -> Optional[dict[str, Any]]:
|
|
start, end = window_bounds_for_settlement(settlement, start_hour)
|
|
start_ms = int(start.timestamp() * 1000)
|
|
# 1H 棒覆盖 [T, T+1h);窗终点 16:00 用 15:00 棒的 close
|
|
last_bar_ms = int((end - timedelta(hours=1)).timestamp() * 1000)
|
|
if start_ms not in bar_map or last_bar_ms not in bar_map:
|
|
return None
|
|
opens = bar_map[start_ms]["o"]
|
|
close = bar_map[last_bar_ms]["c"]
|
|
hi = bar_map[start_ms]["h"]
|
|
lo = bar_map[start_ms]["l"]
|
|
t = start_ms
|
|
while t <= last_bar_ms:
|
|
b = bar_map.get(t)
|
|
if b:
|
|
hi = max(hi, b["h"])
|
|
lo = min(lo, b["l"])
|
|
t += 3600 * 1000
|
|
up = hi - opens
|
|
down = opens - lo
|
|
amp = hi - lo
|
|
change = close - opens
|
|
return {
|
|
"settlement_day": settlement.isoformat(),
|
|
"window_start": start.strftime("%Y-%m-%d %H:%M"),
|
|
"window_end": end.strftime("%Y-%m-%d %H:%M"),
|
|
"open": round(opens, 4),
|
|
"high": round(hi, 4),
|
|
"low": round(lo, 4),
|
|
"close": round(close, 4),
|
|
"up_points": round(up, 4),
|
|
"down_points": round(down, 4),
|
|
"amplitude": round(amp, 4),
|
|
"change": round(change, 4),
|
|
}
|
|
|
|
|
|
def normalize_straddle_premium(raw: Any) -> Optional[float]:
|
|
"""双边权利金(点数).空/≤0 表示不做跨式对照."""
|
|
if raw is None or raw == "":
|
|
return None
|
|
try:
|
|
v = float(raw)
|
|
except (TypeError, ValueError):
|
|
raise ValueError("双边权利金须为数字") from None
|
|
if v <= 0:
|
|
return None
|
|
return v
|
|
|
|
|
|
def straddle_long_stats(rows: list[dict[str, Any]], premium: float) -> dict[str, Any]:
|
|
"""买跨(赌波动):盈亏按收盘 |C−O| − 双边权利金;越过阈值用严格 >."""
|
|
prem = float(premium)
|
|
if prem <= 0:
|
|
raise ValueError("双边权利金须 > 0")
|
|
if not rows:
|
|
return {
|
|
"side": "long_straddle",
|
|
"premium": prem,
|
|
"sample_count": 0,
|
|
"up_exceed_days": 0,
|
|
"up_exceed_ratio": None,
|
|
"down_exceed_days": 0,
|
|
"down_exceed_ratio": None,
|
|
"abs_change_exceed_days": 0,
|
|
"abs_change_exceed_ratio": None,
|
|
"pnl_total": None,
|
|
"pnl_avg": None,
|
|
"win_days": 0,
|
|
"win_ratio": None,
|
|
"pnl_max": None,
|
|
"pnl_min": None,
|
|
}
|
|
n = len(rows)
|
|
up_ex = sum(1 for r in rows if float(r["up_points"]) > prem)
|
|
down_ex = sum(1 for r in rows if float(r["down_points"]) > prem)
|
|
abs_ex = sum(1 for r in rows if abs(float(r["change"])) > prem)
|
|
pnls = [abs(float(r["change"])) - prem for r in rows]
|
|
win = sum(1 for p in pnls if p > 0)
|
|
return {
|
|
"side": "long_straddle",
|
|
"premium": round(prem, 4),
|
|
"sample_count": n,
|
|
"up_exceed_days": up_ex,
|
|
"up_exceed_ratio": round(up_ex / n, 4),
|
|
"down_exceed_days": down_ex,
|
|
"down_exceed_ratio": round(down_ex / n, 4),
|
|
"abs_change_exceed_days": abs_ex,
|
|
"abs_change_exceed_ratio": round(abs_ex / n, 4),
|
|
"pnl_total": round(sum(pnls), 4),
|
|
"pnl_avg": round(statistics.fmean(pnls), 4),
|
|
"win_days": win,
|
|
"win_ratio": round(win / n, 4),
|
|
"pnl_max": round(max(pnls), 4),
|
|
"pnl_min": round(min(pnls), 4),
|
|
}
|
|
|
|
|
|
def summarize_rows(
|
|
rows: list[dict[str, Any]],
|
|
*,
|
|
straddle_premium: Any = None,
|
|
) -> dict[str, Any]:
|
|
if not rows:
|
|
out = {
|
|
"sample_count": 0,
|
|
"max_amplitude": None,
|
|
"max_amplitude_day": None,
|
|
"avg_amplitude": None,
|
|
"median_amplitude": None,
|
|
"max_up_points": None,
|
|
"avg_up_points": None,
|
|
"max_down_points": None,
|
|
"avg_down_points": None,
|
|
"up_day_ratio": None,
|
|
"down_day_ratio": None,
|
|
"straddle": None,
|
|
}
|
|
prem = normalize_straddle_premium(straddle_premium)
|
|
if prem is not None:
|
|
out["straddle"] = straddle_long_stats([], prem)
|
|
return out
|
|
amps = [float(r["amplitude"]) for r in rows]
|
|
ups = [float(r["up_points"]) for r in rows]
|
|
downs = [float(r["down_points"]) for r in rows]
|
|
max_amp = max(amps)
|
|
max_amp_day = next(r["settlement_day"] for r in rows if float(r["amplitude"]) == max_amp)
|
|
up_days = sum(1 for r in rows if float(r["change"]) > 0)
|
|
down_days = sum(1 for r in rows if float(r["change"]) < 0)
|
|
n = len(rows)
|
|
out: dict[str, Any] = {
|
|
"sample_count": n,
|
|
"max_amplitude": round(max_amp, 4),
|
|
"max_amplitude_day": max_amp_day,
|
|
"avg_amplitude": round(statistics.fmean(amps), 4),
|
|
"median_amplitude": round(statistics.median(amps), 4),
|
|
"max_up_points": round(max(ups), 4),
|
|
"avg_up_points": round(statistics.fmean(ups), 4),
|
|
"max_down_points": round(max(downs), 4),
|
|
"avg_down_points": round(statistics.fmean(downs), 4),
|
|
"up_day_ratio": round(up_days / n, 4),
|
|
"down_day_ratio": round(down_days / n, 4),
|
|
"straddle": None,
|
|
}
|
|
prem = normalize_straddle_premium(straddle_premium)
|
|
if prem is not None:
|
|
out["straddle"] = straddle_long_stats(rows, prem)
|
|
return out
|
|
|
|
|
|
def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]:
|
|
if not row or len(row) < 5:
|
|
return None
|
|
try:
|
|
ts = int(row[0])
|
|
o, h, l, c = float(row[1]), float(row[2]), float(row[3]), float(row[4])
|
|
except (TypeError, ValueError, IndexError):
|
|
return None
|
|
return {"ts": ts, "o": o, "h": h, "l": l, "c": c}
|
|
|
|
|
|
def fetch_okx_candles(
|
|
*,
|
|
url: str,
|
|
inst_id: str,
|
|
since_ms: int,
|
|
until_ms: int,
|
|
bar: str = "1H",
|
|
client: Optional[httpx.Client] = None,
|
|
timeout: float = 30.0,
|
|
) -> list[dict[str, Any]]:
|
|
"""拉取 [since_ms, until_ms] 覆盖的 K 线(含边界).OKX 返回新→旧."""
|
|
own = client is None
|
|
client = client or httpx.Client(
|
|
timeout=timeout,
|
|
trust_env=False,
|
|
headers={"User-Agent": "crypto_monitor-amp-stats/1.0"},
|
|
)
|
|
try:
|
|
out: dict[int, dict[str, Any]] = {}
|
|
after: Optional[str] = None
|
|
for _ in range(80):
|
|
params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"}
|
|
if after:
|
|
params["after"] = after
|
|
r = client.get(url, params=params)
|
|
r.raise_for_status()
|
|
body = r.json()
|
|
if str(body.get("code") or "") not in ("0", "0.0", ""):
|
|
raise RuntimeError(body.get("msg") or f"OKX error {body.get('code')}")
|
|
data = body.get("data") or []
|
|
if not data:
|
|
break
|
|
oldest_ts = None
|
|
for row in data:
|
|
parsed = _parse_okx_candle_row(row)
|
|
if not parsed:
|
|
continue
|
|
ts = int(parsed["ts"])
|
|
oldest_ts = ts if oldest_ts is None else min(oldest_ts, ts)
|
|
if ts < since_ms - 3600 * 1000:
|
|
continue
|
|
if ts > until_ms + 3600 * 1000:
|
|
continue
|
|
out[ts] = parsed
|
|
if oldest_ts is None:
|
|
break
|
|
if oldest_ts <= since_ms:
|
|
break
|
|
after = str(oldest_ts)
|
|
return [out[k] for k in sorted(out.keys())]
|
|
finally:
|
|
if own:
|
|
client.close()
|
|
|
|
|
|
def fetch_symbol_bars(
|
|
symbol: str,
|
|
*,
|
|
since_ms: int,
|
|
until_ms: int,
|
|
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
|
) -> tuple[list[dict[str, Any]], str, str]:
|
|
"""返回 (bars, price_source_label, inst_id)."""
|
|
key = normalize_symbol(symbol)
|
|
meta = SYMBOLS[key]
|
|
if fetch_fn:
|
|
bars = fetch_fn(inst_id=meta["index_inst"], since_ms=since_ms, until_ms=until_ms)
|
|
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
|
|
|
try:
|
|
bars = fetch_okx_candles(
|
|
url=OKX_INDEX_CANDLES,
|
|
inst_id=meta["index_inst"],
|
|
since_ms=since_ms,
|
|
until_ms=until_ms,
|
|
)
|
|
if bars:
|
|
return bars, f"okx_index:{meta['index_inst']}", meta["index_inst"]
|
|
except Exception:
|
|
bars = []
|
|
|
|
bars = fetch_okx_candles(
|
|
url=OKX_SWAP_CANDLES,
|
|
inst_id=meta["swap_inst"],
|
|
since_ms=since_ms,
|
|
until_ms=until_ms,
|
|
)
|
|
if not bars:
|
|
raise RuntimeError("OKX 指数与永续 K 线均无数据")
|
|
return bars, f"okx_swap:{meta['swap_inst']}", meta["swap_inst"]
|
|
|
|
|
|
def compute_amp_stats(
|
|
*,
|
|
symbol: str = "eth",
|
|
start_hour: int = 16,
|
|
period: str = "2m",
|
|
custom_days: Any = None,
|
|
straddle_premium: Any = None,
|
|
now: Optional[datetime] = None,
|
|
fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None,
|
|
) -> dict[str, Any]:
|
|
key = normalize_symbol(symbol)
|
|
sh = int(start_hour)
|
|
if sh < 0 or sh > 23:
|
|
raise ValueError("起点须为 0-23 整点")
|
|
prem = normalize_straddle_premium(straddle_premium)
|
|
sample_days = resolve_sample_days(period, custom_days)
|
|
settlements = list_settlement_dates(sample_days=sample_days, now=now)
|
|
if not settlements:
|
|
raise RuntimeError("无可用结算日")
|
|
# 最远窗起点
|
|
oldest = settlements[-1]
|
|
newest = settlements[0]
|
|
start0, _ = window_bounds_for_settlement(oldest, sh)
|
|
_, end1 = window_bounds_for_settlement(newest, sh)
|
|
since_ms = int(start0.timestamp() * 1000)
|
|
until_ms = int(end1.timestamp() * 1000)
|
|
bars, price_source, inst_id = fetch_symbol_bars(
|
|
key, since_ms=since_ms, until_ms=until_ms, fetch_fn=fetch_fn
|
|
)
|
|
bar_map = bars_to_map(bars)
|
|
rows: list[dict[str, Any]] = []
|
|
missing: list[str] = []
|
|
for d in settlements:
|
|
row = compute_day_row(d, sh, bar_map)
|
|
if row is None:
|
|
missing.append(d.isoformat())
|
|
continue
|
|
rows.append(row)
|
|
summary = summarize_rows(rows, straddle_premium=prem)
|
|
period_label = period if period != "custom" else f"custom:{sample_days}"
|
|
return {
|
|
"ok": True,
|
|
"exchange": EXCHANGE,
|
|
"symbol": key,
|
|
"symbol_label": SYMBOLS[key]["label"],
|
|
"start_hour": sh,
|
|
"end_hour": END_HOUR,
|
|
"period": period_label,
|
|
"sample_days_requested": sample_days,
|
|
"straddle_premium": prem,
|
|
"timeframe": TIMEFRAME,
|
|
"price_source": price_source,
|
|
"inst_id": inst_id,
|
|
"timezone": "Asia/Shanghai",
|
|
"rows": rows,
|
|
"summary": summary,
|
|
"missing_days": missing[:30],
|
|
"missing_count": len(missing),
|
|
}
|
|
|
|
|
|
def rows_page(rows: list[dict[str, Any]], *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
|
page = max(1, int(page or 1))
|
|
page_size = max(5, min(100, int(page_size or 20)))
|
|
total = len(rows)
|
|
start = (page - 1) * page_size
|
|
chunk = rows[start : start + page_size]
|
|
return {
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"total": total,
|
|
"total_pages": max(1, (total + page_size - 1) // page_size) if total else 1,
|
|
"rows": chunk,
|
|
}
|
|
|
|
|
|
def build_export_csv(payload: dict[str, Any]) -> str:
|
|
buf = io.StringIO()
|
|
# Excel 友好 BOM
|
|
buf.write("\ufeff")
|
|
w = csv.writer(buf)
|
|
s = payload.get("summary") or {}
|
|
w.writerow(["【统计摘要】"])
|
|
w.writerow(["交易所", payload.get("exchange")])
|
|
w.writerow(["标的", payload.get("symbol_label")])
|
|
w.writerow(["价源", payload.get("price_source")])
|
|
w.writerow(["起点整点", f"{payload.get('start_hour')}:00"])
|
|
w.writerow(["终点", f"{payload.get('end_hour')}:00"])
|
|
w.writerow(["周期", payload.get("period")])
|
|
w.writerow(["样本数", s.get("sample_count")])
|
|
w.writerow(["最大振幅", s.get("max_amplitude"), "日期", s.get("max_amplitude_day")])
|
|
w.writerow(["振幅均值", s.get("avg_amplitude"), "中位数", s.get("median_amplitude")])
|
|
w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")])
|
|
w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")])
|
|
w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")])
|
|
st = s.get("straddle") or {}
|
|
if st:
|
|
w.writerow([])
|
|
w.writerow(["【买跨对照·双边权利金】", st.get("premium")])
|
|
w.writerow(["开→高超过", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")])
|
|
w.writerow(["开→低超过", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")])
|
|
w.writerow(["|涨跌|超过", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")])
|
|
w.writerow(
|
|
[
|
|
"买跨点数盈亏合计",
|
|
st.get("pnl_total"),
|
|
"日均",
|
|
st.get("pnl_avg"),
|
|
"赚钱天数",
|
|
st.get("win_days"),
|
|
"胜率",
|
|
st.get("win_ratio"),
|
|
]
|
|
)
|
|
w.writerow(["单日最大赚", st.get("pnl_max"), "单日最大亏", st.get("pnl_min")])
|
|
w.writerow([])
|
|
w.writerow(["【日表明细】"])
|
|
w.writerow(
|
|
[
|
|
"结算日",
|
|
"窗起点",
|
|
"窗终点",
|
|
"开盘",
|
|
"最高",
|
|
"最低",
|
|
"收盘",
|
|
"开→高",
|
|
"开→低",
|
|
"振幅",
|
|
"涨跌值",
|
|
]
|
|
)
|
|
for r in payload.get("rows") or []:
|
|
w.writerow(
|
|
[
|
|
r.get("settlement_day"),
|
|
r.get("window_start"),
|
|
r.get("window_end"),
|
|
r.get("open"),
|
|
r.get("high"),
|
|
r.get("low"),
|
|
r.get("close"),
|
|
r.get("up_points"),
|
|
r.get("down_points"),
|
|
r.get("amplitude"),
|
|
r.get("change"),
|
|
]
|
|
)
|
|
return buf.getvalue()
|
|
|
|
|
|
def export_filename(payload: dict[str, Any]) -> str:
|
|
sym = (payload.get("symbol") or "eth").lower()
|
|
sh = int(payload.get("start_hour") or 16)
|
|
period = str(payload.get("period") or "2m").replace(":", "")
|
|
day = datetime.now(APP_TZ).strftime("%Y%m%d")
|
|
return f"okx_{sym}_amp_{sh}to16_{period}_{day}.csv"
|