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
+203 -25
View File
@@ -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
+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:
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():