From 4585dba3c35363e84e7cb95e5693131674fc3265 Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 8 Aug 2026 11:20:11 +0800 Subject: [PATCH] =?UTF-8?q?Add=20=E6=B0=B8=E6=9C=9F=E5=8D=8A=E8=87=AA?= =?UTF-8?q?=E5=8A=A8:=20human=20arm,=20machine=20open/close,=20then=20stop?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings toggle, Plan panel, Fleet monitor, exit locks and armed TOCTOU gates; docs and dual audits. Co-authored-by: Cursor --- backend/app/api/fleet.py | 8 + backend/app/api/plan.py | 57 ++++++- backend/app/api/settings.py | 71 ++++++++ backend/app/config.py | 8 + backend/app/main.py | 9 + backend/app/notify/wecom.py | 2 + backend/app/strategy/engine.py | 228 ++++++++++++++++++++++--- backend/app/strategy/semi_auto.py | 211 +++++++++++++++++++++++ backend/app/strategy/session.py | 12 ++ backend/tests/test_semi_auto.py | 75 ++++++++ control/frontend/src/pages/Monitor.tsx | 50 +++++- control/frontend/src/styles.css | 5 + docs/中控Fleet说明.md | 4 +- docs/半自动说明.md | 100 +++++++++++ docs/审计说明-2026-08-08-半自动.md | 40 +++++ docs/更新说明.md | 16 ++ docs/策略说明.md | 18 ++ frontend/src/api/client.ts | 17 ++ frontend/src/labels.ts | 2 + frontend/src/pages/Plan.tsx | 227 ++++++++++++++++++++++-- frontend/src/pages/Settings.tsx | 22 +++ frontend/src/styles/app.css | 9 + 22 files changed, 1147 insertions(+), 44 deletions(-) create mode 100644 backend/app/strategy/semi_auto.py create mode 100644 backend/tests/test_semi_auto.py create mode 100644 docs/半自动说明.md create mode 100644 docs/审计说明-2026-08-08-半自动.md diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index 185f8a1..f2b9875 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -290,6 +290,14 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di "exit_target_usdt": st.get("exit_target_usdt"), "net_profit_target": st.get("net_profit_target"), "premium_exit_multiple": _pick("premium_exit_multiple"), + "semi_auto_enabled": st.get("semi_auto_enabled"), + "semi_armed": st.get("semi_armed"), + "semi_view_side": st.get("semi_view_side"), + "semi_option_move_points": st.get("semi_option_move_points"), + "semi_perp_exit_unit": st.get("semi_perp_exit_unit"), + "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"), "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 2272021..c22b434 100644 --- a/backend/app/api/plan.py +++ b/backend/app/api/plan.py @@ -1,11 +1,12 @@ from __future__ import annotations import asyncio -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field +from ..models.db import get_db from ..strategy import get_engine from .auth import require_user @@ -32,6 +33,60 @@ async def plan_emergency(_user: Annotated[str, Depends(require_user)]) -> dict: return await get_engine().emergency_close() +class SemiArmBody(BaseModel): + armed: bool = True + + +class SemiParamsBody(BaseModel): + """首页半自动本单参数(不进系统设置表单主路径,但落同一 settings 表)。""" + + semi_view_side: Literal["long", "short"] | None = None + semi_option_move_points: float | None = Field(default=None, ge=1, le=5000) + 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) + + +@router.post("/semi/arm") +async def plan_semi_arm( + body: SemiArmBody, + _user: Annotated[str, Depends(require_user)], +) -> dict: + return get_engine().arm_semi(armed=bool(body.armed)) + + +@router.put("/semi/params") +async def plan_semi_params( + body: SemiParamsBody, + _user: Annotated[str, Depends(require_user)], +) -> dict: + eng = get_engine() + if eng.matcher.has_open_position(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="有未平仓,禁止修改半自动本单参数", + ) + from ..strategy.semi_auto import is_armed + + if is_armed(eng.ledger): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="已授权盯开,禁止改本单参数;请先取消授权", + ) + st = eng.db.fetchone("SELECT phase FROM strategy_state WHERE id=1") + phase = str(st["phase"] or "") if st else "" + if phase in ("wait_signal", "opening", "open", "closing", "liquidity_wait"): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"当前阶段 {phase} 禁止修改半自动本单参数", + ) + db = get_db() + data = body.model_dump(exclude_none=True) + for k, v in data.items(): + db.set_setting(k, str(v)) + return eng.state() + + class ResidualCloseBody(BaseModel): group_id: str = Field(min_length=1, max_length=128) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index b2fdea3..a1336ed 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -45,6 +45,13 @@ KEYS = ( "max_atm_open_offset", "fixed_direction_enabled", "fixed_perp_side", + "semi_auto_enabled", + "semi_armed", + "semi_view_side", + "semi_option_move_points", + "semi_perp_exit_unit", + "semi_min_option_hours", + "semi_min_option_leverage", "close_bid_mark_max_pct", "residual_min_premium_pct", "residual_close_check_sec", @@ -95,6 +102,13 @@ class StrategySettingsBody(BaseModel): max_atm_open_offset: float | None = Field(default=None, ge=0, le=100) fixed_direction_enabled: bool | None = None fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$") + semi_auto_enabled: bool | None = None + # semi_armed 仅允许经 /api/plan/semi/arm,不接受设置接口写入 + semi_view_side: str | None = Field(default=None, pattern="^(long|short)$") + semi_option_move_points: float | None = Field(default=None, ge=1, le=5000) + 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) 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) @@ -239,6 +253,49 @@ def _read_settings() -> dict: in ("long", "short") else "long" ), + "semi_auto_enabled": _as_bool( + db.get_setting("semi_auto_enabled", str(s.semi_auto_enabled)), + s.semi_auto_enabled, + ), + "semi_armed": _as_bool( + db.get_setting("semi_armed", str(s.semi_armed)), + s.semi_armed, + ), + "semi_view_side": ( + sv + if ( + sv := str( + db.get_setting("semi_view_side", s.semi_view_side) + or s.semi_view_side + ) + .strip() + .lower() + ) + in ("long", "short") + else "long" + ), + "semi_option_move_points": float( + db.get_setting( + "semi_option_move_points", str(s.semi_option_move_points) + ) + or s.semi_option_move_points + ), + "semi_perp_exit_unit": float( + db.get_setting("semi_perp_exit_unit", str(s.semi_perp_exit_unit)) + or s.semi_perp_exit_unit + ), + "semi_min_option_hours": float( + db.get_setting( + "semi_min_option_hours", str(s.semi_min_option_hours) + ) + or s.semi_min_option_hours + ), + "semi_min_option_leverage": float( + db.get_setting( + "semi_min_option_leverage", str(s.semi_min_option_leverage) + ) + or s.semi_min_option_leverage + ), "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 @@ -418,6 +475,8 @@ async def put_strategy_settings( db = get_db() s = get_settings() data = body.model_dump(exclude_none=True) + # 授权态只经 /api/plan/semi/arm;设置接口不可伪造 armed=true + data.pop("semi_armed", None) equity_to_apply: float | None = None switch_to: str | None = None old_hedge = str( @@ -491,6 +550,12 @@ async def put_strategy_settings( "oo_reward_ratio", "oo_budget_cushion", "oo_strike_max_dev_pct", + "semi_view_side", + "semi_option_move_points", + "semi_perp_exit_unit", + "semi_min_option_hours", + "semi_min_option_leverage", + "semi_auto_enabled", ) hit = [k for k in locked_keys if k in data] if hit: @@ -516,6 +581,12 @@ async def put_strategy_settings( data["exit_mode"] = "fixed_usdt" data.pop("perp_qty_eth", None) data["fixed_direction_enabled"] = False + data["semi_auto_enabled"] = False + data["semi_armed"] = False + + # 开关半自动时强制清授权,避免陈旧 armed 或关半自动后落入全自动误开 + if "semi_auto_enabled" in data: + data["semi_armed"] = False sizing_mode = str( data.get( diff --git a/backend/app/config.py b/backend/app/config.py index 83e85da..aa03a48 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -95,6 +95,14 @@ class Settings(BaseSettings): # 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值 fixed_direction_enabled: bool = False fixed_perp_side: str = "long" # long|short;long→买Put,short→买Call + # 半自动:系统设置开关;首页授权后盯开盯平,平完停 + semi_auto_enabled: bool = False + semi_armed: bool = False # 是否已授权本单(平完清零) + semi_view_side: str = "long" # long=买Call+永续空;short=买Put+永续多 + 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 close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差% # 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出 residual_min_premium_pct: float = 20.0 diff --git a/backend/app/main.py b/backend/app/main.py index 4a0411f..8a64436 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -47,6 +47,15 @@ async def lifespan(app: FastAPI): settings = load_runtime_settings() engine = StrategyEngine() set_engine(engine) + # 半自动:无仓时清陈旧授权,须首页重新「授权开下一单」 + try: + from .strategy.semi_auto import clear_armed_if_flat + + clear_armed_if_flat( + db, has_open_position=engine.matcher.has_open_position() + ) + except Exception: + logger.exception("clear stale semi_armed on startup failed") # LIVE:进程启动后不自动真下单,须人工点「启动」 if not get_settings().is_sim: try: diff --git a/backend/app/notify/wecom.py b/backend/app/notify/wecom.py index 62f6bb7..642b930 100644 --- a/backend/app/notify/wecom.py +++ b/backend/app/notify/wecom.py @@ -30,6 +30,8 @@ _FAULT_DEDUP_SEC = 300.0 CLOSE_REASON_ZH: dict[str, str] = { "expiry": "到期结算全平", "target_perp_only": "净盈利达标·只平永续(期权归档到期)", + "semi_target_points": "半自动·标的到点且组合净利>0", + "semi_perp_exit": "半自动·净利基数达标", "fixed_usdt": "固定净盈利达标·双腿全平", "premium_multiple": "权利金倍数达标·双腿全平", "emergency": "紧急全平", diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index bc2eb33..952db30 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -233,7 +233,7 @@ class StrategyEngine: "funds_ok": False, "leverage": leverage, } - return { + out: dict[str, Any] = { "running": bool(row["running"]), "phase": row["phase"], "rounds_done": self._closed_rounds(), @@ -355,6 +355,24 @@ class StrategyEngine: "show_manual_trade_buttons", False ), } + try: + from .semi_auto import read_semi_params + + sp = read_semi_params(self.ledger) + k_eff = float(risk_last_k) if risk_last_k and float(risk_last_k) > 0 else 1.0 + out["semi_auto_enabled"] = bool(sp.get("enabled")) + out["semi_armed"] = bool(sp.get("armed")) + out["semi_view_side"] = sp.get("view_side") + out["semi_option_side"] = sp.get("option_side") + out["semi_perp_side"] = sp.get("perp_side") + out["semi_option_move_points"] = sp.get("option_move_points") + 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_net_exit_target"] = float(sp["perp_exit_unit"]) * k_eff + except Exception: + logger.exception("semi params for state() failed") + return out def _set_state(self, **kwargs: Any) -> None: cols = [] @@ -505,8 +523,26 @@ class StrategyEngine: return int(row["n"] or 0) if row else 0 def _after_close(self) -> None: + from .semi_auto import ( + PHASE_WAIT_HUMAN, + clear_trade_lock, + is_semi_auto, + set_armed, + ) + s = get_settings() rounds = self._closed_rounds() + clear_trade_lock(self.db) + if is_semi_auto(self.ledger): + # 半自动:平完停,清授权,等人工再开下一单 + set_armed(self.db, False) + self._set_state( + rounds_done=rounds, + phase=PHASE_WAIT_HUMAN, + rest_until_ms=None, + last_error=None, + ) + return rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds) rest_until = int(time.time() * 1000) + rest_sec * 1000 self._set_state( @@ -515,6 +551,50 @@ class StrategyEngine: rest_until_ms=rest_until, ) + def arm_semi(self, *, armed: bool = True) -> dict[str, Any]: + """首页半自动:授权/取消本单盯开。""" + from .semi_auto import PHASE_WAIT_HUMAN, is_semi_auto, set_armed + + if not is_semi_auto(self.ledger): + self._set_state(last_error="未开启半自动模式(系统设置)") + return self.state() + hm = str( + self.ledger.get_setting_str("hedge_mode", "perp_option") or "perp_option" + ).strip().lower() + if hm == "option_option": + self._set_state(last_error="期期模式不支持半自动") + return self.state() + if self.matcher.has_open_position(): + self._set_state(last_error="有持仓时不能改授权;请先平仓") + return self.state() + st_row = self.db.fetchone("SELECT phase FROM strategy_state WHERE id=1") + cur_phase = str(st_row["phase"] or "") if st_row else "" + if not armed and cur_phase == "opening": + self._set_state(last_error="开仓落单中,无法取消授权") + return self.state() + if armed: + s = get_settings() + if not s.is_sim: + ok, reason = live_ready() + if not ok: + self._set_state( + running=0, + phase=PHASE_WAIT_HUMAN, + last_error=f"授权失败(LIVE 未就绪):{reason}", + ) + return self.state() + set_armed(self.db, bool(armed)) + if armed: + if not self.state().get("running"): + # 授权时自动拉起循环(仅盯开;未授权不会开);LIVE 已过 live_ready + self._set_state(running=1, phase="idle", last_error=None) + self.ensure_loop() + else: + self._set_state(phase="idle", last_error=None) + else: + self._set_state(phase=PHASE_WAIT_HUMAN, last_error=None) + return self.state() + def _count_groups_for_day(self, wkey: str) -> int: rows = self.db.fetchall( "SELECT group_id FROM groups WHERE group_id LIKE ?", @@ -577,11 +657,8 @@ class StrategyEngine: r = await asyncio.to_thread(abandon, reason="target_perp_only") if r.ok: self._note_retry_result(kind, ok=True) - self._after_close() - self._set_state( - last_error=None, - phase="resting", - ) + # _after_close 已写入 resting / wait_human,勿再覆盖 phase + self.enter_rest_after_close() try: from ..notify import wecom @@ -616,15 +693,22 @@ class StrategyEngine: except Exception: logger.exception("wecom notify_close failed") elif r.liquidity_wait and not bypass_liquidity: - # 等待期间若已变成远虚,下一 tick 走归档 - if self.matcher.option_is_deep_otm(): + # 等待期间若已变成远虚,下一 tick 走归档(半自动要求双腿全平,不归档) + if ( + reason + not in ( + "semi_target_points", + "semi_perp_exit", + ) + and self.matcher.option_is_deep_otm() + ): r2 = await asyncio.to_thread( self.matcher.close_perp_abandon_option, reason="target_perp_only", ) if r2.ok: self._note_retry_result(kind, ok=True) - self._after_close() + self.enter_rest_after_close() try: from ..notify import wecom @@ -831,8 +915,7 @@ class StrategyEngine: r = await asyncio.to_thread(repair) if r.ok: self._note_retry_result("half_open", ok=True) - self._after_close() - self._set_state(phase="resting", last_error=None) + self.enter_rest_after_close() try: from ..notify import wecom @@ -861,9 +944,12 @@ class StrategyEngine: if st_pos == "open": upl = self.matcher.unrealized() from .exits import lock_trade_exit_target, read_locked_exit_target + from .semi_auto import check_semi_exits, is_semi_auto, read_semi_params locked_exit = read_locked_exit_target(upl) - if locked_exit is None and upl.get("group_id"): + if locked_exit is None and upl.get("group_id") and not is_semi_auto( + self.ledger + ): try: locked_exit = lock_trade_exit_target( self.db, @@ -873,14 +959,54 @@ class StrategyEngine: except Exception: logger.exception("backfill exit lock failed") expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl)) - decision = check_exits( - net_pnl=float(upl.get("net_pnl") or 0), - exit_mode=exit_mode, - net_profit_target=net_target, - premium_exit_multiple=prem_mult, - initial_premium=float(upl.get("initial_premium") or 0), - locked_exit_target=locked_exit, - ) + if is_semi_auto(self.ledger): + sp = read_semi_params( + self.ledger, + group_id=str(upl["group_id"]) if upl.get("group_id") else None, + ) + rk = float( + self.ledger.get_setting_float("risk_last_k", 1.0) or 1.0 + ) + if rk <= 0: + rk = 1.0 + semi_d = check_semi_exits( + net_pnl=float(upl.get("net_pnl") or 0), + entry_index=( + float(upl["entry_index_px"]) + if upl.get("entry_index_px") is not None + else None + ), + index_px=( + float(upl["index_px"]) + if upl.get("index_px") is not None + else None + ), + view_side=str(sp["view_side"]), + option_move_points=float(sp["option_move_points"]), + perp_exit_unit=float(sp["perp_exit_unit"]), + risk_k=rk, + ) + # 复用 ExitDecision 形态 + from .exits import ExitDecision + + decision = ExitDecision( + bool(semi_d.should_close), + str(semi_d.reason or ""), + float(semi_d.net_target or 0), + ) + if semi_d.detail and not semi_d.should_close: + # 到点但净利≤0 等提示,不刷屏:仅非空时写入 + if "净利≤0" in semi_d.detail: + self._set_state(last_error=semi_d.detail) + else: + decision = check_exits( + net_pnl=float(upl.get("net_pnl") or 0), + exit_mode=exit_mode, + net_profit_target=net_target, + premium_exit_multiple=prem_mult, + initial_premium=float(upl.get("initial_premium") or 0), + locked_exit_target=locked_exit, + ) pending_close = st["phase"] in ("liquidity_wait", "closing") # 曾因达标进入流动性等待,但当前估价已跌破目标:取消挂起,避免硬平出亏损却仍记「达标」 if ( @@ -906,7 +1032,15 @@ class StrategyEngine: else: reason = decision.reason or "liquidity_retry" bypass = False - abandon = bool(decision.should_close or pending_close) + # 半自动要求双腿全平(先期权后永续),禁止远虚只平永续 + semi_full = reason in ( + "semi_target_points", + "semi_perp_exit", + ) + abandon = ( + bool(decision.should_close or pending_close) + and not semi_full + ) rkind = "liquidity" if pending_close else "close" if is_oo and decision.should_close and not expired.should_close: # 期期达标:只平盈利腿,亏损腿残留 @@ -918,8 +1052,7 @@ class StrategyEngine: close_oo, reason="target_oo_win" ) if r.ok: - self._enter_rest_after_close() - self._set_state(phase="resting", last_error=None) + self.enter_rest_after_close() else: self._set_state( phase="liquidity_wait", @@ -955,8 +1088,7 @@ class StrategyEngine: bypass_liquidity=True, ) if r.ok: - self._enter_rest_after_close() - self._set_state(phase="resting", last_error=None) + self.enter_rest_after_close() else: self._set_state( phase="liquidity_wait", @@ -1006,6 +1138,14 @@ class StrategyEngine: if st["phase"] in ("stopped", "outside_window"): self._set_state(phase="idle") + from .semi_auto import PHASE_WAIT_HUMAN, is_armed, is_semi_auto + + # 半自动未授权:停在 wait_human,不进入选约/开仓 + if is_semi_auto(self.ledger) and not is_armed(self.ledger): + if st["phase"] != PHASE_WAIT_HUMAN: + self._set_state(phase=PHASE_WAIT_HUMAN, last_error=None) + return + skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends) if not can_open_new(skip_weekends=skip_weekends): self._set_state( @@ -1096,6 +1236,22 @@ class StrategyEngine: ) return + # 半自动:选约异步窗口后再次确认授权,防止取消授权后仍开仓 + from .semi_auto import ( + PHASE_WAIT_HUMAN, + is_armed, + is_semi_auto, + lock_trade_params, + read_semi_params, + ) + + if is_semi_auto(self.ledger) and not is_armed(self.ledger): + self._set_state( + phase=PHASE_WAIT_HUMAN, + last_error="半自动授权已取消,已中止开仓", + ) + return + # 选约后:定仓落库 → 兑 USDC → 资金门 fail-closed(与手动开仓同一管道) from .open_pipeline import size_and_gate @@ -1128,6 +1284,13 @@ class StrategyEngine: if st["phase"] == "wait_funds": self._set_state(phase="idle", last_error=None) + if is_semi_auto(self.ledger) and not is_armed(self.ledger): + self._set_state( + phase=PHASE_WAIT_HUMAN, + last_error="半自动授权已取消,已中止开仓", + ) + return + self._set_state(phase="opening", last_error=None) wkey = window_key() count = self._count_groups_for_day(wkey) @@ -1150,6 +1313,12 @@ class StrategyEngine: except Exception: pass return + if is_semi_auto(self.ledger) and not is_armed(self.ledger): + self._set_state( + phase=PHASE_WAIT_HUMAN, + last_error="半自动授权已取消,已中止开仓", + ) + return if oo: open_fn = getattr(self.matcher, "open_oo_group", None) if open_fn is None: @@ -1184,6 +1353,15 @@ class StrategyEngine: expiry_ymd=pick.pair.expiry_ymd, ) if r.ok: + if is_semi_auto(self.ledger): + sp_lock = read_semi_params(self.ledger) + lock_trade_params( + self.db, + group_id=gid, + 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"]), + ) self._set_state(phase="open", last_error=None) try: from ..notify import wecom diff --git a/backend/app/strategy/semi_auto.py b/backend/app/strategy/semi_auto.py new file mode 100644 index 0000000..c1eede9 --- /dev/null +++ b/backend/app/strategy/semi_auto.py @@ -0,0 +1,211 @@ +"""半自动:人工定方向/目标并授权 → 机器盯开盯平 → 平完停等人工。""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from ..config import get_settings +from ..sim.ledger import Ledger + +PHASE_WAIT_HUMAN = "wait_human" +REASON_POINTS = "semi_target_points" +REASON_PERP_NET = "semi_perp_exit" +TRADE_LOCK_KEY = "semi_trade_lock" + + +def is_semi_auto(ledger: Ledger | None = None) -> bool: + led = ledger or Ledger() + s = get_settings() + return bool( + led.get_setting_bool("semi_auto_enabled", s.semi_auto_enabled) + ) + + +def is_armed(ledger: Ledger | None = None) -> bool: + led = ledger or Ledger() + return bool(led.get_setting_bool("semi_armed", False)) + + +def set_armed(db: Any, armed: bool) -> None: + db.set_setting("semi_armed", "true" if armed else "false") + + +def clear_armed_if_flat(db: Any, *, has_open_position: bool) -> None: + """进程重启等:无仓时清授权,避免陈旧 armed 自动开仓。""" + if not has_open_position: + set_armed(db, False) + + +def read_trade_lock(ledger: Ledger | None = None) -> dict[str, Any] | None: + led = ledger or Ledger() + raw = led.get_setting_str(TRADE_LOCK_KEY, "") or "" + raw = str(raw).strip() + if not raw: + return None + try: + data = json.loads(raw) + except Exception: + return None + if not isinstance(data, dict) or not data.get("group_id"): + return None + return data + + +def lock_trade_params( + db: Any, + *, + group_id: str, + view_side: str, + option_move_points: float, + perp_exit_unit: float, +) -> None: + 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), + } + db.set_setting(TRADE_LOCK_KEY, json.dumps(payload, ensure_ascii=False)) + + +def clear_trade_lock(db: Any) -> None: + db.set_setting(TRADE_LOCK_KEY, "") + + +def read_semi_params( + ledger: Ledger | None = None, + *, + group_id: str | None = None, +) -> dict[str, Any]: + led = ledger or Ledger() + s = get_settings() + view = str( + led.get_setting_str("semi_view_side", s.semi_view_side) or s.semi_view_side + ).strip().lower() + if view not in ("long", "short"): + view = "long" + move = float( + led.get_setting_float( + "semi_option_move_points", s.semi_option_move_points + ) + or s.semi_option_move_points + ) + exit_unit = float( + led.get_setting_float("semi_perp_exit_unit", s.semi_perp_exit_unit) + or s.semi_perp_exit_unit + ) + # 持仓中优先用开仓时锁定的出场参数 + 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() + if view not in ("long", "short"): + view = "long" + try: + move = float(lock.get("option_move_points", move)) + exit_unit = float(lock.get("perp_exit_unit", exit_unit)) + except (TypeError, ValueError): + pass + return { + "enabled": is_semi_auto(led), + "armed": is_armed(led), + "view_side": view, + # 看法多 → Call+永续空;看法空 → Put+永续多 + "option_side": "call" if view == "long" else "put", + "perp_side": "short" if view == "long" else "long", + "option_move_points": move, + "perp_exit_unit": exit_unit, + "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 + ), + "trade_locked": bool(lock and group_id and str(lock.get("group_id")) == str(group_id)), + } + + +@dataclass(slots=True) +class SemiExitDecision: + should_close: bool + reason: str = "" + detail: str = "" + target_index: float | None = None + net_target: float | None = None + + +def check_semi_exits( + *, + net_pnl: float, + entry_index: float | None, + index_px: float | None, + view_side: str, + option_move_points: float, + perp_exit_unit: float, + risk_k: float = 1.0, +) -> SemiExitDecision: + """ + 顺方向:标的波动达到目标点 且 组合净利>0 → 全平。 + 逆方向兑现:组合净利 ≥ 永续出场基数×k → 全平。 + 流动性在 close_group 内再验;平仓顺序已是先期权后永续。 + """ + view = (view_side or "long").strip().lower() + if view not in ("long", "short"): + view = "long" + move = max(0.0, float(option_move_points)) + k = float(risk_k) if risk_k and risk_k > 0 else 1.0 + net_tgt = max(0.0, float(perp_exit_unit)) * k + net = float(net_pnl) + + # 逆方向 / 对冲兑现:净利达标即可离场(不必等点位) + if net_tgt > 0 and net + 1e-9 >= net_tgt: + return SemiExitDecision( + True, + REASON_PERP_NET, + f"半自动·净利≥{net_tgt:.2f}U(基数×k)", + net_target=net_tgt, + ) + + if entry_index is None or index_px is None: + return SemiExitDecision(False, "", "缺指数") + entry = float(entry_index) + idx = float(index_px) + if entry <= 0 or idx <= 0 or move <= 0: + return SemiExitDecision(False, "", "点位无效") + + if view == "long": + target_idx = entry + move + hit = idx + 1e-9 >= target_idx + else: + target_idx = entry - move + hit = idx - 1e-9 <= target_idx + + if hit and net > 0: + return SemiExitDecision( + True, + REASON_POINTS, + f"半自动·标的到{target_idx:.2f}且组合净利>0", + target_index=target_idx, + net_target=0.0, + ) + if hit and net <= 0: + return SemiExitDecision( + False, + "", + f"已到点位{target_idx:.2f}但组合净利≤0({net:.2f}),继续持有", + target_index=target_idx, + ) + return SemiExitDecision( + False, + "", + f"未到点位(目标{target_idx:.2f})", + target_index=target_idx, + net_target=net_tgt, + ) diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index fa62379..c5b419c 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -721,10 +721,22 @@ class StrategySession: def _pick_for_open_perp(self) -> OpenPick | None: from .signal import decide, decide_fixed + from .semi_auto import is_armed, is_semi_auto, read_semi_params s = self.settings min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats() fixed_on, fixed_perp = _fixed_direction() + # 半自动:强制看法方向 + 实值/平值 + 半自动选约门槛(须已授权) + semi_on = is_semi_auto() + if semi_on: + if not is_armed(): + return None + sp = read_semi_params() + fixed_on = True + fixed_perp = str(sp["perp_side"]) + min_hours = float(sp["min_option_hours"]) + min_lev = float(sp["min_option_leverage"]) + atm_off_on = False opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None # 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%) if not self._apply_amplitude_first_gate(): diff --git a/backend/tests/test_semi_auto.py b/backend/tests/test_semi_auto.py new file mode 100644 index 0000000..3936015 --- /dev/null +++ b/backend/tests/test_semi_auto.py @@ -0,0 +1,75 @@ +"""半自动出场与参数。""" + +from __future__ import annotations + +from app.strategy.semi_auto import REASON_PERP_NET, REASON_POINTS, check_semi_exits + + +def test_semi_points_long_needs_net_positive() -> None: + # 到点但净利≤0 → 不平 + d = check_semi_exits( + net_pnl=-1.0, + entry_index=1800, + index_px=1850, + view_side="long", + option_move_points=50, + perp_exit_unit=5, + risk_k=1, + ) + assert d.should_close is False + assert "净利≤0" in d.detail + + d2 = check_semi_exits( + net_pnl=1.0, + entry_index=1800, + index_px=1850, + view_side="long", + option_move_points=50, + perp_exit_unit=5, + risk_k=1, + ) + assert d2.should_close is True + assert d2.reason == REASON_POINTS + + +def test_semi_points_short() -> None: + d = check_semi_exits( + net_pnl=2.0, + entry_index=1800, + index_px=1750, + view_side="short", + option_move_points=50, + perp_exit_unit=5, + risk_k=1, + ) + assert d.should_close is True + assert d.reason == REASON_POINTS + + +def test_semi_net_exit_with_k() -> None: + # 未到点,但净利 ≥ 5×2=10 + d = check_semi_exits( + net_pnl=10.0, + entry_index=1800, + index_px=1810, + view_side="long", + option_move_points=50, + perp_exit_unit=5, + risk_k=2, + ) + assert d.should_close is True + assert d.reason == REASON_PERP_NET + assert d.net_target == 10.0 + + +def test_semi_not_yet() -> None: + d = check_semi_exits( + net_pnl=3.0, + entry_index=1800, + index_px=1820, + view_side="long", + option_move_points=50, + perp_exit_unit=5, + risk_k=1, + ) + assert d.should_close is False diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index c17a097..cc02752 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -162,6 +162,8 @@ const CLOSE_REASON_ZH: Record = { fixed_usdt: "固定净盈利达标·双腿全平", premium_multiple: "权利金倍数达标·双腿全平", target_perp_only: "净盈利达标·只平永续(期权归档)", + semi_target_points: "半自动·标的到点且组合净利>0", + semi_perp_exit: "半自动·净利基数达标", expiry: "到期结算", emergency: "紧急全平", manual: "手动平仓", @@ -321,7 +323,9 @@ function hedgeModeShort(strat: Record): string { const hm = String(strat.hedge_mode || "perp_option") .trim() .toLowerCase(); - return hm === "option_option" ? "期期" : "永期"; + if (hm === "option_option") return "期期"; + if (strat.semi_auto_enabled === true) return "半自动"; + return "永期"; } function modeParts( @@ -354,7 +358,15 @@ function ModeLabelView({ return ( <> {p.head}/ - + {p.hedge} @@ -366,7 +378,11 @@ function modeLabelTitle(mode: string, strat: Record): string { const riskBased = strat.sizing_mode === "risk_based" || strat.risk_based === true; const hedge = - hedgeModeShort(strat) === "期期" ? "期期=双期权对冲" : "永期=永续+期权"; + hedgeModeShort(strat) === "期期" + ? "期期=双期权对冲" + : hedgeModeShort(strat) === "半自动" + ? "半自动=人工看法·机器盯盘开平·平仓后停" + : "永期=永续+期权"; return `${label}(${riskBased ? "A=以损定仓" : "B=手动开仓"};${hedge})`; } @@ -1126,7 +1142,23 @@ export default function MonitorPage() { {r.openRatio ? {r.openRatio} : null} {r.leverage} - {detailRunning ? "运行中" : "已停"} · {detail.phase} + {detailRunning ? "运行中" : "已停"} ·{" "} + {detail.phase === "wait_human" + ? "等待人工授权" + : detail.phase} + {detail.strat.semi_auto_enabled === true ? ( + + {" "} + · 半自动 + {detail.strat.semi_armed === true + ? "已授权" + : "未授权"} + · + {detail.strat.semi_view_side === "short" + ? "空" + : "多"} + + ) : null} - {fmt(detail.strat.exit_target_usdt, 2)} USDT + + {detail.strat.semi_auto_enabled === true + ? `半自动·点${fmt(detail.strat.semi_option_move_points, 0)}/净≥${fmt( + detail.strat.semi_net_exit_target ?? + detail.strat.semi_perp_exit_unit, + 2, + )}U` + : `${fmt(detail.strat.exit_target_usdt, 2)} USDT`} + {fmtExPx("perp", detail.index_px)} diff --git a/control/frontend/src/styles.css b/control/frontend/src/styles.css index c93265a..547da04 100644 --- a/control/frontend/src/styles.css +++ b/control/frontend/src/styles.css @@ -424,6 +424,11 @@ input { color: var(--muted); } +.mode-hedge-semi { + color: #5ec8ff; + font-weight: 700; +} + .dot { width: 9px; height: 9px; diff --git a/docs/中控Fleet说明.md b/docs/中控Fleet说明.md index 2c5c8e1..bd355bf 100644 --- a/docs/中控Fleet说明.md +++ b/docs/中控Fleet说明.md @@ -138,9 +138,9 @@ bash /opt/eth_hedge_sim/control/deploy/update.sh - **实时推送**:浏览器通过 SSE(`GET /api/nodes/status/stream`)接收状态;中控约每秒**并行**拉取各策略机 `/api/fleet/status`,有变化才推送;断线自动重连。工具栏显示「实时 · 1s」。 - **刷新**:仍可手动走 `/api/nodes/status/all` 拉一次。 -- **卡片**:在线/离线、SIM/LIVE、阶段、持仓、轮次、行情、Token 状态。 +- **卡片**:在线/离线、SIM/LIVE、阶段、持仓、轮次、行情、Token 状态;模式列在开启半自动时显示「半自动」(否则永期/期期)。 - **运行中**:卡片绿色;底部按钮显示「运行中」且不可点启动。 -- **点击卡片**:放大弹层 — 策略详情 + **整体统计**(按需拉 `/api/nodes/{id}/stats` → 策略机 `/api/fleet/stats`,同策略页统计)+ 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗。 +- **点击卡片**:放大弹层 — 策略详情 + **整体统计**(按需拉 `/api/nodes/{id}/stats` → 策略机 `/api/fleet/stats`,同策略页统计)+ 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗;半自动时状态含授权/方向,出场列显示点数与净利目标。 - **登录策略机**:免密新标签打开策略页。 - **更新代码 / 勾选更新**:**二次确认**后执行;会 reload 进程,**不会**自动 start。 diff --git a/docs/半自动说明.md b/docs/半自动说明.md new file mode 100644 index 0000000..e3a5043 --- /dev/null +++ b/docs/半自动说明.md @@ -0,0 +1,100 @@ +# 半自动模式说明 + +> 永期(永续 + 期权)专用。期期模式不可用。 +> 与「手动开一组」(测试按钮)无关:半自动是**人工定看法并授权 → 机器盯开盯平 → 平完停**。 + +--- + +## 1. 流程 + +```text +系统设置开启半自动 + → 首页出现「半自动 · 本单」面板 + → 人工设:方向 / 波动点数 / 净利基数 / 选约门槛 + → 「授权开下一单」 + → 机器:选约 → 开仓 → 盯出场 → 双腿全平 + → phase=wait_human,清授权,不自动开下一单 + → 人工再授权才进入下一单 +``` + +| 阶段 | 含义 | +|------|------| +| `wait_human` | 未授权,不开仓;有仓仍可盯平 | +| 授权后 `idle`/`opening`/`open` | 与全自动相同状态机,但出场走半自动规则 | +| 平仓后 | 强制回 `wait_human`,`semi_armed=false`,清本单出场锁 | +| 进程重启 | 无仓时清 `semi_armed`,须重新授权 | +| 开仓成功 | 锁定本单 `view/点数/净利基数`(持仓期不跟设置漂移) | + +安全门:选约异步后、落 `opening` 前再次校验授权;取消授权可中止未成交开仓。LIVE 授权前过 `live_ready`。关闭/开启半自动设置时强制清授权。 + +--- + +## 2. 方向(看法) + +| 看法 | 期权 | 永续 | +|------|------|------| +| 多 (`long`) | 买 Call | 空 | +| 空 (`short`) | 买 Put | 多 | + +选约强制:**实值或平值**;最短剩余小时、最低杠杆用半自动本单参数(默认 ≥30h、杠杆 ≥100)。 + +--- + +## 3. 出场 + +净利口径与全自动一致:可平盘口估价 − 开仓费 − 预估平仓费(非乐观 mark)。 + +| 规则 | 条件 | close_reason | +|------|------|----------------| +| 顺方向 | 指数相对**开仓指数**达到目标点数,**且**组合净利 > 0 | `semi_target_points` | +| 逆方向 / 兑现 | 组合净利 ≥ `semi_perp_exit_unit × k`(k=以损倍数,手动仓视为 1) | `semi_perp_exit` | + +- 到点但净利 ≤ 0:继续持有(状态提示,不平)。 +- 流动性不足:进入 `liquidity_wait`;回落未达标则取消挂起。 +- 半自动平仓**禁止**远虚「只平永续、期权归档」;顺序仍为**先期权后永续**。 + +首页预览「指数 → 到点」用**当前指数**示意;真实触发达标用**开仓指数**。 + +--- + +## 4. 设置键 + +| Key | 默认 | 说明 | +|-----|------|------| +| `semi_auto_enabled` | false | 系统设置 · 节奏 | +| `semi_armed` | false | 是否已授权本单(平完清零) | +| `semi_view_side` | long | long / short | +| `semi_option_move_points` | 50 | 顺向波动点数 | +| `semi_perp_exit_unit` | 5 | 净利出场基数(×k) | +| `semi_min_option_hours` | 30 | 选约最短剩余 | +| `semi_min_option_leverage` | 100 | 选约最低杠杆 | + +API(需登录): + +- `POST /api/plan/semi/arm` `{ "armed": true|false }` +- `PUT /api/plan/semi/params` 本单参数(有持仓 409) + +--- + +## 5. 中控 + +Fleet `strategy` 透出半自动字段。监控模式列在永期开启半自动时显示「半自动」;详情状态含授权/方向,出场列显示点数与净利目标。 + +--- + +## 6. 与全自动差异 + +| | 全自动 | 半自动 | +|--|--------|--------| +| 方向 | 盘口信号或固定方向 | 人工看法 | +| 出场 | fixed_usdt / 权利金倍数(可锁定) | 点数+净利>0,或基数×k | +| 轮间 | 休息后自动再开 | 平完停,须再授权 | +| 期期 | 可用 | 强制关闭半自动 | + +--- + +## 7. 修订 + +| 日期 | 说明 | +|------|------| +| 2026-08-08 | 初版:设置开关、首页本单、中控展示、出场与停机 | diff --git a/docs/审计说明-2026-08-08-半自动.md b/docs/审计说明-2026-08-08-半自动.md new file mode 100644 index 0000000..5f6209c --- /dev/null +++ b/docs/审计说明-2026-08-08-半自动.md @@ -0,0 +1,40 @@ +# 审计说明 — 2026-08-08 永期半自动 + +## 范围 + +半自动:`semi_auto.py`、引擎授权/出场/`wait_human`、`/api/plan/semi/*`、设置与 Fleet 透出、首页与中控 UI。 + +## 轮次 + +| 轮次 | 工具 | 结论 | +|------|------|------| +| 1 | Security Review | 3 项中高:取消授权 TOCTOU、出场参数未锁定、关半自动未清 armed | +| 2 | Bugbot | 2 项高:重启后陈旧 armed、授权绕过 LIVE `live_ready` | + +## 已修复 + +1. 选约后、`opening` 前、以及 `open_group` 直前再次校验 `semi_armed`;取消授权中止开仓。 +2. `phase=opening` 时禁止取消授权(防 LIVE 落单窗口 TOCTOU)。 +3. 开仓成功写入 `semi_trade_lock`;盯盘出场读锁;平仓/收尾清理。 +4. 设置关/开半自动强制 `semi_armed=false`;设置接口不可写入 `semi_armed`。 +5. 进程启动无仓清 armed。 +6. `arm_semi` LIVE 先过 `live_ready`。 +7. 已授权或活跃阶段禁止改本单参数。 +8. 半自动平仓禁止远虚只平永续;平仓后勿覆盖 `wait_human` phase。 + +## 复审 + +| 轮次 | 工具 | 结论 | +|------|------|------| +| 复审 Security | 先验 MEDIUM(opening→open_group 窗口) | 已用 `open_group` 直前 `is_armed` + `phase=opening` 禁止取消授权关闭 | +| 复审 Bugbot | 同窗口 TOCTOU(high) | 同上已修;无其它半自动逻辑 bug | + +## 验证 + +- `pytest backend/tests/test_semi_auto.py`:通过。 +- 授权 API 仍 `require_user`;Fleet 仅只读字段。 + +## 残留风险(可接受) + +- 授权后至开仓完成前改系统级非半自动参数(如杠杆)仍可能影响定仓;与全自动一致,非半自动独有。 +- 有仓重启保留 trade_lock;无仓清 armed 后须重新授权。 diff --git a/docs/更新说明.md b/docs/更新说明.md index 8489c25..c9b20ae 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,22 @@ --- +## 2026-08-08 — 永期半自动(人工授权单笔) + +### 变更 + +1. 系统设置「节奏」增加半自动开关;期期强制关闭半自动。 +2. 首页「半自动 · 本单」:看法方向、波动点数、净利基数、选约门槛;授权/取消。 +3. 引擎:未授权 `wait_human`;授权后选约 ITM/ATM 开仓;出场为到点且净利>0 或净利≥基数×k;平完清授权停机。 +4. 中控模式列/详情透出半自动状态与目标。 +5. 文档:`docs/半自动说明.md`;审计见 `docs/审计说明-2026-08-08-半自动.md`。 + +### 审计 + +两轮:Bugbot + Security Review(见审计说明)。 + +--- + ## 2026-08-07 — 期期对冲互斥模式 ### 变更 diff --git a/docs/策略说明.md b/docs/策略说明.md index 7d92140..dd951d0 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -464,6 +464,13 @@ LIVE 特殊态:`option_closed_perp_pending`(期权已在交易所卖掉、 | `oo_min_leverage` | 200 | 期期单腿最低杠杆 | | `oo_reward_ratio` | 2 | 期期盈亏比(目标=预算×比) | | `oo_budget_cushion` | 0.92 | 期期定仓预留比例 | +| `semi_auto_enabled` | false | 半自动总开关(永期) | +| `semi_armed` | false | 半自动本单是否已授权 | +| `semi_view_side` | long | 半自动看法 long/short | +| `semi_option_move_points` | 50 | 半自动顺向波动点数 | +| `semi_perp_exit_unit` | 5 | 半自动净利出场基数(×k) | +| `semi_min_option_hours` | 30 | 半自动选约最短剩余小时 | +| `semi_min_option_leverage` | 100 | 半自动选约最低杠杆 | --- @@ -479,10 +486,21 @@ LIVE 特殊态:`option_closed_perp_pending`(期权已在交易所卖掉、 --- +## 9.2 半自动(永期) + +详见 [半自动说明](./半自动说明.md)。摘要: + +- 系统设置开启后,首页「半自动 · 本单」定方向/点数/净利基数并授权。 +- 机器盯选约(ITM/ATM)→ 开 → 平;平完 `wait_human`,不连开。 +- 出场:顺向到点且组合净利>0,或净利≥基数×k;先期权后永续。 + +--- + ## 10. 修订记录 | 日期 | 说明 | |------|------| +| 2026-08-08 | 半自动:人工授权单笔;点数/净利出场;中控展示 | | 2026-08-07 | 期期对冲互斥模式;振幅高低选约;盈亏比出场;SIM/LIVE 双通道 | | 2026-08-02 | §3/§4 展开开平仓逐步逻辑;新增 §5 实盘状态机与异常处理;残留买一 IOC 回收 | | 2026-07-24 | 固定方向:永续多→Put / 空→Call,仅实值或平值 | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index cd5f37e..af1973a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -268,6 +268,16 @@ export type PlanState = { max_atm_open_offset?: number; fixed_direction_enabled?: boolean; fixed_perp_side?: "long" | "short"; + semi_auto_enabled?: boolean; + semi_armed?: boolean; + semi_view_side?: "long" | "short"; + semi_option_side?: "call" | "put"; + semi_perp_side?: "long" | "short"; + semi_option_move_points?: number; + semi_perp_exit_unit?: number; + semi_min_option_hours?: number; + semi_min_option_leverage?: number; + semi_net_exit_target?: number; can_open: boolean; open_capacity?: { leverage?: number; @@ -404,6 +414,13 @@ export type StrategySettings = { max_atm_open_offset?: number; fixed_direction_enabled?: boolean; fixed_perp_side?: "long" | "short"; + semi_auto_enabled?: boolean; + semi_armed?: boolean; + semi_view_side?: "long" | "short"; + semi_option_move_points?: number; + semi_perp_exit_unit?: number; + semi_min_option_hours?: number; + semi_min_option_leverage?: number; close_bid_mark_max_pct?: number; residual_min_premium_pct?: number; residual_close_check_sec?: number; diff --git a/frontend/src/labels.ts b/frontend/src/labels.ts index 7a1daea..58b79d0 100644 --- a/frontend/src/labels.ts +++ b/frontend/src/labels.ts @@ -28,6 +28,8 @@ const CLOSE_REASON_ZH: Record = { premium_multiple: "权利金倍数达标·双腿全平", target_perp_only: "净盈利达标·只平永续(期权归档)", residual_premium_close: "残留期权·权利金回收中途平", + semi_target_points: "半自动·标的到点且组合净利>0·双腿全平", + semi_perp_exit: "半自动·净利基数达标·双腿全平", expiry: "到期结算", emergency: "紧急全平", manual: "手动平仓", diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 0cb7d4c..9bf2496 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -82,6 +82,7 @@ function fmtHoldDuration(openAtMs: number | null | undefined, now: number) { const PHASE_ZH: Record = { idle: "空闲", wait_signal: "等待信号", + wait_human: "等待人工授权", opening: "开仓中", open: "持仓中", closing: "平仓中", @@ -111,6 +112,12 @@ export default function PlanPage() { const [busy, setBusy] = useState(""); const [residualBusy, setResidualBusy] = useState(""); const [nowMs, setNowMs] = useState(() => Date.now()); + const [semiView, setSemiView] = useState<"long" | "short">("long"); + const [semiMove, setSemiMove] = useState(50); + const [semiExitU, setSemiExitU] = useState(5); + const [semiMinH, setSemiMinH] = useState(30); + const [semiMinLev, setSemiMinLev] = useState(100); + const [semiDirty, setSemiDirty] = useState(false); async function refresh() { try { @@ -120,12 +127,71 @@ export default function PlanPage() { ]); setSnap(m); setPlan(p); + if (!semiDirty) { + setSemiView(p.semi_view_side === "short" ? "short" : "long"); + 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)); + } setErr(""); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } } + async function saveSemiParams() { + setBusy("semi-save"); + setErr(""); + 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, + }), + }); + setPlan(p); + setSemiDirty(false); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(""); + } + } + + async function armSemi(armed: boolean) { + setBusy(armed ? "semi-arm" : "semi-disarm"); + setErr(""); + try { + 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, + }), + }); + setSemiDirty(false); + } + const p = await apiFetch("/api/plan/semi/arm", { + method: "POST", + body: JSON.stringify({ armed }), + }); + setPlan(p); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(""); + } + } + useEffect(() => { refresh(); const t = window.setInterval(refresh, 1500); @@ -171,26 +237,36 @@ export default function PlanPage() { } const bias = snap?.ask_compare?.bias; - const biasTag = - bias === "strike_below_spot" || - bias === "call_ask_gt_put" || - bias === "fixed_short_call" ? ( + const pos = plan?.position; + const open = !!pos?.has_position; + // 持仓中展示本组成交方向,勿用监控 ATM 的实时盘口信号(会漂) + const heldOpt = String(pos?.option_side || "").toLowerCase(); + const heldPerp = String(pos?.perp_side || "").toLowerCase(); + const biasTag = open ? ( + heldOpt === "call" || heldPerp === "short" ? ( 买 Call + 永续空 - ) : bias === "strike_above_spot" || - bias === "put_ask_gt_call" || - bias === "fixed_long_put" ? ( + ) : heldOpt === "put" || heldPerp === "long" ? ( 买 Put + 永续多 ) : ( - 等待 / 相等 - ); + 持仓中 + ) + ) : bias === "strike_below_spot" || + bias === "call_ask_gt_put" || + bias === "fixed_short_call" ? ( + 买 Call + 永续空 + ) : bias === "strike_above_spot" || + bias === "put_ask_gt_call" || + bias === "fixed_long_put" ? ( + 买 Put + 永续多 + ) : ( + 等待 / 相等 + ); - const pos = plan?.position; const isOo = plan?.hedge_mode === "option_option" || snap?.hedge_mode === "option_option" || pos?.hedge_mode === "option_option" || !!pos?.option2_inst_id; - const open = !!pos?.has_position; const exitMode = plan?.exit_mode ?? "fixed_usdt"; const riskBased = plan?.risk_based === true || plan?.sizing_mode === "risk_based"; @@ -488,6 +564,135 @@ export default function PlanPage() { {busy ? {busy}… : null} + {plan?.semi_auto_enabled && !isOo ? ( +
+

半自动 · 本单

+

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

+
+
+ + +
+
+ + { + setSemiMove(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiExitU(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiMinH(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+ + { + setSemiMinLev(Number(e.target.value)); + setSemiDirty(true); + }} + /> +
+
+
+ {(() => { + const idx = snap?.index_px; + if (idx == null || !Number.isFinite(Number(idx))) { + return `目标净利≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · 指数 —`; + } + const n = Number(idx); + const tgt = + semiView === "long" ? n + Number(semiMove) : n - Number(semiMove); + return `指数 ${fmtExPx("index", n)} → 到点 ${fmtExPx("index", tgt)} · 净利目标≈${fmt(plan.semi_net_exit_target ?? semiExitU, 2)}U · ${plan.semi_armed ? "已授权盯开" : "未授权"}`; + })()} +
+
+ + + +
+
+ ) : null} +
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 39f26e8..513e7d8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -89,6 +89,7 @@ export default function SettingsPage() { const [perpQty, setPerpQty] = useState(1); const [optQty, setOptQty] = useState(2); const [showManualTrade, setShowManualTrade] = useState(false); + const [semiAutoOn, setSemiAutoOn] = useState(false); const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual"); const [riskLeverageBasis, setRiskLeverageBasis] = useState< "actual" | "selection" @@ -214,6 +215,7 @@ export default function SettingsPage() { setPerpQty(s.perp_qty_eth ?? 1); setOptQty(s.option_qty_eth ?? 2); setShowManualTrade(s.show_manual_trade_buttons === true); + setSemiAutoOn(s.semi_auto_enabled === true); setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual"); setRiskLeverageBasis( s.risk_leverage_basis === "actual" ? "actual" : "selection", @@ -393,6 +395,7 @@ export default function SettingsPage() { close_bid_mark_max_pct: closeDevPct, residual_min_premium_pct: residualMinPremPct, show_manual_trade_buttons: showManualTrade, + semi_auto_enabled: oo ? false : semiAutoOn, sizing_mode: oo ? "risk_based" : sizingMode, risk_leverage_basis: riskLeverageBasis, risk_loss_mode: oo ? "percent" : riskLossMode, @@ -1582,6 +1585,22 @@ export default function SettingsPage() {

节奏与费用

+ {!isOo ? ( +
+ + +
+ ) : null}
+
  • + 半自动:开启后首页出现本单面板;人工定方向/目标并「授权开下一单」,机器盯选约→开→平;平完停,不自动连开。期期不可用。 +
  • 实盘下单最小间隔:LIVE 私有下单/查单间隔,默认 1s。范围 0.2–30。 diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index 6ea6e66..c336b1c 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -459,6 +459,15 @@ input { margin-bottom: 12px; } +.plan-semi-card { + margin-bottom: 12px; + border-color: rgba(94, 200, 255, 0.35); +} + +.plan-semi-card .plan-panel-title { + color: #5ec8ff; +} + @media (max-width: 900px) { .app-container { padding: 0 12px calc(72px + env(safe-area-inset-bottom, 0px));