Extend semi-auto with ITM/ATM/OTM, offset, and 1:4 sizing.

Moneyness and perp:option units are plan-only; OTM enforces leverage >=180.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-08 11:42:57 +08:00
parent 4585dba3c3
commit 457838ef64
17 changed files with 428 additions and 41 deletions
+4
View File
@@ -298,6 +298,10 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di
"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"),
"semi_moneyness": st.get("semi_moneyness"),
"semi_otm_max_offset": st.get("semi_otm_max_offset"),
"semi_perp_unit": st.get("semi_perp_unit"),
"semi_option_unit": st.get("semi_option_unit"),
"leverage": _pick("leverage", float(settings.leverage)),
"min_option_leverage": _pick(
"min_option_leverage", float(settings.min_option_leverage)
+4
View File
@@ -45,6 +45,10 @@ class SemiParamsBody(BaseModel):
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)
semi_moneyness: Literal["itm", "atm", "otm"] | None = None
semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000)
semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
semi_option_unit: float | None = Field(default=None, ge=0.01, le=100)
@router.post("/semi/arm")
+34
View File
@@ -52,6 +52,10 @@ KEYS = (
"semi_perp_exit_unit",
"semi_min_option_hours",
"semi_min_option_leverage",
"semi_moneyness",
"semi_otm_max_offset",
"semi_perp_unit",
"semi_option_unit",
"close_bid_mark_max_pct",
"residual_min_premium_pct",
"residual_close_check_sec",
@@ -109,6 +113,10 @@ class StrategySettingsBody(BaseModel):
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)
semi_moneyness: str | None = Field(default=None, pattern="^(itm|atm|otm)$")
semi_otm_max_offset: float | None = Field(default=None, ge=1, le=5000)
semi_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
semi_option_unit: float | None = Field(default=None, ge=0.01, 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_close_check_sec: int | None = Field(default=None, ge=30, le=86400)
@@ -296,6 +304,28 @@ def _read_settings() -> dict:
)
or s.semi_min_option_leverage
),
"semi_moneyness": (
lambda m: m
if m in ("itm", "atm", "otm")
else "otm"
)(
str(
db.get_setting("semi_moneyness", s.semi_moneyness) or s.semi_moneyness
)
.strip()
.lower()
),
"semi_otm_max_offset": float(
db.get_setting("semi_otm_max_offset", str(s.semi_otm_max_offset))
or s.semi_otm_max_offset
),
"semi_perp_unit": float(
db.get_setting("semi_perp_unit", str(s.semi_perp_unit)) or s.semi_perp_unit
),
"semi_option_unit": float(
db.get_setting("semi_option_unit", str(s.semi_option_unit))
or s.semi_option_unit
),
"close_bid_mark_max_pct": float(
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
or s.close_bid_mark_max_pct
@@ -555,6 +585,10 @@ async def put_strategy_settings(
"semi_perp_exit_unit",
"semi_min_option_hours",
"semi_min_option_leverage",
"semi_moneyness",
"semi_otm_max_offset",
"semi_perp_unit",
"semi_option_unit",
"semi_auto_enabled",
)
hit = [k for k in locked_keys if k in data]
+5 -1
View File
@@ -102,7 +102,11 @@ class Settings(BaseSettings):
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
semi_min_option_leverage: float = 200.0 # 虚值默认 200(虚值门仍强制≥180)
semi_moneyness: str = "otm" # itm|atm|otm
semi_otm_max_offset: float = 25.0 # 虚值 |KS| 上限(可配)
semi_perp_unit: float = 1.0 # 半自动永续:期权 配比分子
semi_option_unit: float = 4.0 # 配比分母,默认 1:4
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
# 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出
residual_min_premium_pct: float = 20.0
+8
View File
@@ -369,6 +369,10 @@ class StrategyEngine:
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_moneyness"] = sp.get("moneyness")
out["semi_otm_max_offset"] = sp.get("otm_max_offset")
out["semi_perp_unit"] = sp.get("perp_unit")
out["semi_option_unit"] = sp.get("option_unit")
out["semi_net_exit_target"] = float(sp["perp_exit_unit"]) * k_eff
except Exception:
logger.exception("semi params for state() failed")
@@ -1361,6 +1365,10 @@ class StrategyEngine:
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"]),
moneyness=str(sp_lock.get("moneyness") or "otm"),
otm_max_offset=float(sp_lock.get("otm_max_offset") or 25),
perp_unit=float(sp_lock.get("perp_unit") or 1),
option_unit=float(sp_lock.get("option_unit") or 4),
)
self._set_state(phase="open", last_error=None)
try:
+17
View File
@@ -76,10 +76,27 @@ def size_and_gate(
if not rs.ok:
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
else:
from ..sim.ledger import Ledger
from .risk_sizing import is_risk_based
from .semi_auto import is_semi_auto, read_semi_params
led = Ledger(database)
semi_perp = None
semi_opt = None
if is_semi_auto(led):
sp = read_semi_params(led)
semi_perp = float(sp["perp_unit"])
semi_opt = float(sp["option_unit"])
if not is_risk_based(led):
# 手动仓:半自动直接写入 永续:期权 配比名义
database.set_setting("perp_qty_eth", f"{semi_perp:.4f}")
database.set_setting("option_qty_eth", f"{semi_opt:.4f}")
rs = apply_risk_sizing_to_ledger(
index_px=float(index_px),
option_ask=float(option_ask),
db=database,
perp_unit=semi_perp,
option_unit=semi_opt,
)
if not rs.ok:
return OpenPrepResult(ok=False, detail=rs.detail, sizing_detail=rs.detail)
+15 -1
View File
@@ -428,12 +428,18 @@ def compute_risk_sizing(
index_px: float,
option_ask: float,
db: Database | None = None,
perp_unit: float | None = None,
option_unit: float | None = None,
) -> RiskSizingResult:
database = db or get_db()
ledger = Ledger(database)
s = get_settings()
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
perp_u, opt_u, exit_u = read_risk_units(ledger)
if perp_unit is not None and float(perp_unit) > 0:
perp_u = float(perp_unit)
if option_unit is not None and float(option_unit) > 0:
opt_u = float(option_unit)
basis_raw = ledger.get_setting_str(
"risk_leverage_basis", s.risk_leverage_basis
) or s.risk_leverage_basis
@@ -682,6 +688,8 @@ def apply_risk_sizing_to_ledger(
index_px: float,
option_ask: float,
db: Database | None = None,
perp_unit: float | None = None,
option_unit: float | None = None,
) -> RiskSizingResult:
"""计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。持仓中拒绝改写。"""
database = db or get_db()
@@ -699,7 +707,13 @@ def apply_risk_sizing_to_ledger(
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
)
r = compute_risk_sizing(index_px=index_px, option_ask=option_ask, db=database)
r = compute_risk_sizing(
index_px=index_px,
option_ask=option_ask,
db=database,
perp_unit=perp_unit,
option_unit=option_unit,
)
if not r.ok:
return r
+66 -5
View File
@@ -46,6 +46,53 @@ def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None:
return min(strikes, key=lambda s: (abs(s - mark_px), s))
def pick_otm_strike(
strikes: list[float],
mark_px: float,
*,
option_side: str,
max_offset: float,
) -> float | None:
"""
虚值Call K>SPut K<S取最接近标的且 |KS|max_offset 的一档
"""
if not strikes or mark_px <= 0:
return None
side = (option_side or "").strip().lower()
spot = float(mark_px)
cap = max(0.0, float(max_offset))
if side == "call":
cands = [
float(s)
for s in strikes
if float(s) > spot + 1e-9 and float(s) - spot <= cap + 1e-9
]
elif side == "put":
cands = [
float(s)
for s in strikes
if float(s) < spot - 1e-9 and spot - float(s) <= cap + 1e-9
]
else:
return None
if not cands:
return None
return min(cands, key=lambda s: (abs(s - spot), s))
def is_otm(*, option_side: str, strike: float, mark_px: float) -> bool:
if mark_px <= 0:
return False
side = (option_side or "").strip().lower()
k = float(strike)
s = float(mark_px)
if side == "call":
return k > s + 1e-9
if side == "put":
return k < s - 1e-9
return False
def pick_itm_or_atm_strike(
strikes: list[float],
mark_px: float,
@@ -166,11 +213,14 @@ def select_option_pair(
min_hours: float | None = None,
now: datetime | None = None,
option_side: str | None = None,
moneyness: str | None = None,
otm_max_offset: float | None = None,
) -> OptionPair | None:
"""
选到期 + 行权价
option_side call/put 按实值/平值选档固定方向模式
option_side call/put moneyness 选档默认实值/平值兼容固定方向
否则仍选 ATM现有规则
moneyness: itm | atm | otm仅半自动传入 otm/atm
"""
complete = _complete_by_expiry(contracts)
if not complete:
@@ -194,13 +244,24 @@ def select_option_pair(
ymd = eligible[0]
ems, strikes_map = complete[ymd]
keys = list(strikes_map.keys())
side = (option_side or "").strip().lower() or None
mny = (moneyness or "").strip().lower() or None
if side in ("call", "put"):
strike = pick_itm_or_atm_strike(
list(strikes_map.keys()), mark_px, option_side=side
)
if mny == "otm":
strike = pick_otm_strike(
keys,
mark_px,
option_side=side,
max_offset=float(otm_max_offset or 0),
)
elif mny == "atm":
strike = pick_atm_strike(keys, mark_px)
else:
# itm 或未指定:实值/平值(固定方向默认)
strike = pick_itm_or_atm_strike(keys, mark_px, option_side=side)
else:
strike = pick_atm_strike(list(strikes_map.keys()), mark_px)
strike = pick_atm_strike(keys, mark_px)
if strike is None:
return None
legs = strikes_map[strike]
+64 -7
View File
@@ -13,6 +13,8 @@ PHASE_WAIT_HUMAN = "wait_human"
REASON_POINTS = "semi_target_points"
REASON_PERP_NET = "semi_perp_exit"
TRADE_LOCK_KEY = "semi_trade_lock"
OTM_LEV_FLOOR = 180.0
MONEYNESS_CHOICES = ("itm", "atm", "otm")
def is_semi_auto(ledger: Ledger | None = None) -> bool:
@@ -60,12 +62,23 @@ def lock_trade_params(
view_side: str,
option_move_points: float,
perp_exit_unit: float,
moneyness: str = "otm",
otm_max_offset: float = 25.0,
perp_unit: float = 1.0,
option_unit: float = 4.0,
) -> None:
mny = str(moneyness or "otm").strip().lower()
if mny not in MONEYNESS_CHOICES:
mny = "otm"
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),
"moneyness": mny,
"otm_max_offset": float(otm_max_offset),
"perp_unit": float(perp_unit),
"option_unit": float(option_unit),
}
db.set_setting(TRADE_LOCK_KEY, json.dumps(payload, ensure_ascii=False))
@@ -74,6 +87,19 @@ def clear_trade_lock(db: Any) -> None:
db.set_setting(TRADE_LOCK_KEY, "")
def _norm_moneyness(raw: str | None, default: str = "otm") -> str:
m = str(raw or default).strip().lower()
return m if m in MONEYNESS_CHOICES else default
def effective_min_leverage(moneyness: str, configured: float) -> float:
"""虚值强制不低于 180;实值/平值用配置值。"""
lev = max(1.0, float(configured))
if _norm_moneyness(moneyness) == "otm":
return max(lev, OTM_LEV_FLOOR)
return lev
def read_semi_params(
ledger: Ledger | None = None,
*,
@@ -96,7 +122,32 @@ def read_semi_params(
led.get_setting_float("semi_perp_exit_unit", s.semi_perp_exit_unit)
or s.semi_perp_exit_unit
)
# 持仓中优先用开仓时锁定的出场参数
moneyness = _norm_moneyness(
led.get_setting_str("semi_moneyness", s.semi_moneyness),
s.semi_moneyness,
)
otm_off = float(
led.get_setting_float("semi_otm_max_offset", s.semi_otm_max_offset)
or s.semi_otm_max_offset
)
perp_u = float(
led.get_setting_float("semi_perp_unit", s.semi_perp_unit) or s.semi_perp_unit
)
opt_u = float(
led.get_setting_float("semi_option_unit", s.semi_option_unit)
or s.semi_option_unit
)
if perp_u <= 0:
perp_u = float(s.semi_perp_unit)
if opt_u <= 0:
opt_u = float(s.semi_option_unit)
min_lev_cfg = float(
led.get_setting_float(
"semi_min_option_leverage", s.semi_min_option_leverage
)
or s.semi_min_option_leverage
)
# 持仓中优先用开仓时锁定的出场/选约参数
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()
@@ -105,8 +156,12 @@ def read_semi_params(
try:
move = float(lock.get("option_move_points", move))
exit_unit = float(lock.get("perp_exit_unit", exit_unit))
otm_off = float(lock.get("otm_max_offset", otm_off))
perp_u = float(lock.get("perp_unit", perp_u))
opt_u = float(lock.get("option_unit", opt_u))
except (TypeError, ValueError):
pass
moneyness = _norm_moneyness(str(lock.get("moneyness") or moneyness), moneyness)
return {
"enabled": is_semi_auto(led),
"armed": is_armed(led),
@@ -116,19 +171,21 @@ def read_semi_params(
"perp_side": "short" if view == "long" else "long",
"option_move_points": move,
"perp_exit_unit": exit_unit,
"moneyness": moneyness,
"otm_max_offset": max(0.0, otm_off),
"perp_unit": perp_u,
"option_unit": opt_u,
"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
"min_option_leverage": effective_min_leverage(moneyness, min_lev_cfg),
"min_option_leverage_cfg": min_lev_cfg,
"trade_locked": bool(
lock and group_id and str(lock.get("group_id")) == str(group_id)
),
"trade_locked": bool(lock and group_id and str(lock.get("group_id")) == str(group_id)),
}
+35 -3
View File
@@ -726,8 +726,10 @@ class StrategySession:
s = self.settings
min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats()
fixed_on, fixed_perp = _fixed_direction()
# 半自动:强制看法方向 + 实值/平值 + 半自动选约门槛(须已授权)
# 半自动:强制看法方向 + 行权类型(itm/atm/otm) + 半自动选约门槛(须已授权)
semi_on = is_semi_auto()
semi_mny: str | None = None
semi_otm_off: float | None = None
if semi_on:
if not is_armed():
return None
@@ -737,6 +739,8 @@ class StrategySession:
min_hours = float(sp["min_option_hours"])
min_lev = float(sp["min_option_leverage"])
atm_off_on = False
semi_mny = str(sp.get("moneyness") or "otm")
semi_otm_off = float(sp.get("otm_max_offset") or 0)
opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None
# 第一关:振幅过滤(默认关;开启则回看窗振幅须 ≤ 最大%)
if not self._apply_amplitude_first_gate():
@@ -766,12 +770,40 @@ class StrategySession:
mark_px=underlying,
expiry_ymd=ymd,
option_side=opt_side_hint,
moneyness=semi_mny if semi_on else None,
otm_max_offset=semi_otm_off if semi_on else None,
)
if pair is None:
if semi_on and semi_mny == "otm":
logger.info(
"skip expiry=%s no OTM within offset=%.1f for %s mark=%.2f",
ymd,
float(semi_otm_off or 0),
opt_side_hint,
underlying,
)
continue
if fixed_on:
if not is_itm_or_atm(
option_side=opt_side_hint or "",
from .selection import is_otm
side = opt_side_hint or ""
if semi_on and semi_mny == "otm":
if not is_otm(
option_side=side,
strike=pair.strike,
mark_px=underlying,
):
continue
if (
atm_open_offset(pair.strike, underlying)
> float(semi_otm_off or 0) + 1e-9
):
continue
elif semi_on and semi_mny == "atm":
# 平值:须为该到期最接近标的的档
pass
elif not is_itm_or_atm(
option_side=side,
strike=pair.strike,
mark_px=underlying,
):
+26 -1
View File
@@ -2,7 +2,13 @@
from __future__ import annotations
from app.strategy.semi_auto import REASON_PERP_NET, REASON_POINTS, check_semi_exits
from app.strategy.selection import pick_otm_strike
from app.strategy.semi_auto import (
REASON_PERP_NET,
REASON_POINTS,
check_semi_exits,
effective_min_leverage,
)
def test_semi_points_long_needs_net_positive() -> None:
@@ -73,3 +79,22 @@ def test_semi_not_yet() -> None:
risk_k=1,
)
assert d.should_close is False
def test_otm_leverage_floor() -> None:
assert effective_min_leverage("otm", 100) == 180
assert effective_min_leverage("otm", 200) == 200
assert effective_min_leverage("itm", 100) == 100
def test_pick_otm_within_offset() -> None:
strikes = [1800.0, 1825.0, 1850.0, 1875.0]
# Call 虚值:标的 1830 → 185020点)在 25 内;1875 超
k = pick_otm_strike(strikes, 1830, option_side="call", max_offset=25)
assert k == 1850.0
assert (
pick_otm_strike(strikes, 1830, option_side="call", max_offset=15) is None
)
# Put 虚值
k2 = pick_otm_strike(strikes, 1830, option_side="put", max_offset=30)
assert k2 == 1825.0