From 457838ef642b29ed1d707f9546dc9012dd63d520 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 8 Aug 2026 11:42:57 +0800 Subject: [PATCH] Extend semi-auto with ITM/ATM/OTM, offset, and 1:4 sizing. Moneyness and perp:option units are plan-only; OTM enforces leverage >=180. Co-authored-by: Cursor --- backend/app/api/fleet.py | 4 + backend/app/api/plan.py | 4 + backend/app/api/settings.py | 34 +++++++ backend/app/config.py | 6 +- backend/app/strategy/engine.py | 8 ++ backend/app/strategy/open_pipeline.py | 17 ++++ backend/app/strategy/risk_sizing.py | 16 +++- backend/app/strategy/selection.py | 71 +++++++++++++-- backend/app/strategy/semi_auto.py | 71 +++++++++++++-- backend/app/strategy/session.py | 38 +++++++- backend/tests/test_semi_auto.py | 27 +++++- control/frontend/src/pages/Monitor.tsx | 11 ++- docs/半自动说明.md | 17 +++- docs/更新说明.md | 12 ++- docs/策略说明.md | 6 +- frontend/src/api/client.ts | 8 ++ frontend/src/pages/Plan.tsx | 119 +++++++++++++++++++++---- 17 files changed, 428 insertions(+), 41 deletions(-) diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index f2b9875..936f587 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -298,6 +298,10 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di "semi_net_exit_target": st.get("semi_net_exit_target"), "semi_min_option_hours": st.get("semi_min_option_hours"), "semi_min_option_leverage": st.get("semi_min_option_leverage"), + "semi_moneyness": st.get("semi_moneyness"), + "semi_otm_max_offset": st.get("semi_otm_max_offset"), + "semi_perp_unit": st.get("semi_perp_unit"), + "semi_option_unit": st.get("semi_option_unit"), "leverage": _pick("leverage", float(settings.leverage)), "min_option_leverage": _pick( "min_option_leverage", float(settings.min_option_leverage) diff --git a/backend/app/api/plan.py b/backend/app/api/plan.py index c22b434..70effc2 100644 --- a/backend/app/api/plan.py +++ b/backend/app/api/plan.py @@ -45,6 +45,10 @@ class SemiParamsBody(BaseModel): semi_perp_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000) semi_min_option_hours: float | None = Field(default=None, ge=1, le=720) semi_min_option_leverage: float | None = Field(default=None, ge=1, le=10000) + semi_moneyness: Literal["itm", "atm", "otm"] | None = None + semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000) + semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100) + semi_option_unit: float | None = Field(default=None, ge=0.01, le=100) @router.post("/semi/arm") diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index a1336ed..f66fb28 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -52,6 +52,10 @@ KEYS = ( "semi_perp_exit_unit", "semi_min_option_hours", "semi_min_option_leverage", + "semi_moneyness", + "semi_otm_max_offset", + "semi_perp_unit", + "semi_option_unit", "close_bid_mark_max_pct", "residual_min_premium_pct", "residual_close_check_sec", @@ -109,6 +113,10 @@ class StrategySettingsBody(BaseModel): semi_perp_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000) semi_min_option_hours: float | None = Field(default=None, ge=1, le=720) semi_min_option_leverage: float | None = Field(default=None, ge=1, le=10000) + semi_moneyness: str | None = Field(default=None, pattern="^(itm|atm|otm)$") + semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000) + semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100) + semi_option_unit: float | None = Field(default=None, ge=0.01, le=100) close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100) residual_min_premium_pct: float | None = Field(default=None, ge=1, le=100) residual_close_check_sec: int | None = Field(default=None, ge=30, le=86400) @@ -296,6 +304,28 @@ def _read_settings() -> dict: ) or s.semi_min_option_leverage ), + "semi_moneyness": ( + lambda m: m + if m in ("itm", "atm", "otm") + else "otm" + )( + str( + db.get_setting("semi_moneyness", s.semi_moneyness) or s.semi_moneyness + ) + .strip() + .lower() + ), + "semi_otm_max_offset": float( + db.get_setting("semi_otm_max_offset", str(s.semi_otm_max_offset)) + or s.semi_otm_max_offset + ), + "semi_perp_unit": float( + db.get_setting("semi_perp_unit", str(s.semi_perp_unit)) or s.semi_perp_unit + ), + "semi_option_unit": float( + db.get_setting("semi_option_unit", str(s.semi_option_unit)) + or s.semi_option_unit + ), "close_bid_mark_max_pct": float( db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct)) or s.close_bid_mark_max_pct @@ -555,6 +585,10 @@ async def put_strategy_settings( "semi_perp_exit_unit", "semi_min_option_hours", "semi_min_option_leverage", + "semi_moneyness", + "semi_otm_max_offset", + "semi_perp_unit", + "semi_option_unit", "semi_auto_enabled", ) hit = [k for k in locked_keys if k in data] diff --git a/backend/app/config.py b/backend/app/config.py index aa03a48..f5d260b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -102,7 +102,11 @@ class Settings(BaseSettings): semi_option_move_points: float = 50.0 # 顺方向:标的波动点数 semi_perp_exit_unit: float = 5.0 # 净利出场基数(×k) semi_min_option_hours: float = 30.0 - semi_min_option_leverage: float = 100.0 + semi_min_option_leverage: float = 200.0 # 虚值默认 200(虚值门仍强制≥180) + semi_moneyness: str = "otm" # itm|atm|otm + semi_otm_max_offset: float = 25.0 # 虚值 |K−S| 上限(可配) + semi_perp_unit: float = 1.0 # 半自动永续:期权 配比分子 + semi_option_unit: float = 4.0 # 配比分母,默认 1:4 close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差% # 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出 residual_min_premium_pct: float = 20.0 diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 952db30..63d3f9b 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -369,6 +369,10 @@ class StrategyEngine: out["semi_perp_exit_unit"] = sp.get("perp_exit_unit") out["semi_min_option_hours"] = sp.get("min_option_hours") out["semi_min_option_leverage"] = sp.get("min_option_leverage") + out["semi_moneyness"] = sp.get("moneyness") + out["semi_otm_max_offset"] = sp.get("otm_max_offset") + out["semi_perp_unit"] = sp.get("perp_unit") + out["semi_option_unit"] = sp.get("option_unit") out["semi_net_exit_target"] = float(sp["perp_exit_unit"]) * k_eff except Exception: logger.exception("semi params for state() failed") @@ -1361,6 +1365,10 @@ class StrategyEngine: view_side=str(sp_lock["view_side"]), option_move_points=float(sp_lock["option_move_points"]), perp_exit_unit=float(sp_lock["perp_exit_unit"]), + moneyness=str(sp_lock.get("moneyness") or "otm"), + otm_max_offset=float(sp_lock.get("otm_max_offset") or 25), + perp_unit=float(sp_lock.get("perp_unit") or 1), + option_unit=float(sp_lock.get("option_unit") or 4), ) self._set_state(phase="open", last_error=None) try: diff --git a/backend/app/strategy/open_pipeline.py b/backend/app/strategy/open_pipeline.py index c665571..b023e72 100644 --- a/backend/app/strategy/open_pipeline.py +++ b/backend/app/strategy/open_pipeline.py @@ -76,10 +76,27 @@ def size_and_gate( if not rs.ok: return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail) else: + from ..sim.ledger import Ledger + from .risk_sizing import is_risk_based + from .semi_auto import is_semi_auto, read_semi_params + + led = Ledger(database) + semi_perp = None + semi_opt = None + if is_semi_auto(led): + sp = read_semi_params(led) + semi_perp = float(sp["perp_unit"]) + semi_opt = float(sp["option_unit"]) + if not is_risk_based(led): + # 手动仓:半自动直接写入 永续:期权 配比名义 + database.set_setting("perp_qty_eth", f"{semi_perp:.4f}") + database.set_setting("option_qty_eth", f"{semi_opt:.4f}") rs = apply_risk_sizing_to_ledger( index_px=float(index_px), option_ask=float(option_ask), db=database, + perp_unit=semi_perp, + option_unit=semi_opt, ) if not rs.ok: return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail) diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index 30ceb48..517defd 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -428,12 +428,18 @@ def compute_risk_sizing( index_px: float, option_ask: float, db: Database | None = None, + perp_unit: float | None = None, + option_unit: float | None = None, ) -> RiskSizingResult: database = db or get_db() ledger = Ledger(database) s = get_settings() fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate) perp_u, opt_u, exit_u = read_risk_units(ledger) + if perp_unit is not None and float(perp_unit) > 0: + perp_u = float(perp_unit) + if option_unit is not None and float(option_unit) > 0: + opt_u = float(option_unit) basis_raw = ledger.get_setting_str( "risk_leverage_basis", s.risk_leverage_basis ) or s.risk_leverage_basis @@ -682,6 +688,8 @@ def apply_risk_sizing_to_ledger( index_px: float, option_ask: float, db: Database | None = None, + perp_unit: float | None = None, + option_unit: float | None = None, ) -> RiskSizingResult: """计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。持仓中拒绝改写。""" database = db or get_db() @@ -699,7 +707,13 @@ def apply_risk_sizing_to_ledger( detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算", ) - r = compute_risk_sizing(index_px=index_px, option_ask=option_ask, db=database) + r = compute_risk_sizing( + index_px=index_px, + option_ask=option_ask, + db=database, + perp_unit=perp_unit, + option_unit=option_unit, + ) if not r.ok: return r diff --git a/backend/app/strategy/selection.py b/backend/app/strategy/selection.py index 8953ce9..f30393e 100644 --- a/backend/app/strategy/selection.py +++ b/backend/app/strategy/selection.py @@ -46,6 +46,53 @@ def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None: return min(strikes, key=lambda s: (abs(s - mark_px), s)) +def pick_otm_strike( + strikes: list[float], + mark_px: float, + *, + option_side: str, + max_offset: float, +) -> float | None: + """ + 虚值:Call K>S、Put K spot + 1e-9 and float(s) - spot <= cap + 1e-9 + ] + elif side == "put": + cands = [ + float(s) + for s in strikes + if float(s) < spot - 1e-9 and spot - float(s) <= cap + 1e-9 + ] + else: + return None + if not cands: + return None + return min(cands, key=lambda s: (abs(s - spot), s)) + + +def is_otm(*, option_side: str, strike: float, mark_px: float) -> bool: + if mark_px <= 0: + return False + side = (option_side or "").strip().lower() + k = float(strike) + s = float(mark_px) + if side == "call": + return k > s + 1e-9 + if side == "put": + return k < s - 1e-9 + return False + + def pick_itm_or_atm_strike( strikes: list[float], mark_px: float, @@ -166,11 +213,14 @@ def select_option_pair( min_hours: float | None = None, now: datetime | None = None, option_side: str | None = None, + moneyness: str | None = None, + otm_max_offset: float | None = None, ) -> OptionPair | None: """ 选到期 + 行权价。 - option_side 为 call/put 时:按实值/平值选档(固定方向模式); + option_side 为 call/put 时:按 moneyness 选档(默认实值/平值,兼容固定方向); 否则仍选 ATM(现有规则)。 + moneyness: itm | atm | otm(仅半自动传入 otm/atm)。 """ complete = _complete_by_expiry(contracts) if not complete: @@ -194,13 +244,24 @@ def select_option_pair( ymd = eligible[0] ems, strikes_map = complete[ymd] + keys = list(strikes_map.keys()) side = (option_side or "").strip().lower() or None + mny = (moneyness or "").strip().lower() or None if side in ("call", "put"): - strike = pick_itm_or_atm_strike( - list(strikes_map.keys()), mark_px, option_side=side - ) + if mny == "otm": + strike = pick_otm_strike( + keys, + mark_px, + option_side=side, + max_offset=float(otm_max_offset or 0), + ) + elif mny == "atm": + strike = pick_atm_strike(keys, mark_px) + else: + # itm 或未指定:实值/平值(固定方向默认) + strike = pick_itm_or_atm_strike(keys, mark_px, option_side=side) else: - strike = pick_atm_strike(list(strikes_map.keys()), mark_px) + strike = pick_atm_strike(keys, mark_px) if strike is None: return None legs = strikes_map[strike] diff --git a/backend/app/strategy/semi_auto.py b/backend/app/strategy/semi_auto.py index c1eede9..081defc 100644 --- a/backend/app/strategy/semi_auto.py +++ b/backend/app/strategy/semi_auto.py @@ -13,6 +13,8 @@ PHASE_WAIT_HUMAN = "wait_human" REASON_POINTS = "semi_target_points" REASON_PERP_NET = "semi_perp_exit" TRADE_LOCK_KEY = "semi_trade_lock" +OTM_LEV_FLOOR = 180.0 +MONEYNESS_CHOICES = ("itm", "atm", "otm") def is_semi_auto(ledger: Ledger | None = None) -> bool: @@ -60,12 +62,23 @@ def lock_trade_params( view_side: str, option_move_points: float, perp_exit_unit: float, + moneyness: str = "otm", + otm_max_offset: float = 25.0, + perp_unit: float = 1.0, + option_unit: float = 4.0, ) -> None: + mny = str(moneyness or "otm").strip().lower() + if mny not in MONEYNESS_CHOICES: + mny = "otm" payload = { "group_id": str(group_id), "view_side": "short" if view_side == "short" else "long", "option_move_points": float(option_move_points), "perp_exit_unit": float(perp_exit_unit), + "moneyness": mny, + "otm_max_offset": float(otm_max_offset), + "perp_unit": float(perp_unit), + "option_unit": float(option_unit), } db.set_setting(TRADE_LOCK_KEY, json.dumps(payload, ensure_ascii=False)) @@ -74,6 +87,19 @@ def clear_trade_lock(db: Any) -> None: db.set_setting(TRADE_LOCK_KEY, "") +def _norm_moneyness(raw: str | None, default: str = "otm") -> str: + m = str(raw or default).strip().lower() + return m if m in MONEYNESS_CHOICES else default + + +def effective_min_leverage(moneyness: str, configured: float) -> float: + """虚值强制不低于 180;实值/平值用配置值。""" + lev = max(1.0, float(configured)) + if _norm_moneyness(moneyness) == "otm": + return max(lev, OTM_LEV_FLOOR) + return lev + + def read_semi_params( ledger: Ledger | None = None, *, @@ -96,7 +122,32 @@ def read_semi_params( led.get_setting_float("semi_perp_exit_unit", s.semi_perp_exit_unit) or s.semi_perp_exit_unit ) - # 持仓中优先用开仓时锁定的出场参数 + moneyness = _norm_moneyness( + led.get_setting_str("semi_moneyness", s.semi_moneyness), + s.semi_moneyness, + ) + otm_off = float( + led.get_setting_float("semi_otm_max_offset", s.semi_otm_max_offset) + or s.semi_otm_max_offset + ) + perp_u = float( + led.get_setting_float("semi_perp_unit", s.semi_perp_unit) or s.semi_perp_unit + ) + opt_u = float( + led.get_setting_float("semi_option_unit", s.semi_option_unit) + or s.semi_option_unit + ) + if perp_u <= 0: + perp_u = float(s.semi_perp_unit) + if opt_u <= 0: + opt_u = float(s.semi_option_unit) + min_lev_cfg = float( + led.get_setting_float( + "semi_min_option_leverage", s.semi_min_option_leverage + ) + or s.semi_min_option_leverage + ) + # 持仓中优先用开仓时锁定的出场/选约参数 lock = read_trade_lock(led) if lock and group_id and str(lock.get("group_id")) == str(group_id): view = str(lock.get("view_side") or view).strip().lower() @@ -105,8 +156,12 @@ def read_semi_params( try: move = float(lock.get("option_move_points", move)) exit_unit = float(lock.get("perp_exit_unit", exit_unit)) + otm_off = float(lock.get("otm_max_offset", otm_off)) + perp_u = float(lock.get("perp_unit", perp_u)) + opt_u = float(lock.get("option_unit", opt_u)) except (TypeError, ValueError): pass + moneyness = _norm_moneyness(str(lock.get("moneyness") or moneyness), moneyness) return { "enabled": is_semi_auto(led), "armed": is_armed(led), @@ -116,19 +171,21 @@ def read_semi_params( "perp_side": "short" if view == "long" else "long", "option_move_points": move, "perp_exit_unit": exit_unit, + "moneyness": moneyness, + "otm_max_offset": max(0.0, otm_off), + "perp_unit": perp_u, + "option_unit": opt_u, "min_option_hours": float( led.get_setting_float( "semi_min_option_hours", s.semi_min_option_hours ) or s.semi_min_option_hours ), - "min_option_leverage": float( - led.get_setting_float( - "semi_min_option_leverage", s.semi_min_option_leverage - ) - or s.semi_min_option_leverage + "min_option_leverage": effective_min_leverage(moneyness, min_lev_cfg), + "min_option_leverage_cfg": min_lev_cfg, + "trade_locked": bool( + lock and group_id and str(lock.get("group_id")) == str(group_id) ), - "trade_locked": bool(lock and group_id and str(lock.get("group_id")) == str(group_id)), } diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index c5b419c..f915d24 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -726,8 +726,10 @@ class StrategySession: s = self.settings min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats() fixed_on, fixed_perp = _fixed_direction() - # 半自动:强制看法方向 + 实值/平值 + 半自动选约门槛(须已授权) + # 半自动:强制看法方向 + 行权类型(itm/atm/otm) + 半自动选约门槛(须已授权) semi_on = is_semi_auto() + semi_mny: str | None = None + semi_otm_off: float | None = None if semi_on: if not is_armed(): return None @@ -737,6 +739,8 @@ class StrategySession: min_hours = float(sp["min_option_hours"]) min_lev = float(sp["min_option_leverage"]) atm_off_on = False + semi_mny = str(sp.get("moneyness") or "otm") + semi_otm_off = float(sp.get("otm_max_offset") or 0) opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None # 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%) if not self._apply_amplitude_first_gate(): @@ -766,12 +770,40 @@ class StrategySession: mark_px=underlying, expiry_ymd=ymd, option_side=opt_side_hint, + moneyness=semi_mny if semi_on else None, + otm_max_offset=semi_otm_off if semi_on else None, ) if pair is None: + if semi_on and semi_mny == "otm": + logger.info( + "skip expiry=%s no OTM within offset=%.1f for %s mark=%.2f", + ymd, + float(semi_otm_off or 0), + opt_side_hint, + underlying, + ) continue if fixed_on: - if not is_itm_or_atm( - option_side=opt_side_hint or "", + from .selection import is_otm + + side = opt_side_hint or "" + if semi_on and semi_mny == "otm": + if not is_otm( + option_side=side, + strike=pair.strike, + mark_px=underlying, + ): + continue + if ( + atm_open_offset(pair.strike, underlying) + > float(semi_otm_off or 0) + 1e-9 + ): + continue + elif semi_on and semi_mny == "atm": + # 平值:须为该到期最接近标的的档 + pass + elif not is_itm_or_atm( + option_side=side, strike=pair.strike, mark_px=underlying, ): diff --git a/backend/tests/test_semi_auto.py b/backend/tests/test_semi_auto.py index 3936015..f2fd38d 100644 --- a/backend/tests/test_semi_auto.py +++ b/backend/tests/test_semi_auto.py @@ -2,7 +2,13 @@ from __future__ import annotations -from app.strategy.semi_auto import REASON_PERP_NET, REASON_POINTS, check_semi_exits +from app.strategy.selection import pick_otm_strike +from app.strategy.semi_auto import ( + REASON_PERP_NET, + REASON_POINTS, + check_semi_exits, + effective_min_leverage, +) def test_semi_points_long_needs_net_positive() -> None: @@ -73,3 +79,22 @@ def test_semi_not_yet() -> None: risk_k=1, ) assert d.should_close is False + + +def test_otm_leverage_floor() -> None: + assert effective_min_leverage("otm", 100) == 180 + assert effective_min_leverage("otm", 200) == 200 + assert effective_min_leverage("itm", 100) == 100 + + +def test_pick_otm_within_offset() -> None: + strikes = [1800.0, 1825.0, 1850.0, 1875.0] + # Call 虚值:标的 1830 → 1850(20点)在 25 内;1875 超 + k = pick_otm_strike(strikes, 1830, option_side="call", max_offset=25) + assert k == 1850.0 + assert ( + pick_otm_strike(strikes, 1830, option_side="call", max_offset=15) is None + ) + # Put 虚值 + k2 = pick_otm_strike(strikes, 1830, option_side="put", max_offset=30) + assert k2 == 1825.0 diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index cc02752..e381524 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -1194,7 +1194,16 @@ export default function MonitorPage() { {detail.strat.semi_auto_enabled === true - ? `半自动·点${fmt(detail.strat.semi_option_move_points, 0)}/净≥${fmt( + ? `半自动·${ + detail.strat.semi_moneyness === "itm" + ? "实值" + : detail.strat.semi_moneyness === "atm" + ? "平值" + : "虚值" + } ${fmt(detail.strat.semi_perp_unit, 0)}:${fmt( + detail.strat.semi_option_unit, + 0, + )} ·点${fmt(detail.strat.semi_option_move_points, 0)}/净≥${fmt( detail.strat.semi_net_exit_target ?? detail.strat.semi_perp_exit_unit, 2, diff --git a/docs/半自动说明.md b/docs/半自动说明.md index e3a5043..647e5bf 100644 --- a/docs/半自动说明.md +++ b/docs/半自动说明.md @@ -36,7 +36,15 @@ | 多 (`long`) | 买 Call | 空 | | 空 (`short`) | 买 Put | 多 | -选约强制:**实值或平值**;最短剩余小时、最低杠杆用半自动本单参数(默认 ≥30h、杠杆 ≥100)。 +选约:本单可选 **实值 / 平值 / 虚值**(仅半自动)。 + +| 行权类型 | 规则 | +|----------|------| +| `itm` | 实值或平值(Call K≤S;Put K≥S) | +| `atm` | 该到期最接近标的的档 | +| `otm` | 虚值且 \|K−S\| ≤ `semi_otm_max_offset`(可配);杠杆门强制 ≥180,默认 200 | + +名义配比:`semi_perp_unit` : `semi_option_unit`(默认 **1:4**);以损定仓时作单位再乘 k,手动仓直接写入名义。最短剩余小时默认 ≥30h。 --- @@ -67,7 +75,11 @@ | `semi_option_move_points` | 50 | 顺向波动点数 | | `semi_perp_exit_unit` | 5 | 净利出场基数(×k) | | `semi_min_option_hours` | 30 | 选约最短剩余 | -| `semi_min_option_leverage` | 100 | 选约最低杠杆 | +| `semi_min_option_leverage` | 200 | 选约最低杠杆(虚值仍≥180) | +| `semi_moneyness` | otm | itm / atm / otm | +| `semi_otm_max_offset` | 25 | 虚值最大偏离(点) | +| `semi_perp_unit` | 1 | 永续名义单位 | +| `semi_option_unit` | 4 | 期权名义单位 | API(需登录): @@ -97,4 +109,5 @@ Fleet `strategy` 透出半自动字段。监控模式列在永期开启半自动 | 日期 | 说明 | |------|------| +| 2026-08-08 | 行权类型 itm/atm/otm;虚值偏离可配;永续:期权配比默认 1:4 | | 2026-08-08 | 初版:设置开关、首页本单、中控展示、出场与停机 | diff --git a/docs/更新说明.md b/docs/更新说明.md index c9b20ae..dfdf6bb 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,13 +5,23 @@ --- +## 2026-08-08 — 半自动行权类型与 1:4 配比 + +### 变更 + +1. 半自动本单增加行权类型:实值 / 平值 / 虚值;虚值最大偏离可配。 +2. 永续:期权名义配比可配,默认 1:4(以损作单位×k / 手动直写)。 +3. 虚值杠杆门强制 ≥180,默认 200。 + +--- + ## 2026-08-08 — 永期半自动(人工授权单笔) ### 变更 1. 系统设置「节奏」增加半自动开关;期期强制关闭半自动。 2. 首页「半自动 · 本单」:看法方向、波动点数、净利基数、选约门槛;授权/取消。 -3. 引擎:未授权 `wait_human`;授权后选约 ITM/ATM 开仓;出场为到点且净利>0 或净利≥基数×k;平完清授权停机。 +3. 引擎:未授权 `wait_human`;授权后选约并开仓;出场为到点且净利>0 或净利≥基数×k;平完清授权停机。 4. 中控模式列/详情透出半自动状态与目标。 5. 文档:`docs/半自动说明.md`;审计见 `docs/审计说明-2026-08-08-半自动.md`。 diff --git a/docs/策略说明.md b/docs/策略说明.md index dd951d0..3a38138 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -470,7 +470,11 @@ LIVE 特殊态:`option_closed_perp_pending`(期权已在交易所卖掉、 | `semi_option_move_points` | 50 | 半自动顺向波动点数 | | `semi_perp_exit_unit` | 5 | 半自动净利出场基数(×k) | | `semi_min_option_hours` | 30 | 半自动选约最短剩余小时 | -| `semi_min_option_leverage` | 100 | 半自动选约最低杠杆 | +| `semi_min_option_leverage` | 200 | 半自动选约最低杠杆(虚值≥180) | +| `semi_moneyness` | otm | 半自动行权类型 itm/atm/otm | +| `semi_otm_max_offset` | 25 | 半自动虚值最大偏离 | +| `semi_perp_unit` | 1 | 半自动永续名义单位 | +| `semi_option_unit` | 4 | 半自动期权名义单位 | --- diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index af1973a..797a1e0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -277,6 +277,10 @@ export type PlanState = { semi_perp_exit_unit?: number; semi_min_option_hours?: number; semi_min_option_leverage?: number; + semi_moneyness?: "itm" | "atm" | "otm"; + semi_otm_max_offset?: number; + semi_perp_unit?: number; + semi_option_unit?: number; semi_net_exit_target?: number; can_open: boolean; open_capacity?: { @@ -421,6 +425,10 @@ export type StrategySettings = { semi_perp_exit_unit?: number; semi_min_option_hours?: number; semi_min_option_leverage?: number; + semi_moneyness?: "itm" | "atm" | "otm"; + semi_otm_max_offset?: number; + semi_perp_unit?: number; + semi_option_unit?: number; close_bid_mark_max_pct?: number; residual_min_premium_pct?: number; residual_close_check_sec?: number; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 9bf2496..b6aa73a 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -116,9 +116,25 @@ export default function PlanPage() { const [semiMove, setSemiMove] = useState(50); const [semiExitU, setSemiExitU] = useState(5); const [semiMinH, setSemiMinH] = useState(30); - const [semiMinLev, setSemiMinLev] = useState(100); + const [semiMinLev, setSemiMinLev] = useState(200); + const [semiMny, setSemiMny] = useState<"itm" | "atm" | "otm">("otm"); + const [semiOtmOff, setSemiOtmOff] = useState(25); + const [semiPerpU, setSemiPerpU] = useState(1); + const [semiOptU, setSemiOptU] = useState(4); const [semiDirty, setSemiDirty] = useState(false); + const semiParamsBody = () => ({ + semi_view_side: semiView, + semi_option_move_points: semiMove, + semi_perp_exit_unit: semiExitU, + semi_min_option_hours: semiMinH, + semi_min_option_leverage: semiMinLev, + semi_moneyness: semiMny, + semi_otm_max_offset: semiOtmOff, + semi_perp_unit: semiPerpU, + semi_option_unit: semiOptU, + }); + async function refresh() { try { const [m, p] = await Promise.all([ @@ -132,7 +148,12 @@ export default function PlanPage() { setSemiMove(Number(p.semi_option_move_points ?? 50)); setSemiExitU(Number(p.semi_perp_exit_unit ?? 5)); setSemiMinH(Number(p.semi_min_option_hours ?? 30)); - setSemiMinLev(Number(p.semi_min_option_leverage ?? 100)); + setSemiMinLev(Number(p.semi_min_option_leverage ?? 200)); + const m = p.semi_moneyness; + setSemiMny(m === "itm" || m === "atm" || m === "otm" ? m : "otm"); + setSemiOtmOff(Number(p.semi_otm_max_offset ?? 25)); + setSemiPerpU(Number(p.semi_perp_unit ?? 1)); + setSemiOptU(Number(p.semi_option_unit ?? 4)); } setErr(""); } catch (e) { @@ -146,13 +167,7 @@ export default function PlanPage() { try { const p = await apiFetch("/api/plan/semi/params", { method: "PUT", - body: JSON.stringify({ - semi_view_side: semiView, - semi_option_move_points: semiMove, - semi_perp_exit_unit: semiExitU, - semi_min_option_hours: semiMinH, - semi_min_option_leverage: semiMinLev, - }), + body: JSON.stringify(semiParamsBody()), }); setPlan(p); setSemiDirty(false); @@ -170,13 +185,7 @@ export default function PlanPage() { if (semiDirty) { await apiFetch("/api/plan/semi/params", { method: "PUT", - body: JSON.stringify({ - semi_view_side: semiView, - semi_option_move_points: semiMove, - semi_perp_exit_unit: semiExitU, - semi_min_option_hours: semiMinH, - semi_min_option_leverage: semiMinLev, - }), + body: JSON.stringify(semiParamsBody()), }); setSemiDirty(false); } @@ -568,7 +577,7 @@ export default function PlanPage() {

半自动 · 本单

- 人工定方向与目标 → 授权后机器盯选约/开平 → 平完停,再授权下一单。选约:≥剩余时长与杠杆、实值或平值。顺向:开仓指数±点数且组合净利>0;逆向兑现:净利≥基数×k。平仓先期权后永续。 + 人工定方向/行权类型/配比 → 授权后机器盯开平 → 平完停。虚值杠杆门≥180(默认200)。顺向:开仓指数±点数且净利>0;逆向:净利≥基数×k。

@@ -587,6 +596,76 @@ export default function PlanPage() {
+
+ + +
+ {semiMny === "otm" ? ( +
+ + { + setSemiOtmOff(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+ ) : null} +
+ + { + setSemiPerpU(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiOptU(Number(e.target.value)); + setSemiDirty(true); + }} + /> +