Add fixed-direction switch for perp/option open side.

When enabled, lock perp long→buy Put or short→buy Call with ITM/ATM only; off keeps ATM/ask rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 19:18:12 +08:00
parent 55109e82d3
commit 0ee1d8be5d
16 changed files with 427 additions and 46 deletions
+23
View File
@@ -41,6 +41,8 @@ KEYS = (
"min_option_leverage",
"atm_open_offset_enabled",
"max_atm_open_offset",
"fixed_direction_enabled",
"fixed_perp_side",
"close_bid_mark_max_pct",
"perp_qty_eth",
"option_qty_eth",
@@ -63,6 +65,8 @@ class StrategySettingsBody(BaseModel):
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
atm_open_offset_enabled: bool | None = None
max_atm_open_offset: float | None = Field(default=None, ge=0, le=100)
fixed_direction_enabled: bool | None = None
fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$")
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
perp_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
@@ -131,6 +135,25 @@ def _read_settings() -> dict:
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
or s.max_atm_open_offset
),
"fixed_direction_enabled": _as_bool(
db.get_setting(
"fixed_direction_enabled", str(s.fixed_direction_enabled)
),
s.fixed_direction_enabled,
),
"fixed_perp_side": (
side
if (
side := str(
db.get_setting("fixed_perp_side", s.fixed_perp_side)
or s.fixed_perp_side
)
.strip()
.lower()
)
in ("long", "short")
else "long"
),
"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
+3
View File
@@ -72,6 +72,9 @@ class Settings(BaseSettings):
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点)
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
fixed_direction_enabled: bool = False
fixed_perp_side: str = "long" # long|shortlong→买Putshort→买Call
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
perp_qty_eth: float = 1.0
option_qty_eth: float = 2.0
+20 -1
View File
@@ -88,7 +88,26 @@ def _open_bias(
call: Quote | None,
put: Quote | None,
) -> str:
"""与开仓 decide 一致:先按 ATM 相对现价,贴平时再卖一比价"""
"""与开仓 decide 一致;固定方向开启时显示 fixed_*"""
try:
from ..config import get_settings
from ..models.db import get_db
s = get_settings()
db = get_db()
raw = db.get_setting(
"fixed_direction_enabled", str(s.fixed_direction_enabled)
)
on = str(raw or "").strip().lower() in ("1", "true", "yes", "on")
if on:
side = str(
db.get_setting("fixed_perp_side", s.fixed_perp_side)
or s.fixed_perp_side
or "long"
).strip().lower()
return "fixed_long_put" if side == "long" else "fixed_short_call"
except Exception:
pass
mark = None
if index_px is not None and index_px > 0:
mark = float(index_px)
+2
View File
@@ -40,6 +40,7 @@ def select_option_pair(
expiry_ymd: str | None = None,
min_hours: float | None = None,
now=None,
option_side: str | None = None,
) -> OptionPair | None:
contracts = normalize_contracts(instruments)
return _select_pair(
@@ -48,6 +49,7 @@ def select_option_pair(
expiry_ymd=expiry_ymd,
min_hours=min_hours,
now=now,
option_side=option_side,
)
+2
View File
@@ -232,6 +232,8 @@ class Database:
"min_option_leverage": str(s.min_option_leverage),
"atm_open_offset_enabled": str(s.atm_open_offset_enabled),
"max_atm_open_offset": str(s.max_atm_open_offset),
"fixed_direction_enabled": str(s.fixed_direction_enabled),
"fixed_perp_side": str(s.fixed_perp_side),
"close_bid_mark_max_pct": str(s.close_bid_mark_max_pct),
"perp_qty_eth": str(s.perp_qty_eth),
"option_qty_eth": str(s.option_qty_eth),
+12
View File
@@ -93,6 +93,16 @@ class StrategyEngine:
max_atm_off = self.ledger.get_setting_float(
"max_atm_open_offset", s.max_atm_open_offset
)
fixed_dir_on = self.ledger.get_setting_bool(
"fixed_direction_enabled", s.fixed_direction_enabled
)
fixed_perp = str(
self.ledger.get_setting_str("fixed_perp_side", s.fixed_perp_side)
or s.fixed_perp_side
or "long"
).strip().lower()
if fixed_perp not in ("long", "short"):
fixed_perp = "long"
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
rest_until = row["rest_until_ms"]
@@ -136,6 +146,8 @@ class StrategyEngine:
"min_option_leverage": min_opt_lev,
"atm_open_offset_enabled": atm_off_on,
"max_atm_open_offset": max_atm_off,
"fixed_direction_enabled": fixed_dir_on,
"fixed_perp_side": fixed_perp,
"can_open": allow_open,
"open_capacity": open_cap,
"last_error": last_error,
+56 -4
View File
@@ -46,6 +46,46 @@ 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_itm_or_atm_strike(
strikes: list[float],
mark_px: float,
*,
option_side: str,
) -> float | None:
"""
固定方向选约:只要实值或平值,不要虚值。
- Call:行权价 ≤ 标的(平值/实值)
- Put:行权价 ≥ 标的(平值/实值)
在合格档中取最接近标的者(优先平值)。
"""
if not strikes or mark_px <= 0:
return None
side = (option_side or "").strip().lower()
if side == "call":
cands = [float(s) for s in strikes if float(s) <= float(mark_px) + 1e-9]
elif side == "put":
cands = [float(s) for s in strikes if float(s) >= float(mark_px) - 1e-9]
else:
return None
if not cands:
return None
return min(cands, key=lambda s: (abs(s - float(mark_px)), s))
def is_itm_or_atm(*, option_side: str, strike: float, mark_px: float) -> bool:
"""Call: K≤SPut: K≥S。"""
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 atm_open_offset(strike: float, mark_px: float) -> float:
"""开仓用:ATM 行权价相对标的的绝对点差。"""
return abs(float(strike) - float(mark_px))
@@ -125,7 +165,13 @@ def select_option_pair(
expiry_ymd: str | None = None,
min_hours: float | None = None,
now: datetime | None = None,
option_side: str | None = None,
) -> OptionPair | None:
"""
选到期 + 行权价。
option_side 为 call/put 时:按实值/平值选档(固定方向模式);
否则仍选 ATM(现有规则)。
"""
complete = _complete_by_expiry(contracts)
if not complete:
return None
@@ -148,14 +194,20 @@ def select_option_pair(
ymd = eligible[0]
ems, strikes_map = complete[ymd]
atm = pick_atm_strike(list(strikes_map.keys()), mark_px)
if atm is None:
side = (option_side 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
)
else:
strike = pick_atm_strike(list(strikes_map.keys()), mark_px)
if strike is None:
return None
legs = strikes_map[atm]
legs = strikes_map[strike]
return OptionPair(
expiry_ymd=ymd,
expiry_ms=ems,
strike=atm,
strike=strike,
call_inst_id=legs["C"],
put_inst_id=legs["P"],
)
+95 -24
View File
@@ -16,6 +16,7 @@ from .selection import (
atm_allows_open,
atm_open_offset,
hours_until_expiry,
is_itm_or_atm,
list_eligible_expiry_ymds,
option_leverage,
select_option_pair,
@@ -101,6 +102,36 @@ def _strategy_floats() -> tuple[float, float, float, bool]:
)
def _fixed_direction() -> tuple[bool, str]:
"""(enabled, perp_side long|short)。默认关。"""
s = get_settings()
try:
from ..models.db import get_db
db = get_db()
enabled = _as_bool_setting(
db.get_setting(
"fixed_direction_enabled", str(s.fixed_direction_enabled)
),
s.fixed_direction_enabled,
)
side = str(
db.get_setting("fixed_perp_side", s.fixed_perp_side) or s.fixed_perp_side
).strip().lower()
if side not in ("long", "short"):
side = "long"
return enabled, side
except Exception:
side = str(s.fixed_perp_side or "long").strip().lower()
if side not in ("long", "short"):
side = "long"
return bool(s.fixed_direction_enabled), side
def _option_side_for_perp(perp_side: str) -> str:
return "put" if (perp_side or "").strip().lower() == "long" else "call"
@dataclass(slots=True)
class OpenPick:
pair: OptionPair
@@ -230,19 +261,29 @@ class StrategySession:
if mark is None or mark <= 0:
raise RuntimeError("无法获取标的标记/指数价格,无法选 ATM")
min_hours, _, _, _ = _strategy_floats()
fixed_on, fixed_perp = _fixed_direction()
opt_side = _option_side_for_perp(fixed_perp) if fixed_on else None
contracts = self.ex.list_option_contracts(s.option_inst_family)
pair = select_option_pair(contracts, mark_px=float(mark), min_hours=min_hours)
pair = select_option_pair(
contracts,
mark_px=float(mark),
min_hours=min_hours,
option_side=opt_side,
)
if pair is None:
kind = f"实值/平值 {opt_side}" if opt_side else "ATM"
raise RuntimeError(
f"未找到剩余≥{min_hours}h 的 ATM Call/Put (family={s.option_inst_family})"
f"未找到剩余≥{min_hours}h 的 {kind} Call/Put (family={s.option_inst_family})"
)
return self._apply_pair(pair, mark=float(mark), idx=idx)
def pick_for_open(self) -> OpenPick | None:
from .signal import decide
from .signal import decide, decide_fixed
s = self.settings
min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats()
fixed_on, fixed_perp = _fixed_direction()
opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None
idx = self.ex.fetch_index(s.index_inst_id)
mark = self.ex.fetch_mark(s.perp_inst_id) or idx
if mark is None or mark <= 0:
@@ -255,24 +296,44 @@ class StrategySession:
return None
for ymd in eligible:
pair = select_option_pair(contracts, mark_px=underlying, expiry_ymd=ymd)
pair = select_option_pair(
contracts,
mark_px=underlying,
expiry_ymd=ymd,
option_side=opt_side_hint,
)
if pair is None:
continue
offset = atm_open_offset(pair.strike, underlying)
if not atm_allows_open(
pair.strike,
underlying,
max_offset=max_atm_off,
enabled=atm_off_on,
):
logger.info(
"skip expiry=%s strike=%.0f atm_offset=%.1f > max=%.1f",
ymd,
if fixed_on:
if not is_itm_or_atm(
option_side=opt_side_hint or "",
strike=pair.strike,
mark_px=underlying,
):
logger.info(
"skip expiry=%s strike=%.0f not ITM/ATM for %s mark=%.2f",
ymd,
pair.strike,
opt_side_hint,
underlying,
)
continue
else:
offset = atm_open_offset(pair.strike, underlying)
if not atm_allows_open(
pair.strike,
offset,
max_atm_off,
)
continue
underlying,
max_offset=max_atm_off,
enabled=atm_off_on,
):
logger.info(
"skip expiry=%s strike=%.0f atm_offset=%.1f > max=%.1f",
ymd,
pair.strike,
offset,
max_atm_off,
)
continue
call_bids, call_asks, _ = self.ex.fetch_book(pair.call_inst_id, depth=5)
put_bids, put_asks, _ = self.ex.fetch_book(pair.put_inst_id, depth=5)
call_ask = call_asks[0].px if call_asks else None
@@ -284,12 +345,15 @@ class StrategySession:
if put_ask is None:
pq = self.ex.quote(pair.put_inst_id)
put_ask = pq.ask if pq else None
sig = decide(
call_ask,
put_ask,
strike=pair.strike,
mark_px=underlying,
)
if fixed_on:
sig = decide_fixed(call_ask, put_ask, perp_side=fixed_perp)
else:
sig = decide(
call_ask,
put_ask,
strike=pair.strike,
mark_px=underlying,
)
if sig is None:
continue
opt_ask = sig.call_ask if sig.option_side == "call" else sig.put_ask
@@ -376,6 +440,13 @@ class StrategySession:
mark = mark_px if mark_px is not None else self._mark_for_atm()
if mark is None or mark <= 0:
return False
fixed_on, fixed_perp = _fixed_direction()
if fixed_on:
opt = _option_side_for_perp(fixed_perp)
if not is_itm_or_atm(
option_side=opt, strike=float(self._pair.strike), mark_px=float(mark)
):
return True
return abs(float(self._pair.strike) - float(mark)) >= _ATM_DRIFT_POINTS
async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None:
+35
View File
@@ -66,3 +66,38 @@ def decide(
put_ask=pa,
)
return None
def decide_fixed(
call_ask: float | None,
put_ask: float | None,
*,
perp_side: str,
) -> Signal | None:
"""
固定方向:
- 永续多 → 买 Put
- 永续空 → 买 Call
"""
if call_ask is None or put_ask is None:
return None
side = (perp_side or "").strip().lower()
ca = float(call_ask)
pa = float(put_ask)
if side == "long":
return Signal(
bias="fixed_long_put",
option_side="put",
perp_side="long",
call_ask=ca,
put_ask=pa,
)
if side == "short":
return Signal(
bias="fixed_short_call",
option_side="call",
perp_side="short",
call_ask=ca,
put_ask=pa,
)
return None