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:
+203
-25
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user