Add 永期半自动: human arm, machine open/close, then stop.

Settings toggle, Plan panel, Fleet monitor, exit locks and armed TOCTOU gates; docs and dual audits.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 11:20:11 +08:00
parent bf3441537e
commit 4585dba3c3
22 changed files with 1147 additions and 44 deletions
+8
View File
@@ -290,6 +290,14 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di
"exit_target_usdt": st.get("exit_target_usdt"), "exit_target_usdt": st.get("exit_target_usdt"),
"net_profit_target": st.get("net_profit_target"), "net_profit_target": st.get("net_profit_target"),
"premium_exit_multiple": _pick("premium_exit_multiple"), "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)), "leverage": _pick("leverage", float(settings.leverage)),
"min_option_leverage": _pick( "min_option_leverage": _pick(
"min_option_leverage", float(settings.min_option_leverage) "min_option_leverage", float(settings.min_option_leverage)
+56 -1
View File
@@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from typing import Annotated from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..models.db import get_db
from ..strategy import get_engine from ..strategy import get_engine
from .auth import require_user 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() 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): class ResidualCloseBody(BaseModel):
group_id: str = Field(min_length=1, max_length=128) group_id: str = Field(min_length=1, max_length=128)
+71
View File
@@ -45,6 +45,13 @@ KEYS = (
"max_atm_open_offset", "max_atm_open_offset",
"fixed_direction_enabled", "fixed_direction_enabled",
"fixed_perp_side", "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", "close_bid_mark_max_pct",
"residual_min_premium_pct", "residual_min_premium_pct",
"residual_close_check_sec", "residual_close_check_sec",
@@ -95,6 +102,13 @@ class StrategySettingsBody(BaseModel):
max_atm_open_offset: float | None = Field(default=None, ge=0, le=100) max_atm_open_offset: float | None = Field(default=None, ge=0, le=100)
fixed_direction_enabled: bool | None = None fixed_direction_enabled: bool | None = None
fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$") 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) 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_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) residual_close_check_sec: int | None = Field(default=None, ge=30, le=86400)
@@ -239,6 +253,49 @@ def _read_settings() -> dict:
in ("long", "short") in ("long", "short")
else "long" 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( "close_bid_mark_max_pct": float(
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct)) db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
or 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() db = get_db()
s = get_settings() s = get_settings()
data = body.model_dump(exclude_none=True) data = body.model_dump(exclude_none=True)
# 授权态只经 /api/plan/semi/arm;设置接口不可伪造 armed=true
data.pop("semi_armed", None)
equity_to_apply: float | None = None equity_to_apply: float | None = None
switch_to: str | None = None switch_to: str | None = None
old_hedge = str( old_hedge = str(
@@ -491,6 +550,12 @@ async def put_strategy_settings(
"oo_reward_ratio", "oo_reward_ratio",
"oo_budget_cushion", "oo_budget_cushion",
"oo_strike_max_dev_pct", "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] hit = [k for k in locked_keys if k in data]
if hit: if hit:
@@ -516,6 +581,12 @@ async def put_strategy_settings(
data["exit_mode"] = "fixed_usdt" data["exit_mode"] = "fixed_usdt"
data.pop("perp_qty_eth", None) data.pop("perp_qty_eth", None)
data["fixed_direction_enabled"] = False 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( sizing_mode = str(
data.get( data.get(
+8
View File
@@ -95,6 +95,14 @@ class Settings(BaseSettings):
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值 # 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
fixed_direction_enabled: bool = False fixed_direction_enabled: bool = False
fixed_perp_side: str = "long" # long|shortlong→买Putshort→买Call fixed_perp_side: str = "long" # long|shortlong→买Putshort→买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 # 平仓:买一相对标记最大偏差% close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
# 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出 # 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出
residual_min_premium_pct: float = 20.0 residual_min_premium_pct: float = 20.0
+9
View File
@@ -47,6 +47,15 @@ async def lifespan(app: FastAPI):
settings = load_runtime_settings() settings = load_runtime_settings()
engine = StrategyEngine() engine = StrategyEngine()
set_engine(engine) 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:进程启动后不自动真下单,须人工点「启动」 # LIVE:进程启动后不自动真下单,须人工点「启动」
if not get_settings().is_sim: if not get_settings().is_sim:
try: try:
+2
View File
@@ -30,6 +30,8 @@ _FAULT_DEDUP_SEC = 300.0
CLOSE_REASON_ZH: dict[str, str] = { CLOSE_REASON_ZH: dict[str, str] = {
"expiry": "到期结算全平", "expiry": "到期结算全平",
"target_perp_only": "净盈利达标·只平永续(期权归档到期)", "target_perp_only": "净盈利达标·只平永续(期权归档到期)",
"semi_target_points": "半自动·标的到点且组合净利>0",
"semi_perp_exit": "半自动·净利基数达标",
"fixed_usdt": "固定净盈利达标·双腿全平", "fixed_usdt": "固定净盈利达标·双腿全平",
"premium_multiple": "权利金倍数达标·双腿全平", "premium_multiple": "权利金倍数达标·双腿全平",
"emergency": "紧急全平", "emergency": "紧急全平",
+203 -25
View File
@@ -233,7 +233,7 @@ class StrategyEngine:
"funds_ok": False, "funds_ok": False,
"leverage": leverage, "leverage": leverage,
} }
return { out: dict[str, Any] = {
"running": bool(row["running"]), "running": bool(row["running"]),
"phase": row["phase"], "phase": row["phase"],
"rounds_done": self._closed_rounds(), "rounds_done": self._closed_rounds(),
@@ -355,6 +355,24 @@ class StrategyEngine:
"show_manual_trade_buttons", False "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: def _set_state(self, **kwargs: Any) -> None:
cols = [] cols = []
@@ -505,8 +523,26 @@ class StrategyEngine:
return int(row["n"] or 0) if row else 0 return int(row["n"] or 0) if row else 0
def _after_close(self) -> None: def _after_close(self) -> None:
from .semi_auto import (
PHASE_WAIT_HUMAN,
clear_trade_lock,
is_semi_auto,
set_armed,
)
s = get_settings() s = get_settings()
rounds = self._closed_rounds() 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_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
rest_until = int(time.time() * 1000) + rest_sec * 1000 rest_until = int(time.time() * 1000) + rest_sec * 1000
self._set_state( self._set_state(
@@ -515,6 +551,50 @@ class StrategyEngine:
rest_until_ms=rest_until, 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: def _count_groups_for_day(self, wkey: str) -> int:
rows = self.db.fetchall( rows = self.db.fetchall(
"SELECT group_id FROM groups WHERE group_id LIKE ?", "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") r = await asyncio.to_thread(abandon, reason="target_perp_only")
if r.ok: if r.ok:
self._note_retry_result(kind, ok=True) self._note_retry_result(kind, ok=True)
self._after_close() # _after_close 已写入 resting / wait_human,勿再覆盖 phase
self._set_state( self.enter_rest_after_close()
last_error=None,
phase="resting",
)
try: try:
from ..notify import wecom from ..notify import wecom
@@ -616,15 +693,22 @@ class StrategyEngine:
except Exception: except Exception:
logger.exception("wecom notify_close failed") logger.exception("wecom notify_close failed")
elif r.liquidity_wait and not bypass_liquidity: elif r.liquidity_wait and not bypass_liquidity:
# 等待期间若已变成远虚,下一 tick 走归档 # 等待期间若已变成远虚,下一 tick 走归档(半自动要求双腿全平,不归档)
if self.matcher.option_is_deep_otm(): if (
reason
not in (
"semi_target_points",
"semi_perp_exit",
)
and self.matcher.option_is_deep_otm()
):
r2 = await asyncio.to_thread( r2 = await asyncio.to_thread(
self.matcher.close_perp_abandon_option, self.matcher.close_perp_abandon_option,
reason="target_perp_only", reason="target_perp_only",
) )
if r2.ok: if r2.ok:
self._note_retry_result(kind, ok=True) self._note_retry_result(kind, ok=True)
self._after_close() self.enter_rest_after_close()
try: try:
from ..notify import wecom from ..notify import wecom
@@ -831,8 +915,7 @@ class StrategyEngine:
r = await asyncio.to_thread(repair) r = await asyncio.to_thread(repair)
if r.ok: if r.ok:
self._note_retry_result("half_open", ok=True) self._note_retry_result("half_open", ok=True)
self._after_close() self.enter_rest_after_close()
self._set_state(phase="resting", last_error=None)
try: try:
from ..notify import wecom from ..notify import wecom
@@ -861,9 +944,12 @@ class StrategyEngine:
if st_pos == "open": if st_pos == "open":
upl = self.matcher.unrealized() upl = self.matcher.unrealized()
from .exits import lock_trade_exit_target, read_locked_exit_target 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) 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: try:
locked_exit = lock_trade_exit_target( locked_exit = lock_trade_exit_target(
self.db, self.db,
@@ -873,14 +959,54 @@ class StrategyEngine:
except Exception: except Exception:
logger.exception("backfill exit lock failed") logger.exception("backfill exit lock failed")
expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl)) expired = check_expiry_close(expiry_ms=self._position_expiry_ms(upl))
decision = check_exits( if is_semi_auto(self.ledger):
net_pnl=float(upl.get("net_pnl") or 0), sp = read_semi_params(
exit_mode=exit_mode, self.ledger,
net_profit_target=net_target, group_id=str(upl["group_id"]) if upl.get("group_id") else None,
premium_exit_multiple=prem_mult, )
initial_premium=float(upl.get("initial_premium") or 0), rk = float(
locked_exit_target=locked_exit, 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") pending_close = st["phase"] in ("liquidity_wait", "closing")
# 曾因达标进入流动性等待,但当前估价已跌破目标:取消挂起,避免硬平出亏损却仍记「达标」 # 曾因达标进入流动性等待,但当前估价已跌破目标:取消挂起,避免硬平出亏损却仍记「达标」
if ( if (
@@ -906,7 +1032,15 @@ class StrategyEngine:
else: else:
reason = decision.reason or "liquidity_retry" reason = decision.reason or "liquidity_retry"
bypass = False 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" rkind = "liquidity" if pending_close else "close"
if is_oo and decision.should_close and not expired.should_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" close_oo, reason="target_oo_win"
) )
if r.ok: if r.ok:
self._enter_rest_after_close() self.enter_rest_after_close()
self._set_state(phase="resting", last_error=None)
else: else:
self._set_state( self._set_state(
phase="liquidity_wait", phase="liquidity_wait",
@@ -955,8 +1088,7 @@ class StrategyEngine:
bypass_liquidity=True, bypass_liquidity=True,
) )
if r.ok: if r.ok:
self._enter_rest_after_close() self.enter_rest_after_close()
self._set_state(phase="resting", last_error=None)
else: else:
self._set_state( self._set_state(
phase="liquidity_wait", phase="liquidity_wait",
@@ -1006,6 +1138,14 @@ class StrategyEngine:
if st["phase"] in ("stopped", "outside_window"): if st["phase"] in ("stopped", "outside_window"):
self._set_state(phase="idle") 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) skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends)
if not can_open_new(skip_weekends=skip_weekends): if not can_open_new(skip_weekends=skip_weekends):
self._set_state( self._set_state(
@@ -1096,6 +1236,22 @@ class StrategyEngine:
) )
return 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(与手动开仓同一管道) # 选约后:定仓落库 → 兑 USDC → 资金门 fail-closed(与手动开仓同一管道)
from .open_pipeline import size_and_gate from .open_pipeline import size_and_gate
@@ -1128,6 +1284,13 @@ class StrategyEngine:
if st["phase"] == "wait_funds": if st["phase"] == "wait_funds":
self._set_state(phase="idle", last_error=None) 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) self._set_state(phase="opening", last_error=None)
wkey = window_key() wkey = window_key()
count = self._count_groups_for_day(wkey) count = self._count_groups_for_day(wkey)
@@ -1150,6 +1313,12 @@ class StrategyEngine:
except Exception: except Exception:
pass pass
return 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: if oo:
open_fn = getattr(self.matcher, "open_oo_group", None) open_fn = getattr(self.matcher, "open_oo_group", None)
if open_fn is None: if open_fn is None:
@@ -1184,6 +1353,15 @@ class StrategyEngine:
expiry_ymd=pick.pair.expiry_ymd, expiry_ymd=pick.pair.expiry_ymd,
) )
if r.ok: 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) self._set_state(phase="open", last_error=None)
try: try:
from ..notify import wecom from ..notify import wecom
+211
View File
@@ -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,
)
+12
View File
@@ -721,10 +721,22 @@ class StrategySession:
def _pick_for_open_perp(self) -> OpenPick | None: def _pick_for_open_perp(self) -> OpenPick | None:
from .signal import decide, decide_fixed from .signal import decide, decide_fixed
from .semi_auto import is_armed, is_semi_auto, read_semi_params
s = self.settings s = self.settings
min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats() min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats()
fixed_on, fixed_perp = _fixed_direction() 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 opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None
# 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%) # 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%)
if not self._apply_amplitude_first_gate(): if not self._apply_amplitude_first_gate():
+75
View File
@@ -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
+45 -5
View File
@@ -162,6 +162,8 @@ const CLOSE_REASON_ZH: Record<string, string> = {
fixed_usdt: "固定净盈利达标·双腿全平", fixed_usdt: "固定净盈利达标·双腿全平",
premium_multiple: "权利金倍数达标·双腿全平", premium_multiple: "权利金倍数达标·双腿全平",
target_perp_only: "净盈利达标·只平永续(期权归档)", target_perp_only: "净盈利达标·只平永续(期权归档)",
semi_target_points: "半自动·标的到点且组合净利>0",
semi_perp_exit: "半自动·净利基数达标",
expiry: "到期结算", expiry: "到期结算",
emergency: "紧急全平", emergency: "紧急全平",
manual: "手动平仓", manual: "手动平仓",
@@ -321,7 +323,9 @@ function hedgeModeShort(strat: Record<string, unknown>): string {
const hm = String(strat.hedge_mode || "perp_option") const hm = String(strat.hedge_mode || "perp_option")
.trim() .trim()
.toLowerCase(); .toLowerCase();
return hm === "option_option" ? "期期" : "永期"; if (hm === "option_option") return "期期";
if (strat.semi_auto_enabled === true) return "半自动";
return "永期";
} }
function modeParts( function modeParts(
@@ -354,7 +358,15 @@ function ModeLabelView({
return ( return (
<> <>
{p.head}/ {p.head}/
<span className={p.isOo ? "mode-hedge-oo" : "mode-hedge-po"}> <span
className={
p.isOo
? "mode-hedge-oo"
: p.hedge === "半自动"
? "mode-hedge-semi"
: "mode-hedge-po"
}
>
{p.hedge} {p.hedge}
</span> </span>
</> </>
@@ -366,7 +378,11 @@ function modeLabelTitle(mode: string, strat: Record<string, unknown>): string {
const riskBased = const riskBased =
strat.sizing_mode === "risk_based" || strat.risk_based === true; strat.sizing_mode === "risk_based" || strat.risk_based === true;
const hedge = const hedge =
hedgeModeShort(strat) === "期期" ? "期期=双期权对冲" : "永期=永续+期权"; hedgeModeShort(strat) === "期期"
? "期期=双期权对冲"
: hedgeModeShort(strat) === "半自动"
? "半自动=人工看法·机器盯盘开平·平仓后停"
: "永期=永续+期权";
return `${label}${riskBased ? "A=以损定仓" : "B=手动开仓"}${hedge}`; return `${label}${riskBased ? "A=以损定仓" : "B=手动开仓"}${hedge}`;
} }
@@ -1126,7 +1142,23 @@ export default function MonitorPage() {
{r.openRatio ? <td>{r.openRatio}</td> : null} {r.openRatio ? <td>{r.openRatio}</td> : null}
<td title={r.leverageTitle}>{r.leverage}</td> <td title={r.leverageTitle}>{r.leverage}</td>
<td> <td>
{detailRunning ? "运行中" : "已停"} · {detail.phase} {detailRunning ? "运行中" : "已停"} ·{" "}
{detail.phase === "wait_human"
? "等待人工授权"
: detail.phase}
{detail.strat.semi_auto_enabled === true ? (
<span className="meta">
{" "}
·
{detail.strat.semi_armed === true
? "已授权"
: "未授权"}
·
{detail.strat.semi_view_side === "short"
? "空"
: "多"}
</span>
) : null}
</td> </td>
<td> <td>
<ModeLabelView <ModeLabelView
@@ -1160,7 +1192,15 @@ export default function MonitorPage() {
4, 4,
)} ETH`} )} ETH`}
</td> </td>
<td>{fmt(detail.strat.exit_target_usdt, 2)} USDT</td> <td>
{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`}
</td>
<td className="mono"> <td className="mono">
{fmtExPx("perp", detail.index_px)} {fmtExPx("perp", detail.index_px)}
</td> </td>
+5
View File
@@ -424,6 +424,11 @@ input {
color: var(--muted); color: var(--muted);
} }
.mode-hedge-semi {
color: #5ec8ff;
font-weight: 700;
}
.dot { .dot {
width: 9px; width: 9px;
height: 9px; height: 9px;
+2 -2
View File
@@ -138,9 +138,9 @@ bash /opt/eth_hedge_sim/control/deploy/update.sh
- **实时推送**:浏览器通过 SSE(`GET /api/nodes/status/stream`)接收状态;中控约每秒**并行**拉取各策略机 `/api/fleet/status`,有变化才推送;断线自动重连。工具栏显示「实时 · 1s」。 - **实时推送**:浏览器通过 SSE(`GET /api/nodes/status/stream`)接收状态;中控约每秒**并行**拉取各策略机 `/api/fleet/status`,有变化才推送;断线自动重连。工具栏显示「实时 · 1s」。
- **刷新**:仍可手动走 `/api/nodes/status/all` 拉一次。 - **刷新**:仍可手动走 `/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。 - **更新代码 / 勾选更新**:**二次确认**后执行;会 reload 进程,**不会**自动 start。
+100
View File
@@ -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 | 初版:设置开关、首页本单、中控展示、出场与停机 |
+40
View File
@@ -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 | 先验 MEDIUMopening→open_group 窗口) | 已用 `open_group` 直前 `is_armed` + `phase=opening` 禁止取消授权关闭 |
| 复审 Bugbot | 同窗口 TOCTOUhigh) | 同上已修;无其它半自动逻辑 bug |
## 验证
- `pytest backend/tests/test_semi_auto.py`:通过。
- 授权 API 仍 `require_user`Fleet 仅只读字段。
## 残留风险(可接受)
- 授权后至开仓完成前改系统级非半自动参数(如杠杆)仍可能影响定仓;与全自动一致,非半自动独有。
- 有仓重启保留 trade_lock;无仓清 armed 后须重新授权。
+16
View File
@@ -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 — 期期对冲互斥模式 ## 2026-08-07 — 期期对冲互斥模式
### 变更 ### 变更
+18
View File
@@ -464,6 +464,13 @@ LIVE 特殊态:`option_closed_perp_pending`(期权已在交易所卖掉、
| `oo_min_leverage` | 200 | 期期单腿最低杠杆 | | `oo_min_leverage` | 200 | 期期单腿最低杠杆 |
| `oo_reward_ratio` | 2 | 期期盈亏比(目标=预算×比) | | `oo_reward_ratio` | 2 | 期期盈亏比(目标=预算×比) |
| `oo_budget_cushion` | 0.92 | 期期定仓预留比例 | | `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. 修订记录 ## 10. 修订记录
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-08-08 | 半自动:人工授权单笔;点数/净利出场;中控展示 |
| 2026-08-07 | 期期对冲互斥模式;振幅高低选约;盈亏比出场;SIM/LIVE 双通道 | | 2026-08-07 | 期期对冲互斥模式;振幅高低选约;盈亏比出场;SIM/LIVE 双通道 |
| 2026-08-02 | §3/§4 展开开平仓逐步逻辑;新增 §5 实盘状态机与异常处理;残留买一 IOC 回收 | | 2026-08-02 | §3/§4 展开开平仓逐步逻辑;新增 §5 实盘状态机与异常处理;残留买一 IOC 回收 |
| 2026-07-24 | 固定方向:永续多→Put / 空→Call,仅实值或平值 | | 2026-07-24 | 固定方向:永续多→Put / 空→Call,仅实值或平值 |
+17
View File
@@ -268,6 +268,16 @@ export type PlanState = {
max_atm_open_offset?: number; max_atm_open_offset?: number;
fixed_direction_enabled?: boolean; fixed_direction_enabled?: boolean;
fixed_perp_side?: "long" | "short"; 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; can_open: boolean;
open_capacity?: { open_capacity?: {
leverage?: number; leverage?: number;
@@ -404,6 +414,13 @@ export type StrategySettings = {
max_atm_open_offset?: number; max_atm_open_offset?: number;
fixed_direction_enabled?: boolean; fixed_direction_enabled?: boolean;
fixed_perp_side?: "long" | "short"; 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; close_bid_mark_max_pct?: number;
residual_min_premium_pct?: number; residual_min_premium_pct?: number;
residual_close_check_sec?: number; residual_close_check_sec?: number;
+2
View File
@@ -28,6 +28,8 @@ const CLOSE_REASON_ZH: Record<string, string> = {
premium_multiple: "权利金倍数达标·双腿全平", premium_multiple: "权利金倍数达标·双腿全平",
target_perp_only: "净盈利达标·只平永续(期权归档)", target_perp_only: "净盈利达标·只平永续(期权归档)",
residual_premium_close: "残留期权·权利金回收中途平", residual_premium_close: "残留期权·权利金回收中途平",
semi_target_points: "半自动·标的到点且组合净利>0·双腿全平",
semi_perp_exit: "半自动·净利基数达标·双腿全平",
expiry: "到期结算", expiry: "到期结算",
emergency: "紧急全平", emergency: "紧急全平",
manual: "手动平仓", manual: "手动平仓",
+216 -11
View File
@@ -82,6 +82,7 @@ function fmtHoldDuration(openAtMs: number | null | undefined, now: number) {
const PHASE_ZH: Record<string, string> = { const PHASE_ZH: Record<string, string> = {
idle: "空闲", idle: "空闲",
wait_signal: "等待信号", wait_signal: "等待信号",
wait_human: "等待人工授权",
opening: "开仓中", opening: "开仓中",
open: "持仓中", open: "持仓中",
closing: "平仓中", closing: "平仓中",
@@ -111,6 +112,12 @@ export default function PlanPage() {
const [busy, setBusy] = useState(""); const [busy, setBusy] = useState("");
const [residualBusy, setResidualBusy] = useState(""); const [residualBusy, setResidualBusy] = useState("");
const [nowMs, setNowMs] = useState(() => Date.now()); 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() { async function refresh() {
try { try {
@@ -120,12 +127,71 @@ export default function PlanPage() {
]); ]);
setSnap(m); setSnap(m);
setPlan(p); 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(""); setErr("");
} catch (e) { } catch (e) {
setErr(e instanceof Error ? e.message : String(e)); setErr(e instanceof Error ? e.message : String(e));
} }
} }
async function saveSemiParams() {
setBusy("semi-save");
setErr("");
try {
const p = await apiFetch<PlanState>("/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<PlanState>("/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<PlanState>("/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(() => { useEffect(() => {
refresh(); refresh();
const t = window.setInterval(refresh, 1500); const t = window.setInterval(refresh, 1500);
@@ -171,26 +237,36 @@ export default function PlanPage() {
} }
const bias = snap?.ask_compare?.bias; const bias = snap?.ask_compare?.bias;
const biasTag = const pos = plan?.position;
bias === "strike_below_spot" || const open = !!pos?.has_position;
bias === "call_ask_gt_put" || // 持仓中展示本组成交方向,勿用监控 ATM 的实时盘口信号(会漂)
bias === "fixed_short_call" ? ( const heldOpt = String(pos?.option_side || "").toLowerCase();
const heldPerp = String(pos?.perp_side || "").toLowerCase();
const biasTag = open ? (
heldOpt === "call" || heldPerp === "short" ? (
<span className="tag up"> Call + </span> <span className="tag up"> Call + </span>
) : bias === "strike_above_spot" || ) : heldOpt === "put" || heldPerp === "long" ? (
bias === "put_ask_gt_call" ||
bias === "fixed_long_put" ? (
<span className="tag down"> Put + </span> <span className="tag down"> Put + </span>
) : ( ) : (
<span className="tag"> / </span> <span className="tag"></span>
); )
) : bias === "strike_below_spot" ||
bias === "call_ask_gt_put" ||
bias === "fixed_short_call" ? (
<span className="tag up"> Call + </span>
) : bias === "strike_above_spot" ||
bias === "put_ask_gt_call" ||
bias === "fixed_long_put" ? (
<span className="tag down"> Put + </span>
) : (
<span className="tag"> / </span>
);
const pos = plan?.position;
const isOo = const isOo =
plan?.hedge_mode === "option_option" || plan?.hedge_mode === "option_option" ||
snap?.hedge_mode === "option_option" || snap?.hedge_mode === "option_option" ||
pos?.hedge_mode === "option_option" || pos?.hedge_mode === "option_option" ||
!!pos?.option2_inst_id; !!pos?.option2_inst_id;
const open = !!pos?.has_position;
const exitMode = plan?.exit_mode ?? "fixed_usdt"; const exitMode = plan?.exit_mode ?? "fixed_usdt";
const riskBased = const riskBased =
plan?.risk_based === true || plan?.sizing_mode === "risk_based"; plan?.risk_based === true || plan?.sizing_mode === "risk_based";
@@ -488,6 +564,135 @@ export default function PlanPage() {
{busy ? <span className="meta">{busy}</span> : null} {busy ? <span className="meta">{busy}</span> : null}
</div> </div>
{plan?.semi_auto_enabled && !isOo ? (
<section className="card plan-semi-card" aria-label="半自动本单">
<h3 className="plan-panel-title"> · </h3>
<p className="meta" style={{ marginTop: 0 }}>
/ ±&gt;0×k
</p>
<div className="settings-fields" style={{ marginTop: 8 }}>
<div className="field">
<label htmlFor="semiView"></label>
<select
id="semiView"
className="mono"
disabled={open || !!busy || !!plan.semi_armed}
value={semiView}
onChange={(e) => {
setSemiView(e.target.value === "short" ? "short" : "long");
setSemiDirty(true);
}}
>
<option value="long"> · Call + </option>
<option value="short"> · Put + </option>
</select>
</div>
<div className="field">
<label htmlFor="semiMove"> · </label>
<input
id="semiMove"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMove}
onChange={(e) => {
setSemiMove(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiExitU">U×k</label>
<input
id="semiExitU"
className="mono"
type="number"
step="0.1"
min="0.1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiExitU}
onChange={(e) => {
setSemiExitU(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinH"> · h</label>
<input
id="semiMinH"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinH}
onChange={(e) => {
setSemiMinH(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
<div className="field">
<label htmlFor="semiMinLev"> · </label>
<input
id="semiMinLev"
className="mono"
type="number"
step="1"
min="1"
disabled={open || !!busy || !!plan.semi_armed}
value={semiMinLev}
onChange={(e) => {
setSemiMinLev(Number(e.target.value));
setSemiDirty(true);
}}
/>
</div>
</div>
<div className="mono meta" style={{ marginTop: 8 }}>
{(() => {
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 ? "已授权盯开" : "未授权"}`;
})()}
</div>
<div className="plan-actions" style={{ marginTop: 10, paddingTop: 0 }}>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !semiDirty}
onClick={() => void saveSemiParams()}
>
</button>
<button
className="btn"
type="button"
disabled={open || !!busy || !!plan.semi_armed}
onClick={() => void armSemi(true)}
>
</button>
<button
className="btn ghost"
type="button"
disabled={open || !!busy || !plan.semi_armed}
onClick={() => void armSemi(false)}
>
</button>
</div>
</section>
) : null}
<div className="plan-shell"> <div className="plan-shell">
<div className="plan-board"> <div className="plan-board">
<div className="card plan-strategy"> <div className="card plan-strategy">
+22
View File
@@ -89,6 +89,7 @@ export default function SettingsPage() {
const [perpQty, setPerpQty] = useState(1); const [perpQty, setPerpQty] = useState(1);
const [optQty, setOptQty] = useState(2); const [optQty, setOptQty] = useState(2);
const [showManualTrade, setShowManualTrade] = useState(false); const [showManualTrade, setShowManualTrade] = useState(false);
const [semiAutoOn, setSemiAutoOn] = useState(false);
const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual"); const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual");
const [riskLeverageBasis, setRiskLeverageBasis] = useState< const [riskLeverageBasis, setRiskLeverageBasis] = useState<
"actual" | "selection" "actual" | "selection"
@@ -214,6 +215,7 @@ export default function SettingsPage() {
setPerpQty(s.perp_qty_eth ?? 1); setPerpQty(s.perp_qty_eth ?? 1);
setOptQty(s.option_qty_eth ?? 2); setOptQty(s.option_qty_eth ?? 2);
setShowManualTrade(s.show_manual_trade_buttons === true); setShowManualTrade(s.show_manual_trade_buttons === true);
setSemiAutoOn(s.semi_auto_enabled === true);
setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual"); setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual");
setRiskLeverageBasis( setRiskLeverageBasis(
s.risk_leverage_basis === "actual" ? "actual" : "selection", s.risk_leverage_basis === "actual" ? "actual" : "selection",
@@ -393,6 +395,7 @@ export default function SettingsPage() {
close_bid_mark_max_pct: closeDevPct, close_bid_mark_max_pct: closeDevPct,
residual_min_premium_pct: residualMinPremPct, residual_min_premium_pct: residualMinPremPct,
show_manual_trade_buttons: showManualTrade, show_manual_trade_buttons: showManualTrade,
semi_auto_enabled: oo ? false : semiAutoOn,
sizing_mode: oo ? "risk_based" : sizingMode, sizing_mode: oo ? "risk_based" : sizingMode,
risk_leverage_basis: riskLeverageBasis, risk_leverage_basis: riskLeverageBasis,
risk_loss_mode: oo ? "percent" : riskLossMode, risk_loss_mode: oo ? "percent" : riskLossMode,
@@ -1582,6 +1585,22 @@ export default function SettingsPage() {
<section className="settings-section"> <section className="settings-section">
<h3></h3> <h3></h3>
<div className="settings-fields"> <div className="settings-fields">
{!isOo ? (
<div className="field">
<label htmlFor="semiAuto"></label>
<select
id="semiAuto"
className="mono"
value={semiAutoOn ? "on" : "off"}
onChange={(e) =>
setSemiAutoOn(e.target.value === "on")
}
>
<option value="off"></option>
<option value="on"></option>
</select>
</div>
) : null}
<div className="field"> <div className="field">
<label htmlFor="rest"></label> <label htmlFor="rest"></label>
<input <input
@@ -1770,6 +1789,9 @@ export default function SettingsPage() {
) : null} ) : null}
{stratSub === "pace" ? ( {stratSub === "pace" ? (
<> <>
<li>
/
</li>
<li> <li>
LIVE / 1s LIVE / 1s
0.230 0.230
+9
View File
@@ -459,6 +459,15 @@ input {
margin-bottom: 12px; 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) { @media (max-width: 900px) {
.app-container { .app-container {
padding: 0 12px calc(72px + env(safe-area-inset-bottom, 0px)); padding: 0 12px calc(72px + env(safe-area-inset-bottom, 0px));