Add perpetual-options hedge overlay to amp-stats with hit rates and daily PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-28 14:20:42 +08:00
parent 845884fc67
commit d049c5d317
7 changed files with 543 additions and 19 deletions
+38 -1
View File
@@ -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 | 永期对冲对照:所需点数达标 + 按日组合盈亏 |
+2
View File
@@ -165,3 +165,5 @@ move = (目标 + 权利金) / (期权币数 − 1)
## 不做
实盘开平仓、拉 OKX 期权链卖一、把本页结果自动写入对冲计划。
振幅统计页可对历史日表做同口径对照,见 [振幅统计说明.md](./振幅统计说明.md)「永期对冲对照」。
+278 -4
View File
@@ -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_coins1) 权利金
"""
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()
+42 -1
View File
@@ -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
+104 -9
View File
@@ -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 = '<p class="amp-empty">暂无汇总</p>';
renderStraddle(null);
renderPerpHedge(null);
return;
}
box.innerHTML =
@@ -122,6 +148,7 @@
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
`</div>`;
renderStraddle(s.straddle);
renderPerpHedge(s.perp_hedge);
}
function renderStraddle(st) {
@@ -157,6 +184,35 @@
`</div>`;
}
function renderPerpHedge(ph) {
const box = el("amp-perp-hedge");
if (!box) return;
if (!ph) {
box.innerHTML =
'<p class="amp-empty">填写「永期·现价 / 目标 / 杠杆」后计算;对照所需点数达标天数与组合盈亏(永续多1币+买期权)</p>';
return;
}
const err =
ph.points_error
? `<div><span class="amp-sum-k">推点数提示</span><span class="amp-sum-v">${esc(ph.points_error)}</span></div>`
: "";
box.innerHTML =
`<div class="amp-sum-grid">` +
`<div><span class="amp-sum-k">比例 / 期权仓</span><span class="amp-sum-v">${esc(ph.ratio_label)} · ${esc(ph.opt_coins)} 币</span></div>` +
`<div><span class="amp-sum-k">单币/总权利金</span><span class="amp-sum-v">${esc(ph.prem_per_coin)} / ${esc(ph.premium_total)}</span></div>` +
`<div><span class="amp-sum-k">A所需点数(永续对)</span><span class="amp-sum-v">${esc(ph.move_a)}</span></div>` +
`<div><span class="amp-sum-k">A达标</span><span class="amp-sum-v">${esc(ph.hit_a_days)} 天 · ${esc(pct(ph.hit_a_ratio))}</span></div>` +
`<div><span class="amp-sum-k">B所需点数(组合)</span><span class="amp-sum-v">${esc(ph.move_b)}</span></div>` +
`<div><span class="amp-sum-k">B达标</span><span class="amp-sum-v">${esc(ph.hit_b_days)} 天 · ${esc(pct(ph.hit_b_ratio))}</span></div>` +
`<div><span class="amp-sum-k">组合盈亏合计</span><span class="amp-sum-v ${pnlClass(ph.pnl_total)}">${esc(ph.pnl_total)}</span></div>` +
`<div><span class="amp-sum-k">日均 / 胜率</span><span class="amp-sum-v ${pnlClass(ph.pnl_avg)}">${esc(ph.pnl_avg)} · ${esc(pct(ph.win_ratio))}</span></div>` +
`<div><span class="amp-sum-k">上涨日盈亏</span><span class="amp-sum-v ${pnlClass(ph.up_pnl_total)}">${esc(ph.up_pnl_total)} <small>(${esc(ph.up_days)}天)</small></span></div>` +
`<div><span class="amp-sum-k">下跌日盈亏</span><span class="amp-sum-v ${pnlClass(ph.down_pnl_total)}">${esc(ph.down_pnl_total)} <small>(${esc(ph.down_days)}天)</small></span></div>` +
`<div><span class="amp-sum-k">单日最大赚/亏</span><span class="amp-sum-v">${esc(ph.pnl_max)} / ${esc(ph.pnl_min)}</span></div>` +
err +
`</div>`;
}
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 = '<tr><td colspan="11" class="amp-empty">暂无数据</td></tr>';
body.innerHTML = '<tr><td colspan="12" class="amp-empty">暂无数据</td></tr>';
} else {
body.innerHTML = rows
.map((r) => {
@@ -179,6 +235,10 @@
r.profit == null || r.profit === ""
? "—"
: `<span class="amp-pnl ${pnlClass(r.profit)}">${esc(r.profit)}</span>`;
const hedgePnl =
r.perp_hedge_pnl == null || r.perp_hedge_pnl === ""
? "—"
: `<span class="amp-pnl ${pnlClass(r.perp_hedge_pnl)}">${esc(r.perp_hedge_pnl)}</span>`;
const trClass = r.is_weekend ? ' class="amp-row-weekend"' : "";
return (
`<tr${trClass}>` +
@@ -193,6 +253,7 @@
`<td><strong>${esc(r.amplitude)}</strong></td>` +
`<td>${esc(r.change)}</td>` +
`<td>${profit}</td>` +
`<td>${hedgePnl}</td>` +
`</tr>`
);
})
@@ -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);
},
};
})();
+30 -4
View File
@@ -1272,6 +1272,30 @@
<span>止盈点(点)</span>
<input id="amp-take-profit" type="number" min="0" step="any" placeholder="空=按涨跌" />
</label>
<label class="amp-field">
<span>永期·现价</span>
<input id="amp-hedge-spot" type="number" min="0" step="any" placeholder="如 1800" />
</label>
<label class="amp-field">
<span>永期·目标盈利(U)</span>
<input id="amp-hedge-target" type="number" min="0" step="any" value="15" />
</label>
<label class="amp-field">
<span>永期·永续杠杆</span>
<input id="amp-hedge-perp-lev" type="number" min="0.01" step="any" value="10" />
</label>
<label class="amp-field">
<span>永期·期权杠杆</span>
<input id="amp-hedge-opt-lev" type="number" min="0.01" step="any" value="100" />
</label>
<label class="amp-field">
<span>永期·永续比例</span>
<input id="amp-hedge-ratio-perp" type="number" min="0.01" step="any" value="1" />
</label>
<label class="amp-field">
<span>永期·期权比例</span>
<input id="amp-hedge-ratio-opt" type="number" min="0.01" step="any" value="2" />
</label>
<div class="amp-actions">
<button type="button" id="amp-btn-compute" class="primary">计算</button>
<button type="button" id="amp-btn-save" class="ghost">保存到历史</button>
@@ -1279,22 +1303,24 @@
</div>
</div>
<p id="amp-status" class="toolbar-meta amp-status"></p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金;止盈≥触达则有效波动=止盈点,否则用|涨跌|.周末按结算日标注/筛选.</p>
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低.买跨收益=有效波动−权利金.永期对冲=永续多1币+买期权(默认1:2),对照所需点数达标与组合盈亏.周末按结算日标注/筛选.</p>
<h3 class="amp-block-title">汇总</h3>
<div id="amp-summary" class="amp-summary"></div>
<h3 class="amp-block-title">买跨对照</h3>
<div id="amp-straddle" class="amp-summary amp-straddle"></div>
<h3 class="amp-block-title">永期对冲对照</h3>
<div id="amp-perp-hedge" class="amp-summary amp-perp-hedge"></div>
<h3 class="amp-block-title">日表明细</h3>
<div class="amp-table-wrap">
<table class="amp-table">
<thead>
<tr>
<th>结算日</th><th>窗起点</th><th></th><th></th><th></th><th></th>
<th>开→高</th><th>开→低</th><th>振幅</th><th>涨跌</th><th>收益</th>
<th>开→高</th><th>开→低</th><th>振幅</th><th>涨跌</th><th>收益</th><th>永期盈亏</th>
</tr>
</thead>
<tbody id="amp-table-body">
<tr><td colspan="11" class="amp-empty">点击「计算」加载</td></tr>
<tr><td colspan="12" class="amp-empty">点击「计算」加载</td></tr>
</tbody>
</table>
</div>
@@ -1763,7 +1789,7 @@
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
<script src="/assets/dashboard.js?v=20260723-hide-pnl"></script>
<script src="/assets/strategy.js?v=11"></script>
<script src="/assets/amp_stats.js?v=5"></script>
<script src="/assets/amp_stats.js?v=20260728-hedge"></script>
<script src="/assets/help.js?v=1"></script>
<script src="/assets/logs.js?v=1"></script>
<script src="/assets/ai_review_render.js?v=3"></script>
+49
View File
@@ -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 = [