From d049c5d317e819dd1afecac7388b20421fccb242 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 28 Jul 2026 14:20:42 +0800 Subject: [PATCH] Add perpetual-options hedge overlay to amp-stats with hit rates and daily PnL. Co-authored-by: Cursor --- docs/振幅统计说明.md | 39 +++- docs/永期对冲计算器.md | 2 + lib/hub/amp_stats_lib.py | 282 ++++++++++++++++++++++++- manual_trading_hub/amp_stats_routes.py | 43 +++- manual_trading_hub/static/amp_stats.js | 113 +++++++++- manual_trading_hub/static/index.html | 34 ++- tests/test_amp_stats_lib.py | 49 +++++ 7 files changed, 543 insertions(+), 19 deletions(-) diff --git a/docs/振幅统计说明.md b/docs/振幅统计说明.md index 7a28989..2c17796 100644 --- a/docs/振幅统计说明.md +++ b/docs/振幅统计说明.md @@ -3,7 +3,8 @@ 中控只读工具:按自定义整点起点、**固定北京时间 16:00 收窗**,统计 OKX 上 ETH/BTC 的历史「点数振幅」档案,辅助一天期期权判断空间。 > 开发方案见 [ETH时段振幅统计-开发方案.md](./ETH时段振幅统计-开发方案.md)。 -> **不改下单链路**;不算 IV / 权利金。 +> 永期对冲公式见 [永期对冲计算器.md](./永期对冲计算器.md)。 +> **不改下单链路**;不算 IV。 --- @@ -81,6 +82,41 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / --- +## 永期对冲对照 + +与中控 [永期对冲计算器](./永期对冲计算器.md) 同口径:**永续做多 1 币 + 买期权**(默认比例 **1:2**),在历史振幅日表上做对照。 + +表单填:现价、目标盈利、永续杠杆、期权杠杆、比例(可改)。未填齐现价/目标/杠杆时不计算该块。 + +```text +单币权利金 = 现价 / 期权杠杆 +期权币数 = 1 × (期权比例 / 永续比例) +权利金总额 = 期权币数 × 单币权利金 +``` + +### ① 所需点数达标 + +复用计算器「由比例推点数」: + +| 指标 | 规则 | +|------|------| +| A 所需点数 | 永续方向对、净利=目标 | +| A 达标 | 日 `涨跌 ≥ A点数` 的天数与占比 | +| B 所需点数 | 期权方向对、**组合净利**=目标 | +| B 达标 | 日 `涨跌 ≤ −B点数` 的天数与占比 | + +### ② 按日组合盈亏 + +| 日向 | 组合净利 | +|------|----------| +| 上涨 `涨跌≥0` | `涨跌 − 权利金 − 永续开平手续费(开→收)` | +| 下跌 `涨跌<0` | `\|涨跌\|×(期权币数−1) − 权利金` | + +汇总:合计、日均、胜率、上涨日/下跌日盈亏小计、单日最大赚亏;日表 **永期盈亏** 列。 +改永期参数 / 周末筛选会**本地重算**(不重拉 K 线)。权利金按表单现价固定,不按日开盘重估。 + +--- + ## 历史 Tab - 仅 **保存到历史** 后出现(不会一算就自动入库) @@ -109,3 +145,4 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / | 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 | | 2026-07-23 | 周末筛选/标注、止盈点(≥)、日表收益列 | | 2026-07-23 | 长周期续拉 history K 线;收益列红绿着色 | +| 2026-07-28 | 永期对冲对照:所需点数达标 + 按日组合盈亏 | diff --git a/docs/永期对冲计算器.md b/docs/永期对冲计算器.md index 2e0b393..1de6630 100644 --- a/docs/永期对冲计算器.md +++ b/docs/永期对冲计算器.md @@ -165,3 +165,5 @@ move = (目标 + 权利金) / (期权币数 − 1) ## 不做 实盘开平仓、拉 OKX 期权链卖一、把本页结果自动写入对冲计划。 + +振幅统计页可对历史日表做同口径对照,见 [振幅统计说明.md](./振幅统计说明.md)「永期对冲对照」。 diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py index aecb68d..ed27dc0 100644 --- a/lib/hub/amp_stats_lib.py +++ b/lib/hub/amp_stats_lib.py @@ -249,10 +249,12 @@ def enrich_rows_pnl( *, 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) @@ -272,10 +274,200 @@ def enrich_rows_pnl( 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: + item["perp_hedge_pnl"] = perp_hedge_day_pnl( + change=float(item.get("change") or 0), + open_px=float(item.get("open") or 0), + close_px=float(item.get("close") or 0), + premium_total=float(hedge["premium_total"]), + opt_coins=float(hedge["opt_coins"]), + ) + else: + item["perp_hedge_pnl"] = None out.append(item) return out +def normalize_perp_hedge_params(raw: Any) -> Optional[dict[str, float]]: + """永期对冲对照参数.缺必填则返回 None(不做对照). + + 接受 dict 或带 spot/target_profit_u/perp_leverage/option_leverage 的对象字段. + """ + if raw is None or raw == "": + return None + if not isinstance(raw, dict): + return None + spot = _safe_float(raw.get("spot")) + 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 spot is None or target is None or p_lev is None or o_lev is None: + return None + if spot <= 0 or 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 + prem_per_coin = spot / o_lev + opt_coins = 1.0 * (ro / rp) + premium_total = opt_coins * prem_per_coin + return { + "spot": spot, + "target_profit_u": target, + "perp_leverage": p_lev, + "option_leverage": o_lev, + "ratio_perp": rp, + "ratio_opt": ro, + "ct_mult": ct, + "prem_per_coin": prem_per_coin, + "opt_coins": opt_coins, + "opt_sheets": opt_coins / ct, + "premium_total": premium_total, + } + + +def perp_hedge_day_pnl( + *, + change: float, + open_px: float, + close_px: float, + premium_total: float, + opt_coins: float, +) -> float: + """单日组合净利(永续多1币 + 买期权). + + 上涨: change − 权利金 − 永续开平手续费 + 下跌: |change|×(opt_coins−1) − 权利金 + """ + from lib.trade.trade_fee_lib import estimate_roundtrip_fee_usdt + + chg = float(change or 0) + prem = float(premium_total or 0) + 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) + # 下跌: 永续亏 chg(负), 期权内在 |chg|*coins + return round(abs(chg) * (coins - 1.0) - prem, 4) + + +def perp_hedge_stats( + rows: list[dict[str, Any]], + hedge: dict[str, float], +) -> dict[str, Any]: + """永期对冲:所需点数达标 + 按日组合盈亏汇总.""" + from lib.hub.hub_perp_options_calc_lib import calc_perp_options_points + + points_data, points_err = calc_perp_options_points( + base="ETH", + spot=hedge["spot"], + capital_usdt=max(hedge["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"], + ) + move_a = None + move_b = None + if points_data: + 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 + + # 使用已 enrich 的 perp_hedge_pnl;若无则当场补算 + work: list[dict[str, Any]] = [] + for r in rows or []: + item = dict(r) + if item.get("perp_hedge_pnl") is None: + item["perp_hedge_pnl"] = perp_hedge_day_pnl( + change=float(item.get("change") or 0), + open_px=float(item.get("open") or 0), + close_px=float(item.get("close") or 0), + premium_total=float(hedge["premium_total"]), + opt_coins=float(hedge["opt_coins"]), + ) + work.append(item) + n = len(work) + empty = { + "enabled": True, + "spot": round(hedge["spot"], 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": round(hedge["prem_per_coin"], 4), + "opt_coins": round(hedge["opt_coins"], 4), + "opt_sheets": round(hedge["opt_sheets"], 4), + "premium_total": round(hedge["premium_total"], 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, + "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 = 0 + hit_b = 0 + if move_a is not None and move_a > 0: + hit_a = sum(1 for r in work if float(r.get("change") or 0) >= move_a) + if move_b is not None and move_b > 0: + hit_b = sum(1 for r in work if float(r.get("change") or 0) <= -move_b) + + 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) if move_a else None, + "hit_b_days": hit_b, + "hit_b_ratio": round(hit_b / n, 4) if move_b else None, + "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, @@ -343,7 +535,9 @@ def summarize_rows( *, 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, @@ -358,10 +552,13 @@ def summarize_rows( "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] @@ -384,10 +581,13 @@ def summarize_rows( "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 @@ -594,6 +794,7 @@ def compute_amp_stats( 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]: @@ -604,6 +805,7 @@ def compute_amp_stats( 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: @@ -636,6 +838,7 @@ def compute_amp_stats( straddle_premium=prem, take_profit=tp, weekend_filter=we_mode, + perp_hedge=hedge, price_source=price_source, inst_id=inst_id, missing=missing, @@ -652,6 +855,7 @@ def build_amp_result( 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, @@ -659,9 +863,10 @@ def build_amp_result( 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) - summary = summarize_rows(rows, straddle_premium=prem, take_profit=tp) + 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: @@ -679,6 +884,7 @@ def build_amp_result( "straddle_premium": prem, "take_profit": tp, "weekend_filter": we_mode, + "perp_hedge": hedge, "timeframe": TIMEFRAME, "price_source": price_source, "inst_id": inst_id, @@ -701,11 +907,12 @@ def reframe_amp_stats( 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 线.""" + """已有日表上改周末/权利金/止盈/永期参数,不拉 K 线.""" key = normalize_symbol(symbol) return build_amp_result( rows_all=list(rows_all or []), @@ -716,6 +923,7 @@ def reframe_amp_stats( 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, @@ -779,6 +987,70 @@ def build_export_csv(payload: dict[str, Any]) -> str: ] ) 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("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( @@ -799,6 +1071,7 @@ def build_export_csv(payload: dict[str, Any]) -> str: "有效波动", "触达止盈", "收益", + "永期盈亏", ] ) for r in payload.get("rows") or []: @@ -820,6 +1093,7 @@ def build_export_csv(payload: dict[str, Any]) -> str: r.get("effective_move"), "是" if r.get("take_profit_hit") else "否", r.get("profit"), + r.get("perp_hedge_pnl"), ] ) return buf.getvalue() diff --git a/manual_trading_hub/amp_stats_routes.py b/manual_trading_hub/amp_stats_routes.py index c20f4f2..04007ed 100644 --- a/manual_trading_hub/amp_stats_routes.py +++ b/manual_trading_hub/amp_stats_routes.py @@ -20,6 +20,16 @@ from lib.hub.amp_stats_lib import ( ) +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 @@ -28,6 +38,7 @@ class ComputeBody(BaseModel): straddle_premium: Optional[float] = None take_profit: Optional[float] = None weekend_filter: str = "all" + perp_hedge: Optional[PerpHedgeBody] = None page: int = 1 page_size: int = 20 @@ -37,7 +48,7 @@ class SaveBody(BaseModel): class ReframeBody(BaseModel): - """已有日表上改周末/权利金/止盈(不拉 K 线).""" + """已有日表上改周末/权利金/止盈/永期参数(不拉 K 线).""" rows_all: list[dict[str, Any]] = Field(default_factory=list) symbol: str = "eth" @@ -47,12 +58,19 @@ class ReframeBody(BaseModel): straddle_premium: Optional[float] = None take_profit: 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"]) @@ -85,6 +103,7 @@ def create_amp_stats_router() -> APIRouter: "timeframe": "1H", "metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)", "straddle_note": "买跨:越过权利金用>;止盈≥触达用止盈点否则|涨跌|;收益=有效波动-权利金", + "perp_hedge_note": "永期对冲:永续多1币+买期权;比例默认1:2;达标与组合盈亏见文档", } @router.post("/compute") @@ -98,6 +117,7 @@ def create_amp_stats_router() -> APIRouter: straddle_premium=body.straddle_premium, take_profit=body.take_profit, 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 @@ -125,6 +145,7 @@ def create_amp_stats_router() -> APIRouter: straddle_premium=body.straddle_premium, take_profit=body.take_profit, weekend_filter=body.weekend_filter, + perp_hedge=_hedge_dict(body.perp_hedge), price_source=body.price_source, inst_id=body.inst_id, ) @@ -168,12 +189,30 @@ def create_amp_stats_router() -> APIRouter: straddle_premium: Optional[float] = Query(default=None), take_profit: 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 = { + "spot": hedge_spot, + "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, + } 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_spot is not None else item_hedge try: payload = reframe_amp_stats( rows_all=rows_all, @@ -186,6 +225,7 @@ def create_amp_stats_router() -> APIRouter: else item.get("straddle_premium"), take_profit=take_profit if take_profit is not None else item.get("take_profit"), 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 [], @@ -206,6 +246,7 @@ def create_amp_stats_router() -> APIRouter: straddle_premium=straddle_premium, take_profit=take_profit, weekend_filter=weekend_filter, + perp_hedge=hedge_q, ) 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 e071ba6..4ba6f79 100644 --- a/manual_trading_hub/static/amp_stats.js +++ b/manual_trading_hub/static/amp_stats.js @@ -59,6 +59,31 @@ 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() { + const spot = readNum("amp-hedge-spot"); + const target = readNum("amp-hedge-target"); + const perpLev = readNum("amp-hedge-perp-lev"); + const optLev = readNum("amp-hedge-opt-lev"); + if (spot == null || target == null || perpLev == null || optLev == null) return null; + if (spot <= 0 || target < 0 || perpLev <= 0 || optLev <= 0) return null; + return { + spot, + 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 || ""; @@ -108,6 +133,7 @@ if (!s.sample_count) { box.innerHTML = '

暂无汇总

'; renderStraddle(null); + renderPerpHedge(null); return; } box.innerHTML = @@ -122,6 +148,7 @@ `
价源${esc(result && result.price_source)}
` + ``; renderStraddle(s.straddle); + renderPerpHedge(s.perp_hedge); } function renderStraddle(st) { @@ -157,6 +184,35 @@ ``; } + function renderPerpHedge(ph) { + const box = el("amp-perp-hedge"); + if (!box) return; + if (!ph) { + box.innerHTML = + '

填写「永期·现价 / 目标 / 杠杆」后计算;对照所需点数达标天数与组合盈亏(永续多1币+买期权)

'; + return; + } + const err = + ph.points_error + ? `
推点数提示${esc(ph.points_error)}
` + : ""; + box.innerHTML = + `
` + + `
比例 / 期权仓${esc(ph.ratio_label)} · ${esc(ph.opt_coins)} 币
` + + `
单币/总权利金${esc(ph.prem_per_coin)} / ${esc(ph.premium_total)}
` + + `
A所需点数(永续对)${esc(ph.move_a)}
` + + `
A达标${esc(ph.hit_a_days)} 天 · ${esc(pct(ph.hit_a_ratio))}
` + + `
B所需点数(组合)${esc(ph.move_b)}
` + + `
B达标${esc(ph.hit_b_days)} 天 · ${esc(pct(ph.hit_b_ratio))}
` + + `
组合盈亏合计${esc(ph.pnl_total)}
` + + `
日均 / 胜率${esc(ph.pnl_avg)} · ${esc(pct(ph.win_ratio))}
` + + `
上涨日盈亏${esc(ph.up_pnl_total)} (${esc(ph.up_days)}天)
` + + `
下跌日盈亏${esc(ph.down_pnl_total)} (${esc(ph.down_days)}天)
` + + `
单日最大赚/亏${esc(ph.pnl_max)} / ${esc(ph.pnl_min)}
` + + err + + `
`; + } + function dayLabel(r) { const day = esc(r.settlement_day); if (r.is_weekend && r.weekday_label) { @@ -171,7 +227,7 @@ if (!body) return; const rows = (pagePayload && pagePayload.rows) || []; if (!rows.length) { - body.innerHTML = '暂无数据'; + body.innerHTML = '暂无数据'; } else { body.innerHTML = rows .map((r) => { @@ -179,6 +235,10 @@ r.profit == null || r.profit === "" ? "—" : `${esc(r.profit)}`; + const hedgePnl = + r.perp_hedge_pnl == null || r.perp_hedge_pnl === "" + ? "—" + : `${esc(r.perp_hedge_pnl)}`; const trClass = r.is_weekend ? ' class="amp-row-weekend"' : ""; return ( `` + @@ -193,6 +253,7 @@ `${esc(r.amplitude)}` + `${esc(r.change)}` + `${profit}` + + `${hedgePnl}` + `` ); }) @@ -227,6 +288,7 @@ async function reframe(resetPage) { if (!lastResult) { renderStraddle(null); + renderPerpHedge(null); return; } if (resetPage) pageNo = 1; @@ -245,6 +307,7 @@ straddle_premium: readPremium(), take_profit: readTakeProfit(), weekend_filter: readWeekend(), + perp_hedge: readPerpHedge(), price_source: lastResult.price_source || "", inst_id: lastResult.inst_id || "", page: pageNo, @@ -287,6 +350,7 @@ straddle_premium: readPremium(), take_profit: readTakeProfit(), weekend_filter: readWeekend(), + perp_hedge: readPerpHedge(), page: pageNo, page_size: 20, }), @@ -322,6 +386,18 @@ } } + function appendHedgeQuery(q) { + const h = readPerpHedge(); + if (!h) return; + q.set("hedge_spot", String(h.spot)); + q.set("hedge_target", String(h.target_profit_u)); + q.set("hedge_perp_lev", String(h.perp_leverage)); + q.set("hedge_opt_lev", String(h.option_leverage)); + q.set("hedge_ratio_perp", String(h.ratio_perp)); + q.set("hedge_ratio_opt", String(h.ratio_opt)); + q.set("hedge_ct_mult", String(h.ct_mult || 0.01)); + } + function downloadCurrent() { if (!lastResult) { setStatus("请先计算"); @@ -342,6 +418,7 @@ if (period === "custom") q.set("custom_days", String(customDays)); if (prem != null) q.set("straddle_premium", String(prem)); if (tp != null) q.set("take_profit", String(tp)); + appendHedgeQuery(q); window.location.href = "/api/amp-stats/export?" + q.toString(); } @@ -377,14 +454,14 @@ card.querySelector(".amp-hist-dl")?.addEventListener("click", () => { const prem = readPremium(); const tp = readTakeProfit(); - let url = - "/api/amp-stats/export?history_id=" + - encodeURIComponent(id) + - "&weekend_filter=" + - encodeURIComponent(readWeekend()); - if (prem != null) url += "&straddle_premium=" + encodeURIComponent(String(prem)); - if (tp != null) url += "&take_profit=" + encodeURIComponent(String(tp)); - window.location.href = url; + const q = new URLSearchParams({ + history_id: id, + weekend_filter: readWeekend(), + }); + if (prem != null) q.set("straddle_premium", String(prem)); + if (tp != null) q.set("take_profit", String(tp)); + appendHedgeQuery(q); + window.location.href = "/api/amp-stats/export?" + q.toString(); }); card.querySelector(".amp-hist-del")?.addEventListener("click", async () => { if (!confirm("删除该历史记录?")) return; @@ -414,6 +491,15 @@ if (lastResult.weekend_filter && el("amp-weekend-filter")) { el("amp-weekend-filter").value = lastResult.weekend_filter; } + const h = lastResult.perp_hedge; + if (h && typeof h === "object") { + if (h.spot != null && el("amp-hedge-spot")) el("amp-hedge-spot").value = String(h.spot); + if (h.target_profit_u != null && el("amp-hedge-target")) el("amp-hedge-target").value = String(h.target_profit_u); + if (h.perp_leverage != null && el("amp-hedge-perp-lev")) el("amp-hedge-perp-lev").value = String(h.perp_leverage); + if (h.option_leverage != null && el("amp-hedge-opt-lev")) el("amp-hedge-opt-lev").value = String(h.option_leverage); + if (h.ratio_perp != null && el("amp-hedge-ratio-perp")) el("amp-hedge-ratio-perp").value = String(h.ratio_perp); + if (h.ratio_opt != null && el("amp-hedge-ratio-opt")) el("amp-hedge-ratio-opt").value = String(h.ratio_opt); + } pageNo = 1; setStatus("已载入历史 " + id); await reframe(true); @@ -436,6 +522,14 @@ el("amp-btn-download")?.addEventListener("click", downloadCurrent); el("amp-straddle-premium")?.addEventListener("input", scheduleReframe); el("amp-take-profit")?.addEventListener("input", scheduleReframe); + [ + "amp-hedge-spot", + "amp-hedge-target", + "amp-hedge-perp-lev", + "amp-hedge-opt-lev", + "amp-hedge-ratio-perp", + "amp-hedge-ratio-opt", + ].forEach((id) => el(id)?.addEventListener("input", scheduleReframe)); el("amp-weekend-filter")?.addEventListener("change", () => void reframe(true)); syncCustomDays(); } @@ -446,6 +540,7 @@ setView("stats"); setStatus(""); renderStraddle(null); + renderPerpHedge(null); }, }; })(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 5b1ada0..f898847 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -1272,6 +1272,30 @@ 止盈点(点) + + + + + +
@@ -1279,22 +1303,24 @@

-

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金;止盈≥触达则有效波动=止盈点,否则用|涨跌|.周末按结算日标注/筛选.

+

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金.永期对冲=永续多1币+买期权(默认1:2),对照所需点数达标与组合盈亏.周末按结算日标注/筛选.

汇总

买跨对照

+

永期对冲对照

+

日表明细

- + - +
结算日窗起点开→高开→低振幅涨跌收益开→高开→低振幅涨跌收益永期盈亏
点击「计算」加载
点击「计算」加载
@@ -1763,7 +1789,7 @@ - + diff --git a/tests/test_amp_stats_lib.py b/tests/test_amp_stats_lib.py index 8d7b38d..68aeeb2 100644 --- a/tests/test_amp_stats_lib.py +++ b/tests/test_amp_stats_lib.py @@ -90,6 +90,55 @@ class AmpStatsLibTests(unittest.TestCase): self.assertEqual(s["max_up_points"], 500) self.assertEqual(s["max_down_points"], 200) self.assertIsNone(s["straddle"]) + self.assertIsNone(s["perp_hedge"]) + + def test_perp_hedge_hit_and_pnl(self): + from lib.hub.amp_stats_lib import perp_hedge_day_pnl + + # spot=1800 optLev=100 → prem/coin=18; 1:2 → premium=36 + # A move ≈ 52.83; B portfolio move = 51 + rows = [ + {"open": 1800, "close": 1860, "change": 60, "up_points": 60, "down_points": 0, "amplitude": 60, "settlement_day": "2026-07-01"}, + {"open": 1800, "close": 1740, "change": -60, "up_points": 0, "down_points": 60, "amplitude": 60, "settlement_day": "2026-07-02"}, + {"open": 1800, "close": 1820, "change": 20, "up_points": 20, "down_points": 0, "amplitude": 20, "settlement_day": "2026-07-03"}, + ] + hedge = { + "spot": 1800, + "target_profit_u": 15, + "perp_leverage": 10, + "option_leverage": 100, + "ratio_perp": 1, + "ratio_opt": 2, + "ct_mult": 0.01, + } + s = summarize_rows(rows, perp_hedge=hedge) + ph = s["perp_hedge"] + self.assertIsNotNone(ph) + self.assertEqual(ph["opt_coins"], 2.0) + self.assertEqual(ph["premium_total"], 36.0) + self.assertAlmostEqual(ph["move_b"], 51.0, places=4) + self.assertEqual(ph["hit_a_days"], 1) # only +60 + self.assertEqual(ph["hit_b_days"], 1) # only -60 + # up day pnl = 60 - 36 - fee + up_pnl = perp_hedge_day_pnl(change=60, open_px=1800, close_px=1860, premium_total=36, opt_coins=2) + down_pnl = perp_hedge_day_pnl(change=-60, open_px=1800, close_px=1740, premium_total=36, opt_coins=2) + self.assertAlmostEqual(down_pnl, 60 * (2 - 1) - 36, places=4) # 24 + self.assertAlmostEqual(ph["down_pnl_total"], down_pnl, places=4) + self.assertGreater(up_pnl, 0) + self.assertEqual(ph["up_days"], 2) + self.assertEqual(ph["down_days"], 1) + csv_text = build_export_csv( + { + "exchange": "okx", + "symbol_label": "ETH", + "summary": s, + "rows": rows, + "start_hour": 16, + "end_hour": 16, + } + ) + self.assertIn("永期对冲对照", csv_text) + self.assertIn("永期盈亏", csv_text) def test_long_straddle_stats(self): rows = [