diff --git a/docs/振幅统计说明.md b/docs/振幅统计说明.md index 9c6c6cf..88de03e 100644 --- a/docs/振幅统计说明.md +++ b/docs/振幅统计说明.md @@ -3,8 +3,8 @@ 中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。 > 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。 -> 永期对冲公式见 [永期对冲计算器.md](./永期对冲计算器.md)。 -> **不改下单链路**;不算 IV。 +> **不改下单链路**;不算 IV。 +> 买跨 / 永期对冲测算请用中控 **策略计算器**,本页不再做对照盈亏。 --- @@ -22,8 +22,9 @@ 2. 选择 **标的** ETH / BTC;数据源固定 **OKX** 3. **起点整点**(00–23);终点固定 **16:00** 4. **周期**:1 月 / 2 月 / 3 月 / 半年 / 1 年 / 自定义天数(默认 2 个月) -5. 点 **计算** → 下方看汇总 + 分页日表 -6. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细 +5. 可选填 **波动点数**(如 `50`)→ 看振幅达标占比 +6. 点 **计算** → 下方看汇总 + 振幅占比 + 分页日表 +7. 需要留存时点 **保存到历史**;**下载 CSV** 含摘要 + 全日明细 **跨天例子** @@ -43,8 +44,8 @@ | 字段 | 算法 | |------|------| -| 开→高 | `H − O` | -| 开→低 | `O − L` | +| 开→高 | `H − O`(一边波动) | +| 开→低 | `O − L`(另一边波动) | | **振幅** | `H − L`(= 开→高 + 开→低) | | 涨跌值 | `C − O` | @@ -58,35 +59,19 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / --- -## 对照模式(买跨 / 永期二选一) +## 波动点数 → 振幅占比 -表单 **对照模式** 切换: - -| 模式 | 表单 | 汇总块 | 日表末列 | -|------|------|--------|----------| -| 买跨双边 | 双边权利金、止盈点 | 买跨对照 | 收益 | -| 永期对冲 | 目标盈利、杠杆、比例 | 永期对冲对照 | 永期盈亏 | - -同一时刻只计算 / 展示当前模式;切换后在已有日表上本地重算。 - ---- - -## 买跨对照(赌波动) - -表单可填 **双边权利金(点)**,例如 `30`;旁边可填 **止盈点**(可空): +表单可填 **波动点数**(如 `50`)。填写后下方 **振幅占比** 块显示: | 汇总项 | 口径 | |--------|------| -| 开→高超过权利金 | `H−O > 权利金` 的天数与占比 | -| 开→低超过权利金 | `O−L > 权利金` 的天数与占比 | -| \|涨跌\|超过权利金 | `\|C−O\| > 权利金` 的天数与占比 | -| 有效波动 | 若设止盈且 `开→高≥止盈` 或 `开→低≥止盈` → 用止盈点;否则用 `\|C−O\|` | -| 买跨收益 | `有效波动 − 权利金`(日表「收益」列同口径) | +| 振幅≥点数 | `H−L ≥ 点数` 的天数与**占比**(主指标) | +| 开→高≥点数 | `H−O ≥ 点数` 天数与占比 | +| 开→低≥点数 | `O−L ≥ 点数` 天数与占比 | +| \|涨跌\|≥点数 | `\|C−O\| ≥ 点数` 天数与占比 | -- 方向:**买跨** -- 权利金越过:严格 **`>`**;止盈触达:**`≥`** -- 止盈留空 / ≤0:有效波动一律按 `|涨跌|` -- 已算出日表后,改权利金 / 止盈 / 周末筛选会**本地重算**(不重拉 K 线) +日表保留 **开→高 / 开→低**(两边波动点数),并标 **振幅达标**;达标行振幅会高亮。 +改点数 / 周末筛选会在已有日表上**本地重算**(不重拉 K 线)。 ### 周末 @@ -95,46 +80,6 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / --- -## 永期对冲对照 - -与中控 [永期对冲计算器](./永期对冲计算器.md) 同口径:**永续做多 1 币 + 买期权**(默认比例 **1:2**),在历史振幅日表上做对照。 - -表单填:目标盈利、永续杠杆、期权杠杆、比例(可改)。**入场价 = 当日开盘**,不再填现价。未填齐目标/杠杆时不计算该块。 - -```text -单币权利金(日) = 当日开盘 / 期权杠杆 -期权币数 = 1 × (期权比例 / 永续比例) -权利金总额(日) = 期权币数 × 单币权利金(日) -``` - -推「所需点数」时,用样本 **开盘中位数** 作入场参照(汇总里展示的权利金中位同口径)。 - -### ① 所需点数达标 - -复用计算器「由比例推点数」(按**当日开盘**推 A/B;汇总展示用样本开盘中位): - -| 指标 | 规则 | -|------|------| -| A 所需点数 | 永续方向对、净利=目标 | -| A 达标 | 日 `开→高 ≥ A点数` 的天数与占比 | -| B 所需点数 | 期权方向对、**组合净利**=目标 | -| B 达标 | 日 `开→低 ≥ B点数` 的天数与占比 | - -### ② 按日组合盈亏(目标出场) - -日盈利目标(如 **15U**)用于出场: - -| 情形 | 日盈亏 | -|------|--------| -| 开→高触达 A,或 开→低触达 B | **= 目标盈利**(出场) | -| 两边都触达 | 仍按目标盈利(OHLC 未知先后) | -| 均未触达 | 收盘结算:上涨 `涨跌−权利金−手续费`;下跌 `\|涨跌\|×(期权币数−1)−权利金` | - -汇总:目标出场天数 / 收盘结算天数、合计、日均、胜率、上涨日/下跌日盈亏小计、单日最大赚亏;日表 **永期盈亏** 列。 -改永期参数 / 周末筛选会**本地重算**(不重拉 K 线)。 - ---- - ## 历史 Tab - 仅 **保存到历史** 后出现(不会一算就自动入库) @@ -151,7 +96,7 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / | `manual_trading_hub/amp_stats_routes.py` | API | | `manual_trading_hub/amp_stats_store.py` | 历史 JSON | | `manual_trading_hub/static/amp_stats.js` | 前端 | -| `tests/test_amp_stats_lib.py` | 单元测试 | +| `tests/test_amp_stats_lib.py` | 单测 | --- @@ -160,9 +105,6 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / | 日期 | 说明 | |------|------| | 2026-07-23 | 首版上线说明 | -| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 | -| 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 | -| 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 | -| 2026-07-28 | 永期对冲对照:所需点数达标 + 按日组合盈亏 | -| 2026-07-28 | 永期入场改按日开盘;买跨/永期对照模式二选一 | -| 2026-07-28 | 永期日盈亏按目标盈利出场(开→高/低触达),未触达才收盘结算 | +| 2026-07-23 | 买跨对照、周末筛选、止盈点 | +| 2026-07-28 | 永期对冲对照(后已移除) | +| 2026-07-28 | 去掉买跨/永期;改为波动点数→振幅占比 | diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py index 5ce49a9..6cdcc6d 100644 --- a/lib/hub/amp_stats_lib.py +++ b/lib/hub/amp_stats_lib.py @@ -1,1258 +1,786 @@ -"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径. - -仅只读行情;不触及下单链路. -""" -from __future__ import annotations - -import csv -import io -import statistics -import time -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_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles" -OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles" -OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-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 - wd = settlement.weekday() # Mon=0 … Sun=6 - is_we = wd >= 5 - return { - "settlement_day": settlement.isoformat(), - "window_start": start.strftime("%Y-%m-%d %H:%M"), - "window_end": end.strftime("%Y-%m-%d %H:%M"), - "weekday": wd, - "weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""), - "is_weekend": is_we, - "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 normalize_take_profit(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 normalize_weekend_filter(raw: Any) -> str: - """all | exclude | only;默认全部.""" - s = (str(raw) if raw is not None else "all").strip().lower() - if s in ("", "all", "全部"): - return "all" - if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"): - return "exclude" - if s in ("only", "weekend_only", "only_weekend", "仅周末"): - return "only" - raise ValueError("周末筛选须为 all / exclude / only") - - -def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]: - mode = normalize_weekend_filter(weekend_filter) - if mode == "all": - return list(rows or []) - out: list[dict[str, Any]] = [] - for r in rows or []: - is_we = bool(r.get("is_weekend")) - if "is_weekend" not in r and r.get("settlement_day"): - try: - is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5 - except ValueError: - is_we = False - if mode == "exclude" and is_we: - continue - if mode == "only" and not is_we: - continue - out.append(r) - return out - - -def effective_move_points(row: dict[str, Any], take_profit: Optional[float]) -> float: - """触达止盈(≥)用止盈点,否则用 |涨跌|.""" - abs_chg = abs(float(row.get("change") or 0)) - if take_profit is None: - return abs_chg - tp = float(take_profit) - up = float(row.get("up_points") or 0) - down = float(row.get("down_points") or 0) - if up >= tp or down >= tp: - return tp - return abs_chg - - -def enrich_rows_pnl( - rows: list[dict[str, Any]], - *, - straddle_premium: Optional[float] = None, - take_profit: Optional[float] = None, - perp_hedge: Any = None, -) -> list[dict[str, Any]]: - """为日表附加有效波动 / 是否触达止盈 / 收益(有权利金时) / 永期盈亏.""" - prem = normalize_straddle_premium(straddle_premium) - tp = normalize_take_profit(take_profit) - hedge = normalize_perp_hedge_params(perp_hedge) - out: list[dict[str, Any]] = [] - for r in rows or []: - item = dict(r) - if "is_weekend" not in item and item.get("settlement_day"): - try: - wd = date.fromisoformat(str(item["settlement_day"])).weekday() - item["weekday"] = wd - item["weekday_label"] = "六" if wd == 5 else ("日" if wd == 6 else "") - item["is_weekend"] = wd >= 5 - except ValueError: - item.setdefault("weekday_label", "") - item.setdefault("is_weekend", False) - move = effective_move_points(item, tp) - hit = False - if tp is not None: - hit = float(item.get("up_points") or 0) >= tp or float(item.get("down_points") or 0) >= tp - item["effective_move"] = round(move, 4) - item["take_profit_hit"] = hit - item["profit"] = round(move - prem, 4) if prem is not None else None - if hedge is not None: - day = perp_hedge_day_result(item, hedge) - item["perp_hedge_pnl"] = day["pnl"] - item["perp_hedge_exit"] = day["exit"] - item["perp_hedge_hit_a"] = day["hit_a"] - item["perp_hedge_hit_b"] = day["hit_b"] - else: - item["perp_hedge_pnl"] = None - item["perp_hedge_exit"] = None - item["perp_hedge_hit_a"] = False - item["perp_hedge_hit_b"] = False - out.append(item) - return out - - -def normalize_perp_hedge_params(raw: Any) -> Optional[dict[str, float]]: - """永期对冲对照参数.缺必填则返回 None(不做对照). - - 入场价按日开盘;表单只需目标盈利/杠杆/比例.旧字段 spot 可忽略. - """ - if raw is None or raw == "": - return None - if not isinstance(raw, dict): - return None - target = _safe_float(raw.get("target_profit_u") if "target_profit_u" in raw else raw.get("target")) - p_lev = _safe_float(raw.get("perp_leverage")) - o_lev = _safe_float(raw.get("option_leverage")) - rp = _safe_float(raw.get("ratio_perp")) - ro = _safe_float(raw.get("ratio_opt")) - ct = _safe_float(raw.get("ct_mult")) - if target is None or p_lev is None or o_lev is None: - return None - if target < 0 or p_lev <= 0 or o_lev <= 0: - return None - if rp is None or rp <= 0: - rp = 1.0 - if ro is None or ro <= 0: - ro = 2.0 - if ct is None or ct <= 0: - ct = 0.01 - opt_coins = 1.0 * (ro / rp) - return { - "target_profit_u": target, - "perp_leverage": p_lev, - "option_leverage": o_lev, - "ratio_perp": rp, - "ratio_opt": ro, - "ct_mult": ct, - "opt_coins": opt_coins, - "opt_sheets": opt_coins / ct, - } - - -def perp_hedge_day_premium(*, open_px: float, option_leverage: float, opt_coins: float) -> float: - """单日权利金总额 = 开盘 / 期权杠杆 × 期权币数.""" - o = float(open_px or 0) - lev = float(option_leverage or 0) - coins = float(opt_coins or 0) - if o <= 0 or lev <= 0 or coins < 0: - return 0.0 - return coins * (o / lev) - - -def perp_hedge_required_moves( - *, - open_px: float, - hedge: dict[str, float], -) -> tuple[Optional[float], Optional[float], Optional[str]]: - """按当日开盘推 A/B 达目标盈利所需点数.""" - from lib.hub.hub_perp_options_calc_lib import calc_perp_options_points - - spot = float(open_px or 0) - if spot <= 0: - return None, None, "开盘价无效" - points_data, points_err = calc_perp_options_points( - base="ETH", - spot=spot, - capital_usdt=max(spot / hedge["perp_leverage"] * 2, 1000.0), - target_profit_u=hedge["target_profit_u"], - perp_leverage=hedge["perp_leverage"], - option_leverage=hedge["option_leverage"], - ratio_perp=hedge["ratio_perp"], - ratio_opt=hedge["ratio_opt"], - ct_mult=hedge["ct_mult"], - ) - if not points_data: - return None, None, points_err - move_a = float((points_data.get("case_a") or {}).get("move_points") or 0) or None - mb = (points_data.get("case_b") or {}).get("move_points_portfolio") - move_b = float(mb) if mb is not None else None - return move_a, move_b, points_err - - -def perp_hedge_day_pnl_eod( - *, - change: float, - open_px: float, - close_px: float, - option_leverage: float, - opt_coins: float, -) -> float: - """未触达目标时按收盘结算的组合净利. - - 上涨: change − 权利金 − 永续开平手续费 - 下跌: |change|×(opt_coins−1) − 权利金 - """ - from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt - - chg = float(change or 0) - prem = perp_hedge_day_premium( - open_px=open_px, option_leverage=option_leverage, opt_coins=opt_coins - ) - coins = float(opt_coins or 0) - if chg >= 0: - fee = 0.0 - if open_px and close_px and open_px > 0 and close_px > 0: - fee = estimate_roundtrip_fee_usdt(open_px, close_px, qty=1.0, contract_size=1.0) - return round(chg - prem - fee, 4) - return round(abs(chg) * (coins - 1.0) - prem, 4) - - -def perp_hedge_day_result(row: dict[str, Any], hedge: dict[str, float]) -> dict[str, Any]: - """单日永期结果:触达目标点数则按目标盈利出场,否则收盘结算. - - A: 开→高 ≥ move_a → 出场净利 = 目标盈利 - B: 开→低 ≥ move_b → 出场净利 = 目标盈利 - 两边都触达时仍按目标盈利(路径未知,任一边出场均约为目标). - """ - open_px = float(row.get("open") or 0) - close_px = float(row.get("close") or 0) - change = float(row.get("change") or 0) - up_pts = float(row.get("up_points") or 0) - down_pts = float(row.get("down_points") or 0) - target = float(hedge["target_profit_u"]) - move_a, move_b, _err = perp_hedge_required_moves(open_px=open_px, hedge=hedge) - hit_a = bool(move_a is not None and move_a > 0 and up_pts >= move_a) - hit_b = bool(move_b is not None and move_b > 0 and down_pts >= move_b) - if hit_a or hit_b: - if hit_a and hit_b: - exit_tag = "target_both" - elif hit_a: - exit_tag = "target_a" - else: - exit_tag = "target_b" - return { - "pnl": round(target, 4), - "exit": exit_tag, - "hit_a": hit_a, - "hit_b": hit_b, - "move_a": move_a, - "move_b": move_b, - } - return { - "pnl": perp_hedge_day_pnl_eod( - change=change, - open_px=open_px, - close_px=close_px, - option_leverage=float(hedge["option_leverage"]), - opt_coins=float(hedge["opt_coins"]), - ), - "exit": "eod", - "hit_a": False, - "hit_b": False, - "move_a": move_a, - "move_b": move_b, - } - - -# 兼容旧名:默认按「目标出场」完整日结果取 pnl -def perp_hedge_day_pnl( - *, - change: float, - open_px: float, - close_px: float, - option_leverage: float, - opt_coins: float, - up_points: Optional[float] = None, - down_points: Optional[float] = None, - target_profit_u: Optional[float] = None, - perp_leverage: float = 10.0, - ratio_perp: float = 1.0, - ratio_opt: float = 2.0, - ct_mult: float = 0.01, -) -> float: - """单日盈亏.若给了目标与开→高/低,触达则按目标出场;否则收盘结算.""" - if target_profit_u is None or up_points is None or down_points is None: - return perp_hedge_day_pnl_eod( - change=change, - open_px=open_px, - close_px=close_px, - option_leverage=option_leverage, - opt_coins=opt_coins, - ) - hedge = { - "target_profit_u": float(target_profit_u), - "perp_leverage": float(perp_leverage), - "option_leverage": float(option_leverage), - "ratio_perp": float(ratio_perp), - "ratio_opt": float(ratio_opt), - "ct_mult": float(ct_mult), - "opt_coins": float(opt_coins), - "opt_sheets": float(opt_coins) / float(ct_mult), - } - return float( - perp_hedge_day_result( - { - "open": open_px, - "close": close_px, - "change": change, - "up_points": up_points, - "down_points": down_points, - }, - hedge, - )["pnl"] - ) - - -def perp_hedge_stats( - rows: list[dict[str, Any]], - hedge: dict[str, float], -) -> dict[str, Any]: - """永期对冲:所需点数达标 + 按日组合盈亏汇总. - - 达标看开→高/开→低是否触达当日入场推得的 A/B 点数; - 触达则日盈亏=目标盈利,否则收盘结算.汇总展示点数用样本开盘中位. - """ - opens = [float(r.get("open") or 0) for r in (rows or []) if float(r.get("open") or 0) > 0] - spot_ref = statistics.median(opens) if opens else None - prem_ref = ( - perp_hedge_day_premium( - open_px=spot_ref, - option_leverage=hedge["option_leverage"], - opt_coins=hedge["opt_coins"], - ) - if spot_ref is not None - else None - ) - prem_per_coin_ref = (spot_ref / hedge["option_leverage"]) if spot_ref is not None else None - - move_a = None - move_b = None - points_err = None - if spot_ref is not None and spot_ref > 0: - move_a, move_b, points_err = perp_hedge_required_moves(open_px=spot_ref, hedge=hedge) - else: - points_err = "样本无有效开盘价,无法推所需点数" - - work: list[dict[str, Any]] = [] - for r in rows or []: - item = dict(r) - day = perp_hedge_day_result(item, hedge) - item["perp_hedge_pnl"] = day["pnl"] - item["perp_hedge_exit"] = day["exit"] - item["perp_hedge_hit_a"] = day["hit_a"] - item["perp_hedge_hit_b"] = day["hit_b"] - work.append(item) - n = len(work) - empty = { - "enabled": True, - "entry": "open", - "exit": "target_or_eod", - "spot": None if spot_ref is None else round(spot_ref, 4), - "target_profit_u": round(hedge["target_profit_u"], 4), - "perp_leverage": round(hedge["perp_leverage"], 4), - "option_leverage": round(hedge["option_leverage"], 4), - "ratio_perp": round(hedge["ratio_perp"], 4), - "ratio_opt": round(hedge["ratio_opt"], 4), - "ratio_label": f"{hedge['ratio_perp']:g}:{hedge['ratio_opt']:g}", - "prem_per_coin": None if prem_per_coin_ref is None else round(prem_per_coin_ref, 4), - "opt_coins": round(hedge["opt_coins"], 4), - "opt_sheets": round(hedge["opt_sheets"], 4), - "premium_total": None if prem_ref is None else round(prem_ref, 4), - "move_a": None if move_a is None else round(move_a, 4), - "move_b": None if move_b is None else round(move_b, 4), - "points_error": points_err, - "sample_count": n, - "hit_a_days": 0, - "hit_a_ratio": None, - "hit_b_days": 0, - "hit_b_ratio": None, - "target_exit_days": 0, - "target_exit_ratio": None, - "eod_days": 0, - "pnl_total": None, - "pnl_avg": None, - "win_days": 0, - "win_ratio": None, - "pnl_max": None, - "pnl_min": None, - "up_days": 0, - "down_days": 0, - "up_pnl_total": None, - "down_pnl_total": None, - } - if n <= 0: - return empty - - hit_a = sum(1 for r in work if r.get("perp_hedge_hit_a")) - hit_b = sum(1 for r in work if r.get("perp_hedge_hit_b")) - target_exits = sum(1 for r in work if str(r.get("perp_hedge_exit") or "").startswith("target")) - eod_days = sum(1 for r in work if r.get("perp_hedge_exit") == "eod") - - pnls = [float(r["perp_hedge_pnl"]) for r in work if r.get("perp_hedge_pnl") is not None] - win = sum(1 for p in pnls if p > 0) - up_rows = [r for r in work if float(r.get("change") or 0) >= 0] - down_rows = [r for r in work if float(r.get("change") or 0) < 0] - up_pnls = [float(r["perp_hedge_pnl"]) for r in up_rows if r.get("perp_hedge_pnl") is not None] - down_pnls = [float(r["perp_hedge_pnl"]) for r in down_rows if r.get("perp_hedge_pnl") is not None] - - empty.update( - { - "hit_a_days": hit_a, - "hit_a_ratio": round(hit_a / n, 4), - "hit_b_days": hit_b, - "hit_b_ratio": round(hit_b / n, 4), - "target_exit_days": target_exits, - "target_exit_ratio": round(target_exits / n, 4), - "eod_days": eod_days, - "pnl_total": round(sum(pnls), 4) if pnls else None, - "pnl_avg": round(statistics.fmean(pnls), 4) if pnls else None, - "win_days": win, - "win_ratio": round(win / n, 4), - "pnl_max": round(max(pnls), 4) if pnls else None, - "pnl_min": round(min(pnls), 4) if pnls else None, - "up_days": len(up_rows), - "down_days": len(down_rows), - "up_pnl_total": round(sum(up_pnls), 4) if up_pnls else None, - "down_pnl_total": round(sum(down_pnls), 4) if down_pnls else None, - } - ) - return empty - - -def straddle_long_stats( - rows: list[dict[str, Any]], - premium: float, - *, - take_profit: Any = None, -) -> dict[str, Any]: - """买跨:越过权利金用严格 >;收益=有效波动−权利金(止盈≥触达用止盈点,否则|涨跌|).""" - prem = float(premium) - if prem <= 0: - raise ValueError("双边权利金须 > 0") - tp = normalize_take_profit(take_profit) - enriched = enrich_rows_pnl(rows, straddle_premium=prem, take_profit=tp) - if not enriched: - return { - "side": "long_straddle", - "premium": prem, - "take_profit": tp, - "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, - "tp_hit_days": 0, - "tp_hit_ratio": None, - "pnl_total": None, - "pnl_avg": None, - "win_days": 0, - "win_ratio": None, - "pnl_max": None, - "pnl_min": None, - } - n = len(enriched) - up_ex = sum(1 for r in enriched if float(r["up_points"]) > prem) - down_ex = sum(1 for r in enriched if float(r["down_points"]) > prem) - abs_ex = sum(1 for r in enriched if abs(float(r["change"])) > prem) - tp_hits = sum(1 for r in enriched if r.get("take_profit_hit")) - pnls = [float(r["profit"]) for r in enriched if r.get("profit") is not None] - win = sum(1 for p in pnls if p > 0) - return { - "side": "long_straddle", - "premium": round(prem, 4), - "take_profit": round(tp, 4) if tp is not None else None, - "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), - "tp_hit_days": tp_hits, - "tp_hit_ratio": round(tp_hits / n, 4) if tp is not None else None, - "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, - take_profit: Any = None, - perp_hedge: Any = None, -) -> dict[str, Any]: - hedge = normalize_perp_hedge_params(perp_hedge) - 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, - "perp_hedge": None, - } - prem = normalize_straddle_premium(straddle_premium) - if prem is not None: - out["straddle"] = straddle_long_stats([], prem, take_profit=take_profit) - if hedge is not None: - out["perp_hedge"] = perp_hedge_stats([], hedge) - 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, - "perp_hedge": None, - } - prem = normalize_straddle_premium(straddle_premium) - if prem is not None: - out["straddle"] = straddle_long_stats(rows, prem, take_profit=take_profit) - if hedge is not None: - out["perp_hedge"] = perp_hedge_stats(rows, hedge) - 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 _okx_get_json( - client: httpx.Client, - url: str, - params: dict[str, str], - *, - retries: int = 8, -) -> dict[str, Any]: - """GET OKX 公共行情;遇 429 指数退避重试.""" - last_err: Optional[BaseException] = None - for attempt in range(max(1, int(retries))): - try: - r = client.get(url, params=params) - if r.status_code == 429: - wait = min(12.0, 0.7 * (2**attempt)) - time.sleep(wait) - last_err = httpx.HTTPStatusError( - f"429 Too Many Requests for url '{r.url}'", - request=r.request, - response=r, - ) - continue - r.raise_for_status() - body = r.json() - if not isinstance(body, dict): - raise RuntimeError("OKX 返回非对象 JSON") - return body - except httpx.HTTPStatusError as exc: - status = exc.response.status_code if exc.response is not None else None - if status == 429 and attempt + 1 < retries: - wait = min(12.0, 0.7 * (2**attempt)) - time.sleep(wait) - last_err = exc - continue - raise - except httpx.TransportError as exc: - if attempt + 1 < retries: - time.sleep(min(8.0, 0.5 * (2**attempt))) - last_err = exc - continue - raise - if last_err is not None: - raise last_err - raise RuntimeError("OKX 请求失败") - - -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, - history_url: Optional[str] = None, - max_pages: int = 200, - page_pause_sec: float = 0.12, - history_page_pause_sec: float = 0.22, -) -> list[dict[str, Any]]: - """拉取 [since_ms, until_ms] 覆盖的 K 线(含边界). - - OKX 近期接口约仅 1440 根;更早需 history_* 端点续拉. - 分页带间隔,429 自动退避重试. - """ - 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 - active_url = url - switched_history = False - for page_i in range(max(20, int(max_pages))): - if page_i > 0: - pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec - if pause > 0: - time.sleep(pause) - params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"} - if after: - params["after"] = after - body = _okx_get_json(client, active_url, params) - 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: - # 近期接口到头 → 切历史端点再试 - if history_url and not switched_history and after is not None: - active_url = history_url - switched_history = True - time.sleep(max(history_page_pause_sec, 0.35)) - continue - 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 - # 无新进度时避免死循环 - if after is not None and str(oldest_ts) == after: - if history_url and not switched_history: - active_url = history_url - switched_history = True - time.sleep(max(history_page_pause_sec, 0.35)) - continue - break - after = str(oldest_ts) - # 近期接口返回变少且仍未覆盖 since → 切历史 - if ( - history_url - and not switched_history - and len(data) < 100 - and oldest_ts > since_ms - ): - active_url = history_url - switched_history = True - time.sleep(max(history_page_pause_sec, 0.35)) - 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"] - - index_err: Optional[BaseException] = None - try: - bars = fetch_okx_candles( - url=OKX_INDEX_CANDLES, - history_url=OKX_HISTORY_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 as exc: - index_err = exc - # 指数侧已触发限频时先冷却,再降级永续,避免连环 429 - time.sleep(1.2) - - try: - bars = fetch_okx_candles( - url=OKX_SWAP_CANDLES, - history_url=OKX_HISTORY_SWAP_CANDLES, - inst_id=meta["swap_inst"], - since_ms=since_ms, - until_ms=until_ms, - ) - except Exception as exc: - detail = f"index={index_err}; swap={exc}" if index_err else str(exc) - raise RuntimeError(f"OKX K线拉取失败({detail})") from exc - if not bars: - detail = f"index={index_err}" if index_err else "empty" - raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})") - 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, - take_profit: Any = None, - weekend_filter: Any = "all", - perp_hedge: 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) - tp = normalize_take_profit(take_profit) - we_mode = normalize_weekend_filter(weekend_filter) - hedge = normalize_perp_hedge_params(perp_hedge) - 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_all: 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_all.append(row) - return build_amp_result( - rows_all=rows_all, - symbol_key=key, - start_hour=sh, - period=period, - sample_days=sample_days, - straddle_premium=prem, - take_profit=tp, - weekend_filter=we_mode, - perp_hedge=hedge, - price_source=price_source, - inst_id=inst_id, - missing=missing, - ) - - -def build_amp_result( - *, - rows_all: list[dict[str, Any]], - symbol_key: str, - start_hour: int, - period: str, - sample_days: int, - straddle_premium: Any = None, - take_profit: Any = None, - weekend_filter: Any = "all", - perp_hedge: Any = None, - price_source: str = "", - inst_id: str = "", - missing: Optional[list[str]] = None, -) -> dict[str, Any]: - prem = normalize_straddle_premium(straddle_premium) - tp = normalize_take_profit(take_profit) - we_mode = normalize_weekend_filter(weekend_filter) - hedge = normalize_perp_hedge_params(perp_hedge) - filtered = filter_weekend_rows(rows_all, we_mode) - rows = enrich_rows_pnl(filtered, straddle_premium=prem, take_profit=tp, perp_hedge=hedge) - summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp, perp_hedge=hedge) - if period == "custom" or str(period).startswith("custom:"): - period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}" - else: - period_label = str(period) - miss = missing or [] - return { - "ok": True, - "exchange": EXCHANGE, - "symbol": symbol_key, - "symbol_label": SYMBOLS[symbol_key]["label"], - "start_hour": start_hour, - "end_hour": END_HOUR, - "period": period_label, - "sample_days_requested": sample_days, - "straddle_premium": prem, - "take_profit": tp, - "weekend_filter": we_mode, - "perp_hedge": hedge, - "timeframe": TIMEFRAME, - "price_source": price_source, - "inst_id": inst_id, - "timezone": "Asia/Shanghai", - "rows_all": rows_all, - "rows": rows, - "summary": summary, - "missing_days": miss[:30], - "missing_count": len(miss), - } - - -def reframe_amp_stats( - *, - rows_all: list[dict[str, Any]], - symbol: str = "eth", - start_hour: int = 16, - period: str = "2m", - sample_days: int = 60, - straddle_premium: Any = None, - take_profit: Any = None, - weekend_filter: Any = "all", - perp_hedge: Any = None, - price_source: str = "", - inst_id: str = "", - missing: Optional[list[str]] = None, -) -> dict[str, Any]: - """已有日表上改周末/权利金/止盈/永期参数,不拉 K 线.""" - key = normalize_symbol(symbol) - return build_amp_result( - rows_all=list(rows_all or []), - symbol_key=key, - start_hour=int(start_hour), - period=period, - sample_days=int(sample_days or 60), - straddle_premium=straddle_premium, - take_profit=take_profit, - weekend_filter=weekend_filter, - perp_hedge=perp_hedge, - price_source=price_source, - inst_id=inst_id, - missing=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(["周末筛选", payload.get("weekend_filter")]) - 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"), "止盈点", st.get("take_profit")]) - 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")]) - if st.get("take_profit") is not None: - w.writerow(["触达止盈天数", st.get("tp_hit_days"), "占比", st.get("tp_hit_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")]) - ph = s.get("perp_hedge") or {} - if ph: - w.writerow([]) - w.writerow( - [ - "【永期对冲对照】", - "比例", - ph.get("ratio_label"), - "入场", - "按日开盘", - "推点数开盘中位", - ph.get("spot"), - "目标", - ph.get("target_profit_u"), - ] - ) - w.writerow( - [ - "单币权利金(开盘中位)", - ph.get("prem_per_coin"), - "权利金总额(开盘中位)", - ph.get("premium_total"), - "期权币数", - ph.get("opt_coins"), - ] - ) - w.writerow( - [ - "A所需点数", - ph.get("move_a"), - "A达标天(开→高)", - ph.get("hit_a_days"), - "占比", - ph.get("hit_a_ratio"), - ] - ) - w.writerow( - [ - "B所需点数(组合)", - ph.get("move_b"), - "B达标天(开→低)", - ph.get("hit_b_days"), - "占比", - ph.get("hit_b_ratio"), - ] - ) - w.writerow( - [ - "目标出场天", - ph.get("target_exit_days"), - "收盘结算天", - ph.get("eod_days"), - "目标盈利", - ph.get("target_profit_u"), - ] - ) - w.writerow( - [ - "组合盈亏合计", - ph.get("pnl_total"), - "日均", - ph.get("pnl_avg"), - "胜率", - ph.get("win_ratio"), - ] - ) - w.writerow( - [ - "上涨日盈亏", - ph.get("up_pnl_total"), - "下跌日盈亏", - ph.get("down_pnl_total"), - "最大赚/亏", - f"{ph.get('pnl_max')} / {ph.get('pnl_min')}", - ] - ) - w.writerow([]) - w.writerow(["【日表明细】"]) - w.writerow( - [ - "结算日", - "星期", - "周末", - "窗起点", - "窗终点", - "开盘", - "最高", - "最低", - "收盘", - "开→高", - "开→低", - "振幅", - "涨跌值", - "有效波动", - "触达止盈", - "收益", - "永期盈亏", - ] - ) - for r in payload.get("rows") or []: - w.writerow( - [ - r.get("settlement_day"), - r.get("weekday_label") or "", - "是" if r.get("is_weekend") else "否", - 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"), - r.get("effective_move"), - "是" if r.get("take_profit_hit") else "否", - r.get("profit"), - r.get("perp_hedge_pnl"), - ] - ) - 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" +"""中控振幅统计:OKX 指数(可降级永续)按时段切窗,点数口径. + +仅只读行情;不触及下单链路. +""" +from __future__ import annotations + +import csv +import io +import statistics +import time +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_HISTORY_INDEX_CANDLES = "https://www.okx.com/api/v5/market/history-index-candles" +OKX_SWAP_CANDLES = "https://www.okx.com/api/v5/market/candles" +OKX_HISTORY_SWAP_CANDLES = "https://www.okx.com/api/v5/market/history-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 + wd = settlement.weekday() # Mon=0 … Sun=6 + is_we = wd >= 5 + return { + "settlement_day": settlement.isoformat(), + "window_start": start.strftime("%Y-%m-%d %H:%M"), + "window_end": end.strftime("%Y-%m-%d %H:%M"), + "weekday": wd, + "weekday_label": "六" if wd == 5 else ("日" if wd == 6 else ""), + "is_weekend": is_we, + "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_move_points(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 normalize_weekend_filter(raw: Any) -> str: + """all | exclude | only;默认全部.""" + s = (str(raw) if raw is not None else "all").strip().lower() + if s in ("", "all", "全部"): + return "all" + if s in ("exclude", "exclude_weekend", "no_weekend", "排除周末"): + return "exclude" + if s in ("only", "weekend_only", "only_weekend", "仅周末"): + return "only" + raise ValueError("周末筛选须为 all / exclude / only") + + +def filter_weekend_rows(rows: list[dict[str, Any]], weekend_filter: Any = "all") -> list[dict[str, Any]]: + mode = normalize_weekend_filter(weekend_filter) + if mode == "all": + return list(rows or []) + out: list[dict[str, Any]] = [] + for r in rows or []: + is_we = bool(r.get("is_weekend")) + if "is_weekend" not in r and r.get("settlement_day"): + try: + is_we = date.fromisoformat(str(r["settlement_day"])).weekday() >= 5 + except ValueError: + is_we = False + if mode == "exclude" and is_we: + continue + if mode == "only" and not is_we: + continue + out.append(r) + return out + + +def _ensure_weekend_flags(item: dict[str, Any]) -> None: + if "is_weekend" in item: + return + if not item.get("settlement_day"): + item.setdefault("weekday_label", "") + item.setdefault("is_weekend", False) + return + try: + wd = date.fromisoformat(str(item["settlement_day"])).weekday() + item["weekday"] = wd + item["weekday_label"] = "六" if wd == 5 else ("日" if wd == 6 else "") + item["is_weekend"] = wd >= 5 + except ValueError: + item.setdefault("weekday_label", "") + item.setdefault("is_weekend", False) + + +def enrich_rows( + rows: list[dict[str, Any]], + *, + move_points: Any = None, +) -> list[dict[str, Any]]: + """为日表附加周末标注,以及相对波动点数的两边达标.""" + mp = normalize_move_points(move_points) + out: list[dict[str, Any]] = [] + for r in rows or []: + item = dict(r) + _ensure_weekend_flags(item) + up = float(item.get("up_points") or 0) + down = float(item.get("down_points") or 0) + amp = float(item.get("amplitude") or 0) + hit_up = bool(mp is not None and up >= mp) + hit_down = bool(mp is not None and down >= mp) + amp_hit = bool(mp is not None and amp >= mp) + item["move_points"] = mp + item["hit_up"] = hit_up + item["hit_down"] = hit_down + item["hit_either"] = hit_up or hit_down + item["hit_both"] = hit_up and hit_down + item["amp_hit"] = amp_hit + out.append(item) + return out + + +# 兼容旧调用名 +def enrich_rows_pnl(rows: list[dict[str, Any]], **kwargs: Any) -> list[dict[str, Any]]: + return enrich_rows(rows, move_points=kwargs.get("move_points")) + + +def move_points_stats(rows: list[dict[str, Any]], move_points: float) -> dict[str, Any]: + """波动点数达标汇总:开→高/开→低两边.""" + mp = float(move_points) + if mp <= 0: + raise ValueError("波动点数须 > 0") + work = enrich_rows(rows, move_points=mp) + n = len(work) + empty = { + "move_points": round(mp, 4), + "sample_count": n, + "up_hit_days": 0, + "up_hit_ratio": None, + "down_hit_days": 0, + "down_hit_ratio": None, + "either_hit_days": 0, + "either_hit_ratio": None, + "both_hit_days": 0, + "both_hit_ratio": None, + "amp_hit_days": 0, + "amp_hit_ratio": None, + "abs_change_hit_days": 0, + "abs_change_hit_ratio": None, + } + if n <= 0: + return empty + up_hit = sum(1 for r in work if r.get("hit_up")) + down_hit = sum(1 for r in work if r.get("hit_down")) + either = sum(1 for r in work if r.get("hit_either")) + both = sum(1 for r in work if r.get("hit_both")) + amp_hit = sum(1 for r in work if float(r.get("amplitude") or 0) >= mp) + abs_hit = sum(1 for r in work if abs(float(r.get("change") or 0)) >= mp) + empty.update( + { + "up_hit_days": up_hit, + "up_hit_ratio": round(up_hit / n, 4), + "down_hit_days": down_hit, + "down_hit_ratio": round(down_hit / n, 4), + "either_hit_days": either, + "either_hit_ratio": round(either / n, 4), + "both_hit_days": both, + "both_hit_ratio": round(both / n, 4), + "amp_hit_days": amp_hit, + "amp_hit_ratio": round(amp_hit / n, 4), + "abs_change_hit_days": abs_hit, + "abs_change_hit_ratio": round(abs_hit / n, 4), + } + ) + return empty + + +def summarize_rows( + rows: list[dict[str, Any]], + *, + move_points: Any = None, +) -> dict[str, Any]: + mp = normalize_move_points(move_points) + 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, + "move_points_stats": None, + } + if mp is not None: + out["move_points_stats"] = move_points_stats([], mp) + 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), + "move_points_stats": None, + } + if mp is not None: + out["move_points_stats"] = move_points_stats(rows, mp) + 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 _okx_get_json( + client: httpx.Client, + url: str, + params: dict[str, str], + *, + retries: int = 8, +) -> dict[str, Any]: + """GET OKX 公共行情;遇 429 指数退避重试.""" + last_err: Optional[BaseException] = None + for attempt in range(max(1, int(retries))): + try: + r = client.get(url, params=params) + if r.status_code == 429: + wait = min(12.0, 0.7 * (2**attempt)) + time.sleep(wait) + last_err = httpx.HTTPStatusError( + f"429 Too Many Requests for url '{r.url}'", + request=r.request, + response=r, + ) + continue + r.raise_for_status() + body = r.json() + if not isinstance(body, dict): + raise RuntimeError("OKX 返回非对象 JSON") + return body + except httpx.HTTPStatusError as exc: + status = exc.response.status_code if exc.response is not None else None + if status == 429 and attempt + 1 < retries: + wait = min(12.0, 0.7 * (2**attempt)) + time.sleep(wait) + last_err = exc + continue + raise + except httpx.TransportError as exc: + if attempt + 1 < retries: + time.sleep(min(8.0, 0.5 * (2**attempt))) + last_err = exc + continue + raise + if last_err is not None: + raise last_err + raise RuntimeError("OKX 请求失败") + + +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, + history_url: Optional[str] = None, + max_pages: int = 200, + page_pause_sec: float = 0.12, + history_page_pause_sec: float = 0.22, +) -> list[dict[str, Any]]: + """拉取 [since_ms, until_ms] 覆盖的 K 线(含边界). + + OKX 近期接口约仅 1440 根;更早需 history_* 端点续拉. + 分页带间隔,429 自动退避重试. + """ + 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 + active_url = url + switched_history = False + for page_i in range(max(20, int(max_pages))): + if page_i > 0: + pause = history_page_pause_sec if switched_history or "history" in active_url else page_pause_sec + if pause > 0: + time.sleep(pause) + params: dict[str, str] = {"instId": inst_id, "bar": bar, "limit": "100"} + if after: + params["after"] = after + body = _okx_get_json(client, active_url, params) + 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: + # 近期接口到头 → 切历史端点再试 + if history_url and not switched_history and after is not None: + active_url = history_url + switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) + continue + 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 + # 无新进度时避免死循环 + if after is not None and str(oldest_ts) == after: + if history_url and not switched_history: + active_url = history_url + switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) + continue + break + after = str(oldest_ts) + # 近期接口返回变少且仍未覆盖 since → 切历史 + if ( + history_url + and not switched_history + and len(data) < 100 + and oldest_ts > since_ms + ): + active_url = history_url + switched_history = True + time.sleep(max(history_page_pause_sec, 0.35)) + 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"] + + index_err: Optional[BaseException] = None + try: + bars = fetch_okx_candles( + url=OKX_INDEX_CANDLES, + history_url=OKX_HISTORY_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 as exc: + index_err = exc + # 指数侧已触发限频时先冷却,再降级永续,避免连环 429 + time.sleep(1.2) + + try: + bars = fetch_okx_candles( + url=OKX_SWAP_CANDLES, + history_url=OKX_HISTORY_SWAP_CANDLES, + inst_id=meta["swap_inst"], + since_ms=since_ms, + until_ms=until_ms, + ) + except Exception as exc: + detail = f"index={index_err}; swap={exc}" if index_err else str(exc) + raise RuntimeError(f"OKX K线拉取失败({detail})") from exc + if not bars: + detail = f"index={index_err}" if index_err else "empty" + raise RuntimeError(f"OKX 指数与永续 K 线均无数据({detail})") + 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, + move_points: Any = None, + weekend_filter: Any = "all", + 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 整点") + mp = normalize_move_points(move_points) + we_mode = normalize_weekend_filter(weekend_filter) + 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_all: 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_all.append(row) + return build_amp_result( + rows_all=rows_all, + symbol_key=key, + start_hour=sh, + period=period, + sample_days=sample_days, + move_points=mp, + weekend_filter=we_mode, + price_source=price_source, + inst_id=inst_id, + missing=missing, + ) + + +def build_amp_result( + *, + rows_all: list[dict[str, Any]], + symbol_key: str, + start_hour: int, + period: str, + sample_days: int, + move_points: Any = None, + weekend_filter: Any = "all", + price_source: str = "", + inst_id: str = "", + missing: Optional[list[str]] = None, +) -> dict[str, Any]: + mp = normalize_move_points(move_points) + we_mode = normalize_weekend_filter(weekend_filter) + filtered = filter_weekend_rows(rows_all, we_mode) + rows = enrich_rows(filtered, move_points=mp) + summary = summarize_rows(rows, move_points=mp) + if period == "custom" or str(period).startswith("custom:"): + period_label = period if str(period).startswith("custom:") else f"custom:{sample_days}" + else: + period_label = str(period) + miss = missing or [] + return { + "ok": True, + "exchange": EXCHANGE, + "symbol": symbol_key, + "symbol_label": SYMBOLS[symbol_key]["label"], + "start_hour": start_hour, + "end_hour": END_HOUR, + "period": period_label, + "sample_days_requested": sample_days, + "move_points": mp, + "weekend_filter": we_mode, + "timeframe": TIMEFRAME, + "price_source": price_source, + "inst_id": inst_id, + "timezone": "Asia/Shanghai", + "rows_all": rows_all, + "rows": rows, + "summary": summary, + "missing_days": miss[:30], + "missing_count": len(miss), + } + + +def reframe_amp_stats( + *, + rows_all: list[dict[str, Any]], + symbol: str = "eth", + start_hour: int = 16, + period: str = "2m", + sample_days: int = 60, + move_points: Any = None, + weekend_filter: Any = "all", + price_source: str = "", + inst_id: str = "", + missing: Optional[list[str]] = None, +) -> dict[str, Any]: + """已有日表上改周末/波动点数,不拉 K 线.""" + key = normalize_symbol(symbol) + return build_amp_result( + rows_all=list(rows_all or []), + symbol_key=key, + start_hour=int(start_hour), + period=period, + sample_days=int(sample_days or 60), + move_points=move_points, + weekend_filter=weekend_filter, + price_source=price_source, + inst_id=inst_id, + missing=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(["周末筛选", payload.get("weekend_filter")]) + 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")]) + mp = s.get("move_points_stats") or {} + if mp: + w.writerow([]) + w.writerow(["【波动点数·振幅占比】", mp.get("move_points")]) + w.writerow(["振幅≥点数天数", mp.get("amp_hit_days"), "占比", mp.get("amp_hit_ratio")]) + w.writerow(["开→高≥点数天数", mp.get("up_hit_days"), "占比", mp.get("up_hit_ratio")]) + w.writerow(["开→低≥点数天数", mp.get("down_hit_days"), "占比", mp.get("down_hit_ratio")]) + w.writerow(["|涨跌|≥点数天数", mp.get("abs_change_hit_days"), "占比", mp.get("abs_change_hit_ratio")]) + w.writerow([]) + w.writerow(["【日表明细】"]) + w.writerow( + [ + "结算日", + "星期", + "周末", + "窗起点", + "窗终点", + "开盘", + "最高", + "最低", + "收盘", + "开→高", + "开→低", + "振幅", + "涨跌值", + "对照点数", + "振幅达标", + ] + ) + for r in payload.get("rows") or []: + w.writerow( + [ + r.get("settlement_day"), + r.get("weekday_label") or "", + "是" if r.get("is_weekend") else "否", + 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"), + r.get("move_points") if r.get("move_points") is not None else "", + "是" if r.get("amp_hit") else ("否" if r.get("move_points") is not None else ""), + ] + ) + 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" diff --git a/manual_trading_hub/amp_stats_routes.py b/manual_trading_hub/amp_stats_routes.py index 6f23b00..b3c2625 100644 --- a/manual_trading_hub/amp_stats_routes.py +++ b/manual_trading_hub/amp_stats_routes.py @@ -12,33 +12,20 @@ from lib.hub.amp_stats_lib import ( build_export_csv, compute_amp_stats, export_filename, - normalize_straddle_premium, - normalize_take_profit, + normalize_move_points, normalize_weekend_filter, reframe_amp_stats, rows_page, ) -class PerpHedgeBody(BaseModel): - spot: Optional[float] = None - target_profit_u: Optional[float] = None - perp_leverage: Optional[float] = None - option_leverage: Optional[float] = None - ratio_perp: float = 1.0 - ratio_opt: float = 2.0 - ct_mult: float = 0.01 - - class ComputeBody(BaseModel): symbol: str = "eth" start_hour: int = 16 period: str = "2m" custom_days: Optional[int] = None - straddle_premium: Optional[float] = None - take_profit: Optional[float] = None + move_points: Optional[float] = None weekend_filter: str = "all" - perp_hedge: Optional[PerpHedgeBody] = None page: int = 1 page_size: int = 20 @@ -48,29 +35,21 @@ class SaveBody(BaseModel): class ReframeBody(BaseModel): - """已有日表上改周末/权利金/止盈/永期参数(不拉 K 线).""" + """已有日表上改周末/波动点数(不拉 K 线).""" rows_all: list[dict[str, Any]] = Field(default_factory=list) symbol: str = "eth" start_hour: int = 16 period: str = "2m" sample_days: int = 60 - straddle_premium: Optional[float] = None - take_profit: Optional[float] = None + move_points: Optional[float] = None weekend_filter: str = "all" - perp_hedge: Optional[PerpHedgeBody] = None price_source: str = "" inst_id: str = "" page: int = 1 page_size: int = 20 -def _hedge_dict(body_hedge: Optional[PerpHedgeBody]) -> Optional[dict[str, Any]]: - if body_hedge is None: - return None - return body_hedge.model_dump() - - def create_amp_stats_router() -> APIRouter: router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"]) @@ -102,12 +81,7 @@ def create_amp_stats_router() -> APIRouter: "default_weekend_filter": "all", "timeframe": "1H", "metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)", - "straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金", - "perp_hedge_note": "永期对冲:永续多1币+买期权;入场按日开盘;比例默认1:2;与买跨二选一对照", - "overlay_modes": [ - {"key": "straddle", "label": "买跨双边"}, - {"key": "perp", "label": "永期对冲"}, - ], + "move_points_note": "填波动点数后统计振幅≥该点数的天数占比;日表显示开→高/开→低两边点数与振幅是否达标", } @router.post("/compute") @@ -118,10 +92,8 @@ def create_amp_stats_router() -> APIRouter: start_hour=body.start_hour, period=body.period, custom_days=body.custom_days, - straddle_premium=body.straddle_premium, - take_profit=body.take_profit, + move_points=body.move_points, weekend_filter=body.weekend_filter, - perp_hedge=_hedge_dict(body.perp_hedge), ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -146,10 +118,8 @@ def create_amp_stats_router() -> APIRouter: start_hour=body.start_hour, period=body.period, sample_days=body.sample_days, - straddle_premium=body.straddle_premium, - take_profit=body.take_profit, + move_points=body.move_points, weekend_filter=body.weekend_filter, - perp_hedge=_hedge_dict(body.perp_hedge), price_source=body.price_source, inst_id=body.inst_id, ) @@ -190,33 +160,15 @@ def create_amp_stats_router() -> APIRouter: start_hour: int = Query(default=16), period: str = Query(default="2m"), custom_days: Optional[int] = Query(default=None), - straddle_premium: Optional[float] = Query(default=None), - take_profit: Optional[float] = Query(default=None), + move_points: Optional[float] = Query(default=None), weekend_filter: str = Query(default="all"), - hedge_spot: Optional[float] = Query(default=None), - hedge_target: Optional[float] = Query(default=None), - hedge_perp_lev: Optional[float] = Query(default=None), - hedge_opt_lev: Optional[float] = Query(default=None), - hedge_ratio_perp: float = Query(default=1.0), - hedge_ratio_opt: float = Query(default=2.0), - hedge_ct_mult: float = Query(default=0.01), ): - hedge_q = { - "target_profit_u": hedge_target, - "perp_leverage": hedge_perp_lev, - "option_leverage": hedge_opt_lev, - "ratio_perp": hedge_ratio_perp, - "ratio_opt": hedge_ratio_opt, - "ct_mult": hedge_ct_mult, - } - hedge_q_ready = hedge_target is not None and hedge_perp_lev is not None and hedge_opt_lev is not None if (history_id or "").strip(): item = get_history(history_id.strip()) if not item: raise HTTPException(status_code=404, detail="历史不存在") rows_all = item.get("rows_all") or item.get("rows") or [] - item_hedge = item.get("perp_hedge") if isinstance(item.get("perp_hedge"), dict) else None - use_hedge = hedge_q if hedge_q_ready else item_hedge + use_mp = move_points if move_points is not None else item.get("move_points") try: payload = reframe_amp_stats( rows_all=rows_all, @@ -224,12 +176,8 @@ def create_amp_stats_router() -> APIRouter: start_hour=int(item.get("start_hour") if item.get("start_hour") is not None else start_hour), period=str(item.get("period") or period), sample_days=int(item.get("sample_days_requested") or 60), - straddle_premium=straddle_premium - if straddle_premium is not None - else item.get("straddle_premium"), - take_profit=take_profit if take_profit is not None else item.get("take_profit"), + move_points=use_mp, weekend_filter=weekend_filter or item.get("weekend_filter") or "all", - perp_hedge=use_hedge, price_source=str(item.get("price_source") or ""), inst_id=str(item.get("inst_id") or ""), missing=item.get("missing_days") or [], @@ -238,19 +186,15 @@ def create_amp_stats_router() -> APIRouter: raise HTTPException(status_code=400, detail=str(exc)) from exc else: try: - # validate enums early normalize_weekend_filter(weekend_filter) - normalize_straddle_premium(straddle_premium) - normalize_take_profit(take_profit) + normalize_move_points(move_points) payload = compute_amp_stats( symbol=symbol, start_hour=start_hour, period=period, custom_days=custom_days, - straddle_premium=straddle_premium, - take_profit=take_profit, + move_points=move_points, weekend_filter=weekend_filter, - perp_hedge=hedge_q if hedge_q_ready else None, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/manual_trading_hub/static/amp_stats.js b/manual_trading_hub/static/amp_stats.js index da90cab..4a670fa 100644 --- a/manual_trading_hub/static/amp_stats.js +++ b/manual_trading_hub/static/amp_stats.js @@ -1,5 +1,5 @@ /** - * 中控振幅统计:OKX ETH/BTC + 买跨/止盈/周末筛选. + * 中控振幅统计:OKX ETH/BTC + 波动点数振幅占比 + 周末筛选. */ (function () { const page = document.getElementById("page-amp-stats"); @@ -39,32 +39,8 @@ return (n * 100).toFixed(1) + "%"; } - function overlayMode() { - return el("amp-overlay-mode")?.value || "straddle"; - } - - function syncOverlayMode() { - const isPerp = overlayMode() === "perp"; - page.querySelectorAll(".amp-overlay-straddle").forEach((n) => n.classList.toggle("hidden", isPerp)); - page.querySelectorAll(".amp-overlay-perp").forEach((n) => n.classList.toggle("hidden", !isPerp)); - el("amp-overlay-straddle-block")?.classList.toggle("hidden", isPerp); - el("amp-overlay-perp-block")?.classList.toggle("hidden", !isPerp); - const col = el("amp-col-pnl"); - if (col) col.textContent = isPerp ? "永期盈亏" : "收益"; - } - - function readPremium() { - if (overlayMode() !== "straddle") return null; - const raw = (el("amp-straddle-premium")?.value || "").trim(); - if (!raw) return null; - const n = Number(raw); - if (!Number.isFinite(n) || n <= 0) return null; - return n; - } - - function readTakeProfit() { - if (overlayMode() !== "straddle") return null; - const raw = (el("amp-take-profit")?.value || "").trim(); + function readMovePoints() { + const raw = (el("amp-move-points")?.value || "").trim(); if (!raw) return null; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return null; @@ -75,30 +51,6 @@ return el("amp-weekend-filter")?.value || "all"; } - function readNum(id) { - const raw = (el(id)?.value || "").trim(); - if (!raw) return null; - const n = Number(raw); - return Number.isFinite(n) ? n : null; - } - - function readPerpHedge() { - if (overlayMode() !== "perp") return null; - const target = readNum("amp-hedge-target"); - const perpLev = readNum("amp-hedge-perp-lev"); - const optLev = readNum("amp-hedge-opt-lev"); - if (target == null || perpLev == null || optLev == null) return null; - if (target < 0 || perpLev <= 0 || optLev <= 0) return null; - return { - target_profit_u: target, - perp_leverage: perpLev, - option_leverage: optLev, - ratio_perp: readNum("amp-hedge-ratio-perp") || 1, - ratio_opt: readNum("amp-hedge-ratio-opt") || 2, - ct_mult: 0.01, - }; - } - function setStatus(msg) { const s = el("amp-status"); if (s) s.textContent = msg || ""; @@ -135,21 +87,13 @@ } } - function pnlClass(v) { - const n = Number(v); - if (!Number.isFinite(n) || n === 0) return ""; - return n > 0 ? "is-pos" : "is-neg"; - } - function renderSummary(summary, result) { const box = el("amp-summary"); if (!box) return; const s = summary || {}; - syncOverlayMode(); if (!s.sample_count) { box.innerHTML = '
暂无汇总
'; - renderStraddle(null); - renderPerpHedge(null); + renderMoveStats(null); return; } box.innerHTML = @@ -163,76 +107,23 @@ `填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏
'; + if (!ms) { + box.innerHTML = '填写「波动点数」后计算,可看振幅≥该点数的天数占比
'; return; } - const verdict = - st.pnl_total == null - ? "—" - : Number(st.pnl_total) > 0 - ? "样本合计盈利" - : Number(st.pnl_total) < 0 - ? "样本合计亏损" - : "样本合计持平"; - const tpLine = - st.take_profit != null - ? `填写「目标 / 杠杆」后计算;入场按日开盘;触达目标点数按目标盈利出场,否则收盘结算
'; - return; - } - const err = - ph.points_error - ? `口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.对照模式二选一:买跨收益=有效波动−权利金;永期对冲=永续多1币+买期权(默认1:2),入场按日开盘,触达目标点数按目标盈利出场否则收盘结算.周末按结算日标注/筛选.
+口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.填写波动点数后看振幅≥该点数的天数占比;日表显示两边波动(开→高/开→低)与振幅是否达标.周末按结算日标注/筛选.
| 结算日 | 窗起点 | 开 | 高 | 低 | 收 | -开→高 | 开→低 | 振幅 | 涨跌 | 收益 | +开→高 | 开→低 | 振幅 | 涨跌 | 振幅达标 |
|---|