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:
@@ -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
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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|short;long→买Put,short→买Call
|
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 # 平仓:买一相对标记最大偏差%
|
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
|
||||||
# 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出
|
# 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出
|
||||||
residual_min_premium_pct: float = 20.0
|
residual_min_premium_pct: float = 20.0
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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": "紧急全平",
|
||||||
|
|||||||
+195
-17
@@ -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,6 +959,46 @@ 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))
|
||||||
|
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(
|
decision = check_exits(
|
||||||
net_pnl=float(upl.get("net_pnl") or 0),
|
net_pnl=float(upl.get("net_pnl") or 0),
|
||||||
exit_mode=exit_mode,
|
exit_mode=exit_mode,
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 | 初版:设置开关、首页本单、中控展示、出场与停机 |
|
||||||
@@ -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 后须重新授权。
|
||||||
@@ -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 — 期期对冲互斥模式
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -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,仅实值或平值 |
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: "手动平仓",
|
||||||
|
|||||||
+209
-4
@@ -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,8 +237,20 @@ 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;
|
||||||
|
// 持仓中展示本组成交方向,勿用监控 ATM 的实时盘口信号(会漂)
|
||||||
|
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>
|
||||||
|
) : heldOpt === "put" || heldPerp === "long" ? (
|
||||||
|
<span className="tag down">买 Put + 永续多</span>
|
||||||
|
) : (
|
||||||
|
<span className="tag">持仓中</span>
|
||||||
|
)
|
||||||
|
) : bias === "strike_below_spot" ||
|
||||||
bias === "call_ask_gt_put" ||
|
bias === "call_ask_gt_put" ||
|
||||||
bias === "fixed_short_call" ? (
|
bias === "fixed_short_call" ? (
|
||||||
<span className="tag up">买 Call + 永续空</span>
|
<span className="tag up">买 Call + 永续空</span>
|
||||||
@@ -184,13 +262,11 @@ export default function PlanPage() {
|
|||||||
<span className="tag">等待 / 相等</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 }}>
|
||||||
|
人工定方向与目标 → 授权后机器盯选约/开平 → 平完停,再授权下一单。选约:≥剩余时长与杠杆、实值或平值。顺向:开仓指数±点数且组合净利>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">
|
||||||
|
|||||||
@@ -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.2–30。
|
0.2–30。
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user