From ed3033d793c13d8432a96c171546f6a0bbdbcf0b Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 23 Jul 2026 14:39:59 +0800 Subject: [PATCH] Add hub strategy compare page for perp vs options vs 7:3 hedge. Co-authored-by: Cursor --- docs/策略对比说明.md | 62 +++++ lib/hub/hub_compare_lib.py | 400 +++++++++++++++++++++++++++ manual_trading_hub/hub.py | 43 +++ manual_trading_hub/settings_store.py | 1 + manual_trading_hub/static/app.css | 64 +++++ manual_trading_hub/static/app.js | 18 ++ manual_trading_hub/static/compare.js | 296 ++++++++++++++++++++ manual_trading_hub/static/index.html | 130 ++++++++- tests/test_hub_compare_lib.py | 82 ++++++ 9 files changed, 1094 insertions(+), 2 deletions(-) create mode 100644 docs/策略对比说明.md create mode 100644 lib/hub/hub_compare_lib.py create mode 100644 manual_trading_hub/static/compare.js create mode 100644 tests/test_hub_compare_lib.py diff --git a/docs/策略对比说明.md b/docs/策略对比说明.md new file mode 100644 index 0000000..c00cb07 --- /dev/null +++ b/docs/策略对比说明.md @@ -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 张 + +## 不做 + +实盘下单、拉交易所卖一(二期可选)、历史回测入库。 diff --git a/lib/hub/hub_compare_lib.py b/lib/hub/hub_compare_lib.py new file mode 100644 index 0000000..5224fd1 --- /dev/null +++ b/lib/hub/hub_compare_lib.py @@ -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": [ + "期权止盈按标的到价的内在价值近似,非盘口卖出价", + "到期小盈/小亏未纳入主表与推荐", + "仅本地测算,不下单", + ], + } diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index f6b404e..3ddf8df 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -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: diff --git a/manual_trading_hub/settings_store.py b/manual_trading_hub/settings_store.py index 1759eae..03b75ee 100644 --- a/manual_trading_hub/settings_store.py +++ b/manual_trading_hub/settings_store.py @@ -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, diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index b4f1d3f..8e5d037 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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; } +} diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 6edc62a..4e4ea8d 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -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, diff --git a/manual_trading_hub/static/compare.js b/manual_trading_hub/static/compare.js new file mode 100644 index 0000000..030d515 --- /dev/null +++ b/manual_trading_hub/static/compare.js @@ -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, """); + } + + 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(`
+

单独合约

+
张数${esc(perp.sheets)}
+
止损占用${fmtU(perp.risk_used_u)}
+
面值${esc(perp.contract_size)} 币/张
+
`); + if (opt.ok) { + cards.push(`
+

单独期权 · ${esc(opt.opt_type)} ${esc(opt.strike)}

+
张数${esc(opt.sheets)}
+
权利金${fmtU(opt.premium_u)}
+
单张成本${fmtU(opt.unit_cost_u)}
+
`); + } else { + cards.push(`
+

单独期权

+

${esc(opt.msg || "输入不完整")}

+
`); + } + if (hedge.ok) { + const m = hedge.main || {}; + const s = hedge.side || {}; + cards.push(`
+

期期对冲 7:3

+
主腿 ${esc(m.opt_type)} ${esc(m.strike)}${esc(m.sheets)} 张 · ${fmtU(m.premium_u)}
+
次腿 ${esc(s.opt_type)} ${esc(s.strike)}${esc(s.sheets)} 张 · ${fmtU(s.premium_u)}
+
总权利金${fmtU(hedge.premium_u)}
+
`); + } else { + cards.push(`
+

期期对冲

+

${esc(hedge.msg || "输入不完整")}

+
`); + } + box.innerHTML = cards.join(""); + } + + function cell(v, note) { + const main = `${fmtU(v)}`; + if (!note) return main; + return `${main}
${esc(note)}
`; + } + + 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 = `
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
路径单独合约单独期权期期对冲
A 干净止盈
盈利能力主对比
${cell(perp.path_a_tp)}${opt ? cell(opt.path_a_tp) : dash}${hedge ? cell(hedge.path_a_tp) : dash}
B 打止损
合约实现亏损;期权另注最坏
${cell(perp.path_b_sl)}${ + opt + ? cell(opt.path_b_sl, "最坏到期亏满权利金 " + fmtU(opt.path_b_worst)) + : dash + }${ + hedge + ? cell(hedge.path_b_sl, "最坏双腿归零 " + fmtU(hedge.path_b_worst)) + : dash + }
C 先止损再去止盈
合约踏空对照
${cell( + perp.path_c_realized, + "踏空未拿到 " + fmtU(perp.path_c_missed) + )}${opt ? cell(opt.path_c_hold_to_tp, opt.path_c_note || "") : dash}${hedge ? cell(hedge.path_c_hold_to_tp, hedge.path_c_note || "") : dash}
`; + } + + 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 = `
+
推荐:${esc(rec.choice || "—")}
+

${esc(rec.reason || "")}

+
    ${bullets.map((b) => `
  • ${esc(b)}
  • `).join("")}
+ ${ + warns.length + ? `
${warns.map((w) => esc(w)).join(" · ")}
` + : "" + } +

${(data.notes || []).map(esc).join(" · ")}

+
`; + } + + 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 */ + }, + }; +})(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 139e560..acc75ab 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,7 +16,7 @@ - + @@ -57,6 +57,7 @@ 使用说明 行情区 计算器 + 策略对比 内照明心 语录 数据看板 @@ -957,6 +958,125 @@ + +