Add hub strategy compare page for perp vs options vs 7:3 hedge.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
# 策略对比说明
|
||||
|
||||
中控独立页 **策略对比**(`/compare`):在同一风险额 `R` 下,对比三种工具的止盈能力与止损/踏空路径。
|
||||
|
||||
## 用途
|
||||
|
||||
回答两件事:
|
||||
|
||||
1. **盈利时谁更厉害**:干净止盈路径下各赚多少 U
|
||||
2. **谁更易亏 / 更易踏空**:合约止损后踏空;期权/对冲最坏亏满权利金,但踏空路径下常仍可持有到目标
|
||||
|
||||
不是精确概率模型。到期「小盈/小亏」与 4 点收盘相关,**未纳入主表与推荐**。
|
||||
|
||||
## 入口
|
||||
|
||||
- 顶栏「策略对比」;设置 → 显示与导航可隐藏(`show_nav_compare`)
|
||||
- API:`POST /api/compare/calc`(页面即时调用,价格均为手填)
|
||||
|
||||
## 输入
|
||||
|
||||
| 区块 | 字段 |
|
||||
|------|------|
|
||||
| 公共 | 标的 ETH/BTC、方向、入场价、风险 R、统一止损、止盈 |
|
||||
| 单期权 | Call/Put、行权价、卖一(每币)、可选目标价 |
|
||||
| 期期 | 主腿/次腿 各自行权与卖一;预算固定 **7:3** |
|
||||
|
||||
卖一口径与对冲计划一致:`单张成本 = 卖一 × ct_mult`(默认 `ct_mult=0.01`)。
|
||||
|
||||
## 仓位
|
||||
|
||||
- **合约**:`张数 = floor(R / (|入场−止损| × 面值))`,默认面值 0.01
|
||||
- **单期权**:`张数 = floor(R / 单张成本)`
|
||||
- **期期**:主预算 `0.7R`、次预算 `0.3R`,各自 `floor(预算/单张成本)`
|
||||
|
||||
## 主情景(A/B/C)
|
||||
|
||||
| 路径 | 合约 | 单期权 / 期期 |
|
||||
|------|------|----------------|
|
||||
| A 干净止盈 | 入场→止盈盈亏 | 目标价内在价值 − 已付权利金(近似) |
|
||||
| B 打止损 | −实际止损额(≈R) | 止损价处内在−权利金;并注最坏 −权利金 |
|
||||
| C 先止损再去止盈 | **本单仍为止损亏损**;旁注踏空未拿到的原止盈空间 | **仍持有**至目标价,结果同 A(抗踏空对照) |
|
||||
|
||||
期权止盈按**内在价值近似**,不是盘口卖出价。
|
||||
|
||||
## 推荐规则(可解释)
|
||||
|
||||
1. 比较三者 A / R
|
||||
2. 若合约止盈明显高于另两者(≥1.15×)→ 倾向合约,并提示踏空
|
||||
3. 否则若存在踏空对照(合约亏、期权类 C 仍为正)→ 倾向单期权或期期(期期与单腿接近时优先期期)
|
||||
4. 平局:抗踏空优先期权类,赔付碾压则合约
|
||||
|
||||
## 手测示例
|
||||
|
||||
`ETH` 做多,入场 3500,止损 3400,止盈 3700,R=10;单 Call 行权 3600 卖一 50;对冲主 Call 3600/50、次 Put 3400/30:
|
||||
|
||||
- 合约约 10 张,止损 −10U,止盈约 +20U,踏空未拿到约 +20U
|
||||
- 单期权约 20 张,权利金 10U,止盈约 +10U,最坏 −10U
|
||||
- 期期主 14 / 次 10 张
|
||||
|
||||
## 不做
|
||||
|
||||
实盘下单、拉交易所卖一(二期可选)、历史回测入库。
|
||||
@@ -0,0 +1,400 @@
|
||||
"""中控策略对比:同风险额下 合约 / 单期权 / 期期7:3 情景测算(纯函数)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _f(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def default_contract_size(base: str) -> float:
|
||||
"""OKX 线性永续常用面值(币/张);与计算器缺省一致."""
|
||||
b = (base or "ETH").strip().upper()
|
||||
return 0.01
|
||||
|
||||
|
||||
def default_ct_mult(base: str) -> float:
|
||||
return 0.01
|
||||
|
||||
|
||||
def floor_sheets(n: float, step: float = 1.0) -> float:
|
||||
if n is None or not math.isfinite(n) or n <= 0:
|
||||
return 0.0
|
||||
s = float(step) if step and step > 0 else 1.0
|
||||
return math.floor(n / s + 1e-12) * s
|
||||
|
||||
|
||||
def option_unit_cost(*, ask: float, ct_mult: float) -> float:
|
||||
return float(ask) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_intrinsic_value(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
) -> float:
|
||||
o = (opt_type or "").strip().upper()
|
||||
k = float(strike)
|
||||
s = float(spot)
|
||||
if o == "C":
|
||||
intrinsic = max(0.0, s - k)
|
||||
elif o == "P":
|
||||
intrinsic = max(0.0, k - s)
|
||||
else:
|
||||
intrinsic = 0.0
|
||||
return intrinsic * float(sheets) * float(ct_mult or 0.01)
|
||||
|
||||
|
||||
def option_pnl_at_spot(
|
||||
*,
|
||||
opt_type: str,
|
||||
strike: float,
|
||||
spot: float,
|
||||
sheets: float,
|
||||
ct_mult: float,
|
||||
premium_paid: float,
|
||||
) -> float:
|
||||
return option_intrinsic_value(
|
||||
opt_type=opt_type,
|
||||
strike=strike,
|
||||
spot=spot,
|
||||
sheets=sheets,
|
||||
ct_mult=ct_mult,
|
||||
) - float(premium_paid)
|
||||
|
||||
|
||||
def perp_pnl(
|
||||
*,
|
||||
direction: str,
|
||||
entry: float,
|
||||
exit_px: float,
|
||||
contracts: float,
|
||||
contract_size: float,
|
||||
) -> float:
|
||||
coins = float(contracts) * float(contract_size or 0.01)
|
||||
d = (direction or "long").strip().lower()
|
||||
if d == "short":
|
||||
return (float(entry) - float(exit_px)) * coins
|
||||
return (float(exit_px) - float(entry)) * coins
|
||||
|
||||
|
||||
def _validate_common(inp: dict[str, Any]) -> Optional[str]:
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
if base not in ("ETH", "BTC"):
|
||||
return "标的仅支持 ETH / BTC"
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
if direction not in ("long", "short"):
|
||||
return "方向须为 long / short"
|
||||
s0 = _f(inp.get("entry"))
|
||||
sl = _f(inp.get("sl"))
|
||||
tp = _f(inp.get("tp"))
|
||||
risk = _f(inp.get("risk_u"))
|
||||
if s0 is None or s0 <= 0:
|
||||
return "请填写有效入场价"
|
||||
if sl is None or sl <= 0:
|
||||
return "请填写有效止损价"
|
||||
if tp is None or tp <= 0:
|
||||
return "请填写有效止盈价"
|
||||
if risk is None or risk <= 0:
|
||||
return "请填写有效风险额 R"
|
||||
if direction == "long" and not (sl < s0 < tp):
|
||||
return "做多须满足 止损 < 入场 < 止盈"
|
||||
if direction == "short" and not (tp < s0 < sl):
|
||||
return "做空须满足 止盈 < 入场 < 止损"
|
||||
return None
|
||||
|
||||
|
||||
def _calc_perp(inp: dict[str, Any], *, contract_size: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
s0 = float(inp["entry"])
|
||||
sl = float(inp["sl"])
|
||||
tp = float(inp["tp"])
|
||||
risk = float(inp["risk_u"])
|
||||
per_sheet_sl = abs(s0 - sl) * contract_size
|
||||
sheets = floor_sheets(risk / per_sheet_sl) if per_sheet_sl > 0 else 0.0
|
||||
actual_sl_loss = abs(perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=sl, contracts=sheets, contract_size=contract_size
|
||||
))
|
||||
tp_pnl = perp_pnl(
|
||||
direction=direction, entry=s0, exit_px=tp, contracts=sheets, contract_size=contract_size
|
||||
)
|
||||
# 路径 C:本单已止损 −actual;踏空未拿到 = 原止盈盈利
|
||||
path_a = round(tp_pnl, 4)
|
||||
path_b = round(-actual_sl_loss if sheets > 0 else -risk, 4)
|
||||
path_c_realized = path_b
|
||||
path_c_missed = path_a
|
||||
return {
|
||||
"kind": "perp",
|
||||
"sheets": sheets,
|
||||
"contract_size": contract_size,
|
||||
"per_sheet_sl_u": round(per_sheet_sl, 6),
|
||||
"risk_used_u": round(actual_sl_loss, 4),
|
||||
"path_a_tp": path_a,
|
||||
"path_b_sl": path_b,
|
||||
"path_c_realized": path_c_realized,
|
||||
"path_c_missed": path_c_missed,
|
||||
"path_c_note": "本单已止损;踏空未拿到原止盈空间",
|
||||
"worst_u": path_b,
|
||||
}
|
||||
|
||||
|
||||
def _calc_single_option(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_opt") if inp.get("tp_opt") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
opt = inp.get("option") if isinstance(inp.get("option"), dict) else {}
|
||||
default_type = "C" if direction == "long" else "P"
|
||||
opt_type = str(opt.get("opt_type") or default_type).strip().upper()
|
||||
if opt_type not in ("C", "P"):
|
||||
opt_type = default_type
|
||||
strike = _f(opt.get("strike"))
|
||||
ask = _f(opt.get("ask"))
|
||||
if strike is None or strike <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权行权价"}
|
||||
if ask is None or ask <= 0:
|
||||
return {"ok": False, "msg": "请填写单期权卖一价"}
|
||||
unit = option_unit_cost(ask=ask, ct_mult=ct_mult)
|
||||
sheets = floor_sheets(risk / unit) if unit > 0 else 0.0
|
||||
premium = option_unit_cost(ask=ask, ct_mult=ct_mult) * sheets if sheets else 0.0
|
||||
# 若张数为 0
|
||||
path_a = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=tp, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_at_sl = option_pnl_at_spot(
|
||||
opt_type=opt_type, strike=strike, spot=sl, sheets=sheets, ct_mult=ct_mult, premium_paid=premium
|
||||
)
|
||||
path_b_worst = -premium
|
||||
# 踏空路径:合约被洗后标的仍到 TP,期权仍持有 → 同止盈
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "option",
|
||||
"opt_type": opt_type,
|
||||
"strike": strike,
|
||||
"ask": ask,
|
||||
"ct_mult": ct_mult,
|
||||
"sheets": sheets,
|
||||
"unit_cost_u": round(unit, 6),
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下期权仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def _calc_hedge(inp: dict[str, Any], *, ct_mult: float) -> dict[str, Any]:
|
||||
direction = str(inp.get("direction") or "long").strip().lower()
|
||||
risk = float(inp["risk_u"])
|
||||
tp = float(inp.get("tp_hedge") if inp.get("tp_hedge") not in (None, "") else inp["tp"])
|
||||
sl = float(inp["sl"])
|
||||
hedge = inp.get("hedge") if isinstance(inp.get("hedge"), dict) else {}
|
||||
main_default = "C" if direction == "long" else "P"
|
||||
side_default = "P" if direction == "long" else "C"
|
||||
main = hedge.get("main") if isinstance(hedge.get("main"), dict) else {}
|
||||
side = hedge.get("side") if isinstance(hedge.get("side"), dict) else {}
|
||||
main_type = str(main.get("opt_type") or main_default).strip().upper()
|
||||
side_type = str(side.get("opt_type") or side_default).strip().upper()
|
||||
if main_type not in ("C", "P"):
|
||||
main_type = main_default
|
||||
if side_type not in ("C", "P"):
|
||||
side_type = side_default
|
||||
main_k = _f(main.get("strike"))
|
||||
main_ask = _f(main.get("ask"))
|
||||
side_k = _f(side.get("strike"))
|
||||
side_ask = _f(side.get("ask"))
|
||||
if None in (main_k, main_ask, side_k, side_ask) or min(
|
||||
main_k or 0, main_ask or 0, side_k or 0, side_ask or 0
|
||||
) <= 0:
|
||||
return {"ok": False, "msg": "请填写期期对冲两腿的行权价与卖一"}
|
||||
main_budget = 0.7 * risk
|
||||
side_budget = 0.3 * risk
|
||||
main_unit = option_unit_cost(ask=float(main_ask), ct_mult=ct_mult)
|
||||
side_unit = option_unit_cost(ask=float(side_ask), ct_mult=ct_mult)
|
||||
main_sheets = floor_sheets(main_budget / main_unit) if main_unit > 0 else 0.0
|
||||
side_sheets = floor_sheets(side_budget / side_unit) if side_unit > 0 else 0.0
|
||||
main_prem = main_unit * main_sheets
|
||||
side_prem = side_unit * side_sheets
|
||||
premium = main_prem + side_prem
|
||||
|
||||
def combo_at(spot: float) -> float:
|
||||
a = option_pnl_at_spot(
|
||||
opt_type=main_type,
|
||||
strike=float(main_k),
|
||||
spot=spot,
|
||||
sheets=main_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=main_prem,
|
||||
)
|
||||
b = option_pnl_at_spot(
|
||||
opt_type=side_type,
|
||||
strike=float(side_k),
|
||||
spot=spot,
|
||||
sheets=side_sheets,
|
||||
ct_mult=ct_mult,
|
||||
premium_paid=side_prem,
|
||||
)
|
||||
return a + b
|
||||
|
||||
path_a = combo_at(tp)
|
||||
path_b_at_sl = combo_at(sl)
|
||||
path_b_worst = -premium
|
||||
path_c = path_a
|
||||
return {
|
||||
"ok": True,
|
||||
"kind": "hedge",
|
||||
"ratio": "7:3",
|
||||
"ct_mult": ct_mult,
|
||||
"main": {
|
||||
"opt_type": main_type,
|
||||
"strike": main_k,
|
||||
"ask": main_ask,
|
||||
"sheets": main_sheets,
|
||||
"premium_u": round(main_prem, 4),
|
||||
"budget_u": round(main_budget, 4),
|
||||
},
|
||||
"side": {
|
||||
"opt_type": side_type,
|
||||
"strike": side_k,
|
||||
"ask": side_ask,
|
||||
"sheets": side_sheets,
|
||||
"premium_u": round(side_prem, 4),
|
||||
"budget_u": round(side_budget, 4),
|
||||
},
|
||||
"premium_u": round(premium, 4),
|
||||
"path_a_tp": round(path_a, 4),
|
||||
"path_b_sl": round(path_b_at_sl, 4),
|
||||
"path_b_worst": round(path_b_worst, 4),
|
||||
"path_c_hold_to_tp": round(path_c, 4),
|
||||
"path_c_note": "合约踏空路径下对冲组合仍持有至目标价(内在近似)",
|
||||
"worst_u": round(path_b_worst, 4),
|
||||
}
|
||||
|
||||
|
||||
def recommend(perp: dict[str, Any], opt: dict[str, Any], hedge: dict[str, Any], risk: float) -> dict[str, Any]:
|
||||
"""可解释规则推荐."""
|
||||
candidates: list[tuple[str, float, dict[str, Any]]] = []
|
||||
if perp and perp.get("sheets", 0) > 0:
|
||||
candidates.append(("合约", float(perp.get("path_a_tp") or 0), perp))
|
||||
if opt and opt.get("ok") and opt.get("sheets", 0) > 0:
|
||||
candidates.append(("单期权", float(opt.get("path_a_tp") or 0), opt))
|
||||
if hedge and hedge.get("ok") and (hedge.get("premium_u") or 0) > 0:
|
||||
candidates.append(("期期对冲", float(hedge.get("path_a_tp") or 0), hedge))
|
||||
if not candidates:
|
||||
return {
|
||||
"choice": "—",
|
||||
"reason": "输入不足,无法推荐",
|
||||
"bullets": ["请检查风险额与卖一/止损距是否过小导致张数为 0"],
|
||||
}
|
||||
|
||||
best_name, best_a, _ = max(candidates, key=lambda x: x[1])
|
||||
perp_a = float(perp.get("path_a_tp") or 0) if perp else 0.0
|
||||
opt_a = float(opt.get("path_a_tp") or 0) if opt and opt.get("ok") else 0.0
|
||||
hedge_a = float(hedge.get("path_a_tp") or 0) if hedge and hedge.get("ok") else 0.0
|
||||
|
||||
# 踏空:合约 C 实现为亏损,期权/对冲 C 仍接近 A
|
||||
perp_miss = float(perp.get("path_c_missed") or 0) if perp else 0.0
|
||||
opt_c = float(opt.get("path_c_hold_to_tp") or 0) if opt and opt.get("ok") else None
|
||||
hedge_c = float(hedge.get("path_c_hold_to_tp") or 0) if hedge and hedge.get("ok") else None
|
||||
anti_whipsaw = False
|
||||
if perp_miss > 0 and (
|
||||
(opt_c is not None and opt_c > 0) or (hedge_c is not None and hedge_c > 0)
|
||||
):
|
||||
anti_whipsaw = True
|
||||
|
||||
# 合约止盈明显更高(>= 另两者 1.15 倍)且用户能接受踏空 → 推合约
|
||||
others_max = max(opt_a, hedge_a, 0.0)
|
||||
choice = best_name
|
||||
if perp_a > 0 and perp_a >= others_max * 1.15 and perp_a >= best_a * 0.99:
|
||||
choice = "合约"
|
||||
if anti_whipsaw:
|
||||
reason = "合约止盈赔付更高,但震荡易洗时存在踏空;能接受洗盘再走可选合约"
|
||||
else:
|
||||
reason = "同风险下合约干净止盈赔付最高"
|
||||
elif anti_whipsaw and (opt_a > 0 or hedge_a > 0):
|
||||
# 抗踏空优先期权类;期期与单腿接近时推期期
|
||||
if hedge_a > 0 and (opt_a <= 0 or hedge_a >= opt_a * 0.85):
|
||||
choice = "期期对冲"
|
||||
reason = "震荡易洗时期权类更抗踏空;期期 7:3 兼顾方向与保护"
|
||||
else:
|
||||
choice = "单期权"
|
||||
reason = "震荡易洗时单期权仍可持有到目标,抗踏空优于合约"
|
||||
else:
|
||||
reason = f"同风险下「{best_name}」干净止盈赔付最高"
|
||||
|
||||
bullets = [
|
||||
f"止盈对比:合约 {perp_a:.2f}U / 单期权 {opt_a:.2f}U / 期期 {hedge_a:.2f}U(风险 R={risk:.2f}U)",
|
||||
(
|
||||
"止损与踏空:合约打止损即结束并可能踏空;"
|
||||
"期权/对冲最坏约亏满权利金,踏空路径下常仍持有至目标"
|
||||
if anti_whipsaw
|
||||
else "止损与踏空:三者最坏接近 −R;关注合约是否易被洗后错过止盈"
|
||||
),
|
||||
f"选用建议:{reason}",
|
||||
]
|
||||
return {"choice": choice, "reason": reason, "bullets": bullets}
|
||||
|
||||
|
||||
def run_compare(inp: dict[str, Any]) -> dict[str, Any]:
|
||||
err = _validate_common(inp)
|
||||
if err:
|
||||
return {"ok": False, "msg": err}
|
||||
base = str(inp.get("base") or "ETH").strip().upper()
|
||||
risk = float(inp["risk_u"])
|
||||
cs = _f(inp.get("contract_size")) or default_contract_size(base)
|
||||
ct = _f(inp.get("ct_mult")) or default_ct_mult(base)
|
||||
perp = _calc_perp(inp, contract_size=float(cs))
|
||||
opt = _calc_single_option(inp, ct_mult=float(ct))
|
||||
hedge = _calc_hedge(inp, ct_mult=float(ct))
|
||||
rec = recommend(
|
||||
perp,
|
||||
opt if opt.get("ok") else {"ok": False},
|
||||
hedge if hedge.get("ok") else {"ok": False},
|
||||
risk,
|
||||
)
|
||||
warnings: list[str] = []
|
||||
if perp.get("sheets", 0) <= 0:
|
||||
warnings.append("合约张数为 0:止损距过大或 R 过小")
|
||||
if isinstance(opt, dict) and opt.get("ok") and opt.get("sheets", 0) <= 0:
|
||||
warnings.append("单期权张数为 0:卖一过高或 R 过小")
|
||||
if isinstance(hedge, dict) and hedge.get("ok") and hedge.get("premium_u", 0) <= 0:
|
||||
warnings.append("期期对冲未开出张数:卖一过高或 R 过小")
|
||||
if isinstance(opt, dict) and not opt.get("ok"):
|
||||
warnings.append(str(opt.get("msg") or "单期权输入不完整"))
|
||||
if isinstance(hedge, dict) and not hedge.get("ok"):
|
||||
warnings.append(str(hedge.get("msg") or "期期对冲输入不完整"))
|
||||
return {
|
||||
"ok": True,
|
||||
"base": base,
|
||||
"direction": str(inp.get("direction") or "long").strip().lower(),
|
||||
"entry": float(inp["entry"]),
|
||||
"sl": float(inp["sl"]),
|
||||
"tp": float(inp["tp"]),
|
||||
"risk_u": risk,
|
||||
"contract_size": float(cs),
|
||||
"ct_mult": float(ct),
|
||||
"perp": perp,
|
||||
"option": opt,
|
||||
"hedge": hedge,
|
||||
"recommend": rec,
|
||||
"warnings": warnings,
|
||||
"notes": [
|
||||
"期权止盈按标的到价的内在价值近似,非盘口卖出价",
|
||||
"到期小盈/小亏未纳入主表与推荐",
|
||||
"仅本地测算,不下单",
|
||||
],
|
||||
}
|
||||
@@ -991,6 +991,7 @@ def root_redirect():
|
||||
@app.get("/monitor")
|
||||
@app.get("/plan")
|
||||
@app.get("/calculator")
|
||||
@app.get("/compare")
|
||||
@app.get("/market")
|
||||
@app.get("/archive")
|
||||
@app.get("/quotes")
|
||||
@@ -1113,6 +1114,7 @@ class SettingsDisplayBody(BaseModel):
|
||||
show_nav_quotes: bool = True
|
||||
show_nav_ai: bool = True
|
||||
show_nav_calculator: bool = True
|
||||
show_nav_compare: bool = True
|
||||
show_nav_strategy: bool = True
|
||||
show_nav_amp_stats: bool = True
|
||||
show_nav_help: bool = True
|
||||
@@ -1212,6 +1214,27 @@ class RollCalculatorBody(BaseModel):
|
||||
base: str = "ETH"
|
||||
|
||||
|
||||
class CompareOptionLegBody(BaseModel):
|
||||
opt_type: str = "C"
|
||||
strike: float | None = None
|
||||
ask: float | None = None
|
||||
|
||||
|
||||
class CompareBody(BaseModel):
|
||||
base: str = "ETH"
|
||||
direction: str = "long"
|
||||
entry: float = Field(gt=0)
|
||||
sl: float = Field(gt=0)
|
||||
tp: float = Field(gt=0)
|
||||
risk_u: float = Field(gt=0)
|
||||
tp_opt: float | None = None
|
||||
tp_hedge: float | None = None
|
||||
contract_size: float | None = None
|
||||
ct_mult: float | None = None
|
||||
option: CompareOptionLegBody | None = None
|
||||
hedge: dict | None = None
|
||||
|
||||
|
||||
@app.get("/api/calculator/exchanges")
|
||||
def api_calculator_exchanges():
|
||||
from lib.hub.hub_calculator_market_lib import list_calculator_exchanges
|
||||
@@ -1272,6 +1295,26 @@ def api_calculator_roll(body: RollCalculatorBody):
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
@app.post("/api/compare/calc")
|
||||
def api_compare_calc(body: CompareBody):
|
||||
from lib.hub.hub_compare_lib import run_compare
|
||||
|
||||
payload = body.model_dump()
|
||||
hedge = payload.get("hedge") if isinstance(payload.get("hedge"), dict) else {}
|
||||
# normalize hedge legs from nested dicts
|
||||
if hedge:
|
||||
payload["hedge"] = {
|
||||
"main": hedge.get("main") if isinstance(hedge.get("main"), dict) else {},
|
||||
"side": hedge.get("side") if isinstance(hedge.get("side"), dict) else {},
|
||||
}
|
||||
if payload.get("option") is None:
|
||||
payload["option"] = {}
|
||||
data = run_compare(payload)
|
||||
if not data.get("ok"):
|
||||
return JSONResponse(data, status_code=400)
|
||||
return data
|
||||
|
||||
|
||||
def _find_exchange_by_key(exchange_key: str) -> dict | None:
|
||||
key = (exchange_key or "").strip().lower()
|
||||
if not key:
|
||||
|
||||
@@ -29,6 +29,7 @@ DEFAULT_DISPLAY = {
|
||||
"show_nav_quotes": True,
|
||||
"show_nav_ai": True,
|
||||
"show_nav_calculator": True,
|
||||
"show_nav_compare": True,
|
||||
"show_nav_strategy": True,
|
||||
"show_nav_amp_stats": True,
|
||||
"show_nav_help": True,
|
||||
|
||||
@@ -11008,3 +11008,67 @@ html[data-theme="light"] .hub-logs-card-hint {
|
||||
.amp-form { grid-template-columns: 1fr 1fr; }
|
||||
.amp-actions { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* ©¤©¤ ²ßÂÔ¶Ô±È ©¤©¤ */
|
||||
.cmp-form { display: flex; flex-direction: column; gap: 12px; margin-bottom: 14px; }
|
||||
.cmp-common-card h2,
|
||||
.cmp-form .card h2 { margin: 0 0 10px; font-size: 15px; }
|
||||
.cmp-subhead { margin: 14px 0 8px; font-size: 13px; color: var(--muted); font-weight: 600; }
|
||||
.cmp-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px 12px;
|
||||
}
|
||||
.cmp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.cmp-field input,
|
||||
.cmp-field select {
|
||||
background: var(--inset-surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cmp-input-cols {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.cmp-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.cmp-sum-card { padding: 12px 14px; }
|
||||
.cmp-sum-card h3 { margin: 0 0 8px; font-size: 14px; }
|
||||
.cmp-sum-row {
|
||||
display: flex; justify-content: space-between; gap: 8px;
|
||||
font-size: 12px; margin: 4px 0; color: var(--muted);
|
||||
}
|
||||
.cmp-sum-row strong { color: var(--text); font-weight: 600; }
|
||||
.cmp-muted { color: var(--muted); font-size: 12px; margin: 0; }
|
||||
.cmp-table-wrap { margin-bottom: 14px; }
|
||||
.cmp-table-scroll { overflow-x: auto; }
|
||||
.cmp-table {
|
||||
width: 100%; border-collapse: collapse; font-size: 13px;
|
||||
background: var(--card); border: 1px solid var(--border-soft); border-radius: 10px;
|
||||
}
|
||||
.cmp-table th, .cmp-table td {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
padding: 10px 12px; vertical-align: top; text-align: left;
|
||||
}
|
||||
.cmp-table th:first-child, .cmp-table td:first-child { width: 22%; }
|
||||
.cmp-cell-note { margin-top: 4px; font-size: 11px; color: var(--muted); line-height: 1.35; }
|
||||
.cmp-pnl-pos { color: var(--green); font-weight: 600; }
|
||||
.cmp-pnl-neg { color: var(--red); font-weight: 600; }
|
||||
.cmp-rec-card { padding: 14px 16px; }
|
||||
.cmp-rec-head { font-size: 16px; margin-bottom: 6px; }
|
||||
.cmp-rec-reason { margin: 0 0 8px; color: var(--muted); font-size: 13px; }
|
||||
.cmp-rec-list { margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.5; }
|
||||
.cmp-warn { margin-top: 10px; font-size: 12px; color: var(--warn, #e6a23c); }
|
||||
.cmp-foot-note { margin: 10px 0 0; font-size: 11px; color: var(--muted); }
|
||||
@media (max-width: 900px) {
|
||||
.cmp-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.cmp-input-cols, .cmp-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
return displayPref("show_nav_calculator", true);
|
||||
}
|
||||
|
||||
function showNavComparePref() {
|
||||
return displayPref("show_nav_compare", true);
|
||||
}
|
||||
|
||||
function showNavStrategyPref() {
|
||||
return displayPref("show_nav_strategy", true);
|
||||
}
|
||||
@@ -69,6 +73,7 @@
|
||||
["nav-quotes", "m-nav-quotes", d.show_nav_quotes === false],
|
||||
["nav-ai", "m-tab-ai", d.show_nav_ai === false],
|
||||
["nav-calculator", "m-tab-calculator", d.show_nav_calculator === false],
|
||||
["nav-compare", "m-nav-compare", d.show_nav_compare === false],
|
||||
["nav-strategy", "m-nav-strategy", d.show_nav_strategy === false],
|
||||
["nav-amp-stats", "m-nav-amp-stats", d.show_nav_amp_stats === false],
|
||||
["nav-help", "m-nav-help", d.show_nav_help === false],
|
||||
@@ -142,6 +147,7 @@
|
||||
if (page === "quotes") return showNavQuotesPref();
|
||||
if (page === "ai") return showNavAiPref();
|
||||
if (page === "calculator") return showNavCalculatorPref();
|
||||
if (page === "compare") return showNavComparePref();
|
||||
if (page === "strategy") return showNavStrategyPref();
|
||||
if (page === "amp-stats") return showNavAmpStatsPref();
|
||||
if (page === "help") return showNavHelpPref();
|
||||
@@ -159,6 +165,7 @@
|
||||
const quotesCb = document.getElementById("pref-show-nav-quotes");
|
||||
const aiCb = document.getElementById("pref-show-nav-ai");
|
||||
const calcCb = document.getElementById("pref-show-nav-calculator");
|
||||
const compareCb = document.getElementById("pref-show-nav-compare");
|
||||
const strategyCb = document.getElementById("pref-show-nav-strategy");
|
||||
const ampCb = document.getElementById("pref-show-nav-amp-stats");
|
||||
const helpCb = document.getElementById("pref-show-nav-help");
|
||||
@@ -171,6 +178,7 @@
|
||||
if (quotesCb) quotesCb.checked = d.show_nav_quotes !== false;
|
||||
if (aiCb) aiCb.checked = d.show_nav_ai !== false;
|
||||
if (calcCb) calcCb.checked = d.show_nav_calculator !== false;
|
||||
if (compareCb) compareCb.checked = d.show_nav_compare !== false;
|
||||
if (strategyCb) strategyCb.checked = d.show_nav_strategy !== false;
|
||||
if (ampCb) ampCb.checked = d.show_nav_amp_stats !== false;
|
||||
if (helpCb) helpCb.checked = d.show_nav_help !== false;
|
||||
@@ -1287,6 +1295,7 @@
|
||||
if (p.includes("funds")) return "funds";
|
||||
if (p.includes("plan")) return "plan";
|
||||
if (p.includes("calculator")) return "calculator";
|
||||
if (p.includes("compare")) return "compare";
|
||||
if (p.includes("help")) return "help";
|
||||
if (p.includes("amp-stats")) return "amp-stats";
|
||||
if (p.includes("strategy")) return "strategy";
|
||||
@@ -1304,6 +1313,7 @@
|
||||
if (page === "funds") return "page-funds";
|
||||
if (page === "plan") return "page-plan";
|
||||
if (page === "calculator") return "page-calculator";
|
||||
if (page === "compare") return "page-compare";
|
||||
if (page === "help") return "page-help";
|
||||
if (page === "strategy") return "page-strategy";
|
||||
if (page === "amp-stats") return "page-amp-stats";
|
||||
@@ -1336,6 +1346,7 @@
|
||||
document.body.classList.toggle("hub-page-monitor", page === "monitor");
|
||||
document.body.classList.toggle("hub-page-market", page === "market");
|
||||
document.body.classList.toggle("hub-page-calculator", page === "calculator");
|
||||
document.body.classList.toggle("hub-page-compare", page === "compare");
|
||||
document.body.classList.toggle("hub-page-settings", page === "settings");
|
||||
document.body.classList.toggle("hub-page-archive", page === "archive");
|
||||
document.body.classList.toggle("hub-page-quotes", page === "quotes");
|
||||
@@ -1376,6 +1387,11 @@
|
||||
if (page === "calculator" && window.hubCalculatorPage) {
|
||||
window.hubCalculatorPage.init();
|
||||
}
|
||||
if (page === "compare" && window.hubComparePage) {
|
||||
window.hubComparePage.init();
|
||||
} else if (window.hubComparePage && window.hubComparePage.destroy) {
|
||||
window.hubComparePage.destroy();
|
||||
}
|
||||
if (page === "funds" && window.hubFundsPage) {
|
||||
window.hubFundsPage.init();
|
||||
} else if (window.hubFundsPage && window.hubFundsPage.destroy) {
|
||||
@@ -5104,6 +5120,7 @@
|
||||
const quotesCb = document.getElementById("pref-show-nav-quotes");
|
||||
const aiCb = document.getElementById("pref-show-nav-ai");
|
||||
const calcCb = document.getElementById("pref-show-nav-calculator");
|
||||
const compareCb = document.getElementById("pref-show-nav-compare");
|
||||
const strategyCb = document.getElementById("pref-show-nav-strategy");
|
||||
const ampCb = document.getElementById("pref-show-nav-amp-stats");
|
||||
const helpCb = document.getElementById("pref-show-nav-help");
|
||||
@@ -5128,6 +5145,7 @@
|
||||
show_nav_quotes: quotesCb ? !!quotesCb.checked : true,
|
||||
show_nav_ai: aiCb ? !!aiCb.checked : true,
|
||||
show_nav_calculator: calcCb ? !!calcCb.checked : true,
|
||||
show_nav_compare: compareCb ? !!compareCb.checked : true,
|
||||
show_nav_strategy: strategyCb ? !!strategyCb.checked : true,
|
||||
show_nav_amp_stats: ampCb ? !!ampCb.checked : true,
|
||||
show_nav_help: helpCb ? !!helpCb.checked : true,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 中控策略对比:同风险额 R 下 合约 / 单期权 / 期期7:3
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-compare");
|
||||
if (!page) return;
|
||||
|
||||
let inited = false;
|
||||
let calcTimer = null;
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function num(id) {
|
||||
const el = $(id);
|
||||
if (!el) return null;
|
||||
const n = Number(el.value);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function text(id) {
|
||||
const el = $(id);
|
||||
return el ? String(el.value || "").trim() : "";
|
||||
}
|
||||
|
||||
function fmtU(v) {
|
||||
if (v == null || !Number.isFinite(Number(v))) return "—";
|
||||
const n = Number(v);
|
||||
const abs = Math.abs(n).toFixed(2);
|
||||
if (Math.abs(n) < 1e-9) return "0.00U";
|
||||
return (n > 0 ? "+" : "-") + abs + "U";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "";
|
||||
return n > 0 ? "cmp-pnl-pos" : "cmp-pnl-neg";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
const el = $("cmp-status");
|
||||
if (!el) return;
|
||||
el.textContent = msg || "";
|
||||
el.className = "toolbar-meta" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function syncDirectionDefaults() {
|
||||
const dir = text("cmp-direction") || "long";
|
||||
const isLong = dir === "long";
|
||||
const optType = $("cmp-opt-type");
|
||||
const mainType = $("cmp-hedge-main-type");
|
||||
const sideType = $("cmp-hedge-side-type");
|
||||
if (optType && !optType.dataset.touched) optType.value = isLong ? "C" : "P";
|
||||
if (mainType && !mainType.dataset.touched) mainType.value = isLong ? "C" : "P";
|
||||
if (sideType && !sideType.dataset.touched) sideType.value = isLong ? "P" : "C";
|
||||
}
|
||||
|
||||
function collectPayload() {
|
||||
const tp = num("cmp-tp");
|
||||
return {
|
||||
base: text("cmp-base") || "ETH",
|
||||
direction: text("cmp-direction") || "long",
|
||||
entry: num("cmp-entry"),
|
||||
sl: num("cmp-sl"),
|
||||
tp: tp,
|
||||
risk_u: num("cmp-risk"),
|
||||
tp_opt: num("cmp-tp-opt") != null ? num("cmp-tp-opt") : tp,
|
||||
tp_hedge: num("cmp-tp-hedge") != null ? num("cmp-tp-hedge") : tp,
|
||||
option: {
|
||||
opt_type: text("cmp-opt-type") || "C",
|
||||
strike: num("cmp-opt-strike"),
|
||||
ask: num("cmp-opt-ask"),
|
||||
},
|
||||
hedge: {
|
||||
main: {
|
||||
opt_type: text("cmp-hedge-main-type") || "C",
|
||||
strike: num("cmp-hedge-main-strike"),
|
||||
ask: num("cmp-hedge-main-ask"),
|
||||
},
|
||||
side: {
|
||||
opt_type: text("cmp-hedge-side-type") || "P",
|
||||
strike: num("cmp-hedge-side-strike"),
|
||||
ask: num("cmp-hedge-side-ask"),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderSummaryCards(data) {
|
||||
const box = $("cmp-summary");
|
||||
if (!box) return;
|
||||
const perp = data.perp || {};
|
||||
const opt = data.option || {};
|
||||
const hedge = data.hedge || {};
|
||||
const cards = [];
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>单独合约</h3>
|
||||
<div class="cmp-sum-row"><span>张数</span><strong>${esc(perp.sheets)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>止损占用</span><strong>${fmtU(perp.risk_used_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>面值</span><strong>${esc(perp.contract_size)} 币/张</strong></div>
|
||||
</article>`);
|
||||
if (opt.ok) {
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>单独期权 · ${esc(opt.opt_type)} ${esc(opt.strike)}</h3>
|
||||
<div class="cmp-sum-row"><span>张数</span><strong>${esc(opt.sheets)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>权利金</span><strong>${fmtU(opt.premium_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>单张成本</span><strong>${fmtU(opt.unit_cost_u)}</strong></div>
|
||||
</article>`);
|
||||
} else {
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>单独期权</h3>
|
||||
<p class="cmp-muted">${esc(opt.msg || "输入不完整")}</p>
|
||||
</article>`);
|
||||
}
|
||||
if (hedge.ok) {
|
||||
const m = hedge.main || {};
|
||||
const s = hedge.side || {};
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>期期对冲 7:3</h3>
|
||||
<div class="cmp-sum-row"><span>主腿 ${esc(m.opt_type)} ${esc(m.strike)}</span><strong>${esc(m.sheets)} 张 · ${fmtU(m.premium_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>次腿 ${esc(s.opt_type)} ${esc(s.strike)}</span><strong>${esc(s.sheets)} 张 · ${fmtU(s.premium_u)}</strong></div>
|
||||
<div class="cmp-sum-row"><span>总权利金</span><strong>${fmtU(hedge.premium_u)}</strong></div>
|
||||
</article>`);
|
||||
} else {
|
||||
cards.push(`<article class="cmp-sum-card card">
|
||||
<h3>期期对冲</h3>
|
||||
<p class="cmp-muted">${esc(hedge.msg || "输入不完整")}</p>
|
||||
</article>`);
|
||||
}
|
||||
box.innerHTML = cards.join("");
|
||||
}
|
||||
|
||||
function cell(v, note) {
|
||||
const main = `<span class="${pnlClass(v)}">${fmtU(v)}</span>`;
|
||||
if (!note) return main;
|
||||
return `${main}<div class="cmp-cell-note">${esc(note)}</div>`;
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
const box = $("cmp-table-wrap");
|
||||
if (!box) return;
|
||||
const perp = data.perp || {};
|
||||
const opt = data.option && data.option.ok ? data.option : null;
|
||||
const hedge = data.hedge && data.hedge.ok ? data.hedge : null;
|
||||
const dash = "—";
|
||||
box.innerHTML = `<div class="cmp-table-scroll"><table class="cmp-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>路径</th>
|
||||
<th>单独合约</th>
|
||||
<th>单独期权</th>
|
||||
<th>期期对冲</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>A 干净止盈</strong><div class="cmp-cell-note">盈利能力主对比</div></td>
|
||||
<td>${cell(perp.path_a_tp)}</td>
|
||||
<td>${opt ? cell(opt.path_a_tp) : dash}</td>
|
||||
<td>${hedge ? cell(hedge.path_a_tp) : dash}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>B 打止损</strong><div class="cmp-cell-note">合约实现亏损;期权另注最坏</div></td>
|
||||
<td>${cell(perp.path_b_sl)}</td>
|
||||
<td>${
|
||||
opt
|
||||
? cell(opt.path_b_sl, "最坏到期亏满权利金 " + fmtU(opt.path_b_worst))
|
||||
: dash
|
||||
}</td>
|
||||
<td>${
|
||||
hedge
|
||||
? cell(hedge.path_b_sl, "最坏双腿归零 " + fmtU(hedge.path_b_worst))
|
||||
: dash
|
||||
}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>C 先止损再去止盈</strong><div class="cmp-cell-note">合约踏空对照</div></td>
|
||||
<td>${cell(
|
||||
perp.path_c_realized,
|
||||
"踏空未拿到 " + fmtU(perp.path_c_missed)
|
||||
)}</td>
|
||||
<td>${opt ? cell(opt.path_c_hold_to_tp, opt.path_c_note || "") : dash}</td>
|
||||
<td>${hedge ? cell(hedge.path_c_hold_to_tp, hedge.path_c_note || "") : dash}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
function renderRecommend(data) {
|
||||
const box = $("cmp-recommend");
|
||||
if (!box) return;
|
||||
const rec = data.recommend || {};
|
||||
const bullets = Array.isArray(rec.bullets) ? rec.bullets : [];
|
||||
const warns = Array.isArray(data.warnings) ? data.warnings : [];
|
||||
box.innerHTML = `<div class="cmp-rec-card card">
|
||||
<div class="cmp-rec-head">推荐:<strong>${esc(rec.choice || "—")}</strong></div>
|
||||
<p class="cmp-rec-reason">${esc(rec.reason || "")}</p>
|
||||
<ul class="cmp-rec-list">${bullets.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>
|
||||
${
|
||||
warns.length
|
||||
? `<div class="cmp-warn">${warns.map((w) => esc(w)).join(" · ")}</div>`
|
||||
: ""
|
||||
}
|
||||
<p class="cmp-foot-note">${(data.notes || []).map(esc).join(" · ")}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function runCalc() {
|
||||
const payload = collectPayload();
|
||||
if (
|
||||
payload.entry == null ||
|
||||
payload.sl == null ||
|
||||
payload.tp == null ||
|
||||
payload.risk_u == null
|
||||
) {
|
||||
setStatus("请填写入场 / 止损 / 止盈 / 风险额", true);
|
||||
return;
|
||||
}
|
||||
setStatus("计算中…");
|
||||
try {
|
||||
const r = await fetch("/api/compare/calc", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!data.ok) {
|
||||
setStatus(data.msg || "计算失败", true);
|
||||
return;
|
||||
}
|
||||
renderSummaryCards(data);
|
||||
renderTable(data);
|
||||
renderRecommend(data);
|
||||
setStatus("已更新");
|
||||
} catch (e) {
|
||||
setStatus(String(e.message || e), true);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCalc() {
|
||||
if (calcTimer) clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
void runCalc();
|
||||
}, 280);
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const form = $("cmp-form");
|
||||
if (!form || form.dataset.bound === "1") return;
|
||||
form.dataset.bound = "1";
|
||||
form.addEventListener("submit", (ev) => {
|
||||
ev.preventDefault();
|
||||
void runCalc();
|
||||
});
|
||||
form.querySelectorAll("input, select").forEach((el) => {
|
||||
el.addEventListener("change", () => {
|
||||
if (el.id === "cmp-direction") syncDirectionDefaults();
|
||||
if (
|
||||
el.id === "cmp-opt-type" ||
|
||||
el.id === "cmp-hedge-main-type" ||
|
||||
el.id === "cmp-hedge-side-type"
|
||||
) {
|
||||
el.dataset.touched = "1";
|
||||
}
|
||||
scheduleCalc();
|
||||
});
|
||||
el.addEventListener("input", scheduleCalc);
|
||||
});
|
||||
const btn = $("cmp-btn-run");
|
||||
if (btn) btn.addEventListener("click", () => void runCalc());
|
||||
}
|
||||
|
||||
window.hubComparePage = {
|
||||
init() {
|
||||
if (!inited) {
|
||||
bind();
|
||||
syncDirectionDefaults();
|
||||
inited = true;
|
||||
}
|
||||
scheduleCalc();
|
||||
},
|
||||
destroy() {
|
||||
/* keep form state */
|
||||
},
|
||||
};
|
||||
})();
|
||||
@@ -16,7 +16,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260723-hide-pnl" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260723-compare" />
|
||||
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
|
||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||
@@ -57,6 +57,7 @@
|
||||
<a href="/help" id="nav-help">使用说明</a>
|
||||
<a href="/market" id="nav-market">行情区</a>
|
||||
<a href="/calculator" id="nav-calculator">计算器</a>
|
||||
<a href="/compare" id="nav-compare">策略对比</a>
|
||||
<a href="/archive" id="nav-archive">内照明心</a>
|
||||
<a href="/quotes" id="nav-quotes">语录</a>
|
||||
<a href="/dashboard" id="nav-dashboard">数据看板</a>
|
||||
@@ -957,6 +958,125 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-compare" class="page hidden">
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">CMP</span> 策略对比</h1>
|
||||
<p class="page-desc">同风险额下对比 · 单独合约 / 单独期权 / 期期对冲(7:3) · 看止盈谁强、谁更易踏空</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button type="button" id="cmp-btn-run" class="primary">计算对比</button>
|
||||
<span id="cmp-status" class="toolbar-meta"></span>
|
||||
</div>
|
||||
<form id="cmp-form" class="cmp-form">
|
||||
<section class="card cmp-common-card">
|
||||
<h2>公共参数</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>标的</span>
|
||||
<select id="cmp-base">
|
||||
<option value="ETH" selected>ETH</option>
|
||||
<option value="BTC">BTC</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>方向</span>
|
||||
<select id="cmp-direction">
|
||||
<option value="long" selected>做多</option>
|
||||
<option value="short">做空</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>入场价</span>
|
||||
<input id="cmp-entry" type="number" min="0" step="any" value="3500" required />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>统一风险 R (U)</span>
|
||||
<input id="cmp-risk" type="number" min="0.01" step="any" value="10" required />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>统一止损价</span>
|
||||
<input id="cmp-sl" type="number" min="0" step="any" value="3400" required />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>止盈价</span>
|
||||
<input id="cmp-tp" type="number" min="0" step="any" value="3700" required />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<div class="cmp-input-cols">
|
||||
<section class="card">
|
||||
<h2>单独期权</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>类型</span>
|
||||
<select id="cmp-opt-type">
|
||||
<option value="C" selected>Call</option>
|
||||
<option value="P">Put</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>行权价</span>
|
||||
<input id="cmp-opt-strike" type="number" min="0" step="any" value="3600" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>卖一价(每币)</span>
|
||||
<input id="cmp-opt-ask" type="number" min="0" step="any" value="50" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>期权目标价(默认同止盈)</span>
|
||||
<input id="cmp-tp-opt" type="number" min="0" step="any" placeholder="空=用止盈价" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<h2>期期对冲 · 主腿 70%</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>类型</span>
|
||||
<select id="cmp-hedge-main-type">
|
||||
<option value="C" selected>Call</option>
|
||||
<option value="P">Put</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>行权价</span>
|
||||
<input id="cmp-hedge-main-strike" type="number" min="0" step="any" value="3600" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>卖一价</span>
|
||||
<input id="cmp-hedge-main-ask" type="number" min="0" step="any" value="50" />
|
||||
</label>
|
||||
</div>
|
||||
<h2 class="cmp-subhead">次腿 30%</h2>
|
||||
<div class="cmp-form-grid">
|
||||
<label class="cmp-field">
|
||||
<span>类型</span>
|
||||
<select id="cmp-hedge-side-type">
|
||||
<option value="C">Call</option>
|
||||
<option value="P" selected>Put</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>行权价</span>
|
||||
<input id="cmp-hedge-side-strike" type="number" min="0" step="any" value="3400" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>卖一价</span>
|
||||
<input id="cmp-hedge-side-ask" type="number" min="0" step="any" value="30" />
|
||||
</label>
|
||||
<label class="cmp-field">
|
||||
<span>对冲目标价(默认同止盈)</span>
|
||||
<input id="cmp-tp-hedge" type="number" min="0" step="any" placeholder="空=用止盈价" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</form>
|
||||
<div id="cmp-summary" class="cmp-summary"></div>
|
||||
<div id="cmp-table-wrap" class="cmp-table-wrap"></div>
|
||||
<div id="cmp-recommend" class="cmp-recommend"></div>
|
||||
</div>
|
||||
|
||||
<div id="page-strategy" class="page hidden">
|
||||
<div class="page-head strategy-page-head">
|
||||
<div>
|
||||
@@ -1242,6 +1362,10 @@
|
||||
<input type="checkbox" id="pref-show-nav-calculator" checked />
|
||||
顶栏显示「计算器」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-compare" checked />
|
||||
顶栏显示「策略对比」
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="pref-show-nav-strategy" checked />
|
||||
顶栏显示「策略说明」
|
||||
@@ -1445,6 +1569,7 @@
|
||||
<a href="/dashboard" id="m-nav-dashboard">数据看板</a>
|
||||
<a href="/strategy" id="m-nav-strategy">策略说明</a>
|
||||
<a href="/amp-stats" id="m-nav-amp-stats">振幅统计</a>
|
||||
<a href="/compare" id="m-nav-compare">策略对比</a>
|
||||
<a href="/help" id="m-nav-help">使用说明</a>
|
||||
<a href="/logs" id="m-nav-logs">系统日志</a>
|
||||
<a href="/settings" id="m-nav-settings">系统设置</a>
|
||||
@@ -1502,6 +1627,7 @@
|
||||
<script src="/assets/chart.js?v=20260720-option-day-1600"></script>
|
||||
<script src="/assets/plan.js?v=20260720-autofill"></script>
|
||||
<script src="/assets/calculator.js?v=20260715-calc-tabs"></script>
|
||||
<script src="/assets/compare.js?v=20260723-compare"></script>
|
||||
<script src="/assets/trade_stats_calendar.js?v=3"></script>
|
||||
<script src="/assets/archive.js?v=20260717-archive-cal-chart"></script>
|
||||
<script src="/assets/quotes.js?v=20260717-quotes-feed"></script>
|
||||
@@ -1516,6 +1642,6 @@
|
||||
<script src="/assets/options_expiry_countdown.js?v=1"></script>
|
||||
<script src="/assets/options_position_cards.js?v=3"></script>
|
||||
<script src="/assets/backup.js?v=1"></script>
|
||||
<script src="/assets/app.js?v=20260723-dash-hide-pnl"></script>
|
||||
<script src="/assets/app.js?v=20260723-compare"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""策略对比仓位与情景测算."""
|
||||
from __future__ import annotations
|
||||
|
||||
from lib.hub.hub_compare_lib import run_compare
|
||||
|
||||
|
||||
def test_long_eth_realistic_asks():
|
||||
out = run_compare(
|
||||
{
|
||||
"base": "ETH",
|
||||
"direction": "long",
|
||||
"entry": 3500,
|
||||
"sl": 3400,
|
||||
"tp": 3700,
|
||||
"risk_u": 10,
|
||||
"option": {"opt_type": "C", "strike": 3600, "ask": 50},
|
||||
"hedge": {
|
||||
"main": {"opt_type": "C", "strike": 3600, "ask": 50},
|
||||
"side": {"opt_type": "P", "strike": 3400, "ask": 30},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert out["ok"] is True
|
||||
perp = out["perp"]
|
||||
# 每张止损 = 100 * 0.01 = 1U → 10 张
|
||||
assert perp["sheets"] == 10
|
||||
assert abs(perp["path_b_sl"] + 10) < 1e-6
|
||||
assert perp["path_a_tp"] > 0
|
||||
assert perp["path_c_realized"] == perp["path_b_sl"]
|
||||
assert perp["path_c_missed"] == perp["path_a_tp"]
|
||||
|
||||
opt = out["option"]
|
||||
assert opt["ok"] is True
|
||||
# unit = 50 * 0.01 = 0.5U → 20 张, premium = 10
|
||||
assert opt["sheets"] == 20
|
||||
assert abs(opt["premium_u"] - 10) < 1e-6
|
||||
assert abs(opt["path_b_worst"] + 10) < 1e-6
|
||||
# at TP 3700, call 3600 intrinsic = 100 * 20 * 0.01 = 20, pnl = 20-10 = 10
|
||||
assert abs(opt["path_a_tp"] - 10) < 1e-6
|
||||
assert abs(opt["path_c_hold_to_tp"] - opt["path_a_tp"]) < 1e-6
|
||||
|
||||
hedge = out["hedge"]
|
||||
assert hedge["ok"] is True
|
||||
# main budget 7, unit 0.5 → 14 sheets; side budget 3, unit 0.3 → 10 sheets
|
||||
assert hedge["main"]["sheets"] == 14
|
||||
assert hedge["side"]["sheets"] == 10
|
||||
assert out["recommend"]["choice"] in ("合约", "单期权", "期期对冲")
|
||||
|
||||
|
||||
def test_short_validation():
|
||||
bad = run_compare(
|
||||
{
|
||||
"base": "ETH",
|
||||
"direction": "short",
|
||||
"entry": 3500,
|
||||
"sl": 3400,
|
||||
"tp": 3300,
|
||||
"risk_u": 10,
|
||||
}
|
||||
)
|
||||
assert bad["ok"] is False
|
||||
|
||||
|
||||
def test_recommend_has_bullets():
|
||||
out = run_compare(
|
||||
{
|
||||
"base": "ETH",
|
||||
"direction": "long",
|
||||
"entry": 3500,
|
||||
"sl": 3490,
|
||||
"tp": 3520,
|
||||
"risk_u": 10,
|
||||
"option": {"opt_type": "C", "strike": 3500, "ask": 20},
|
||||
"hedge": {
|
||||
"main": {"opt_type": "C", "strike": 3500, "ask": 20},
|
||||
"side": {"opt_type": "P", "strike": 3480, "ask": 15},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert out["recommend"]["choice"]
|
||||
assert len(out["recommend"]["bullets"]) == 3
|
||||
Reference in New Issue
Block a user