Gate opens when ATM-spot offset exceeds configurable max (default 3).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-25 12:32:36 +08:00
parent a4bcc3ab0d
commit ee0ec57f89
12 changed files with 93 additions and 13 deletions
+6
View File
@@ -31,6 +31,7 @@ KEYS = (
"leverage",
"min_option_hours",
"min_option_leverage",
"max_atm_open_offset",
"close_bid_mark_max_pct",
"perp_qty_eth",
"option_qty_eth",
@@ -49,6 +50,7 @@ class StrategySettingsBody(BaseModel):
leverage: float | None = Field(default=None, ge=1, le=125)
min_option_hours: float | None = Field(default=None, ge=1, le=720)
min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
max_atm_open_offset: float | None = Field(default=None, ge=0, le=100)
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)
@@ -100,6 +102,10 @@ def _read_settings() -> dict:
db.get_setting("min_option_leverage", str(s.min_option_leverage))
or s.min_option_leverage
),
"max_atm_open_offset": float(
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
or s.max_atm_open_offset
),
"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
+1 -1
View File
@@ -52,7 +52,7 @@ async def sim_open_group(
if pick is None:
raise HTTPException(
status_code=409,
detail="无合格期权:请检查剩余时长(≥设置小时)与杠杆(现价/卖一)",
detail="无合格期权:请检查剩余时长、ATM距现价(≤开仓偏差上限)与杠杆(现价/卖一)",
)
force = (body.force_option_side if body else None) or None
+1
View File
@@ -61,6 +61,7 @@ class Settings(BaseSettings):
leverage: float = 3.0 # 永续杠杆
min_option_hours: float = 12.0 # 期权最小剩余小时
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
max_atm_open_offset: float = 3.0 # 开仓:|ATM行权价−标的| 上限(点)
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
perp_qty_eth: float = 1.0
option_qty_eth: float = 2.0
+1
View File
@@ -159,6 +159,7 @@ class Database:
"leverage": str(s.leverage),
"min_option_hours": str(s.min_option_hours),
"min_option_leverage": str(s.min_option_leverage),
"max_atm_open_offset": str(s.max_atm_open_offset),
"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),
+5 -1
View File
@@ -52,6 +52,9 @@ class StrategyEngine:
min_opt_lev = self.ledger.get_setting_float(
"min_option_leverage", s.min_option_leverage
)
max_atm_off = self.ledger.get_setting_float(
"max_atm_open_offset", s.max_atm_open_offset
)
rest_until = row["rest_until_ms"]
rest_left = 0
if rest_until:
@@ -77,6 +80,7 @@ class StrategyEngine:
"leverage": leverage,
"min_option_hours": min_hours,
"min_option_leverage": min_opt_lev,
"max_atm_open_offset": max_atm_off,
"can_open": allow_open,
"last_error": last_error,
"position": upl,
@@ -306,7 +310,7 @@ class StrategyEngine:
pick = await get_session().pick_for_open_async()
if pick is None:
self._set_state(
last_error="无合格期权:需剩余时长与杠杆倍数同时满足"
last_error="无合格期权:需剩余时长、ATM开仓偏差与杠杆同时满足"
)
return
+14
View File
@@ -46,6 +46,20 @@ def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None:
return min(strikes, key=lambda s: (abs(s - mark_px), s))
def atm_open_offset(strike: float, mark_px: float) -> float:
"""开仓用:ATM 行权价相对标的的绝对点差。"""
return abs(float(strike) - float(mark_px))
def atm_allows_open(
strike: float, mark_px: float, *, max_offset: float
) -> bool:
"""|strike mark| ≤ max_offset 才允许开仓。"""
if mark_px <= 0 or max_offset < 0:
return False
return atm_open_offset(strike, mark_px) <= float(max_offset) + 1e-9
def option_leverage(underlying_px: float, premium_ask: float) -> float | None:
if underlying_px <= 0 or premium_ask is None or premium_ask <= 0:
return None
+25 -6
View File
@@ -12,6 +12,8 @@ from ..exchange import get_exchange, set_exchange, build_exchange
from ..exchange.protocol import ExchangeMarket
from ..exchange.types import MarketSnapshot, OptionPair
from .selection import (
atm_allows_open,
atm_open_offset,
hours_until_expiry,
list_eligible_expiry_ymds,
option_leverage,
@@ -34,7 +36,8 @@ def _has_open_position() -> bool:
return False
def _strategy_floats() -> tuple[float, float]:
def _strategy_floats() -> tuple[float, float, float]:
"""min_hours, min_leverage, max_atm_open_offset"""
s = get_settings()
try:
from ..models.db import get_db
@@ -48,9 +51,13 @@ def _strategy_floats() -> tuple[float, float]:
db.get_setting("min_option_leverage", str(s.min_option_leverage))
or s.min_option_leverage
)
return hours, lev
atm_off = float(
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
or s.max_atm_open_offset
)
return hours, lev, atm_off
except Exception:
return s.min_option_hours, s.min_option_leverage
return s.min_option_hours, s.min_option_leverage, s.max_atm_open_offset
@dataclass(slots=True)
@@ -135,7 +142,7 @@ class StrategySession:
mark = self.ex.fetch_mark(s.perp_inst_id) or idx
if mark is None or mark <= 0:
raise RuntimeError("无法获取标的标记/指数价格,无法选 ATM")
min_hours, _ = _strategy_floats()
min_hours, _, _ = _strategy_floats()
contracts = self.ex.list_option_contracts(s.option_inst_family)
pair = select_option_pair(contracts, mark_px=float(mark), min_hours=min_hours)
if pair is None:
@@ -148,7 +155,7 @@ class StrategySession:
from .signal import decide
s = self.settings
min_hours, min_lev = _strategy_floats()
min_hours, min_lev, max_atm_off = _strategy_floats()
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:
@@ -164,6 +171,18 @@ class StrategySession:
pair = select_option_pair(contracts, mark_px=underlying, expiry_ymd=ymd)
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
):
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
@@ -257,7 +276,7 @@ class StrategySession:
def atm_needs_realign(self, mark_px: float | None = None) -> bool:
if self._pair is None:
return True
min_hours, _ = _strategy_floats()
min_hours, _, _ = _strategy_floats()
if (
hours_until_expiry(self._pair.expiry_ymd, expiry_ms=self._pair.expiry_ms)
+ 1e-9
+9
View File
@@ -74,3 +74,12 @@ def test_eligible_skips_short_ttm() -> None:
def test_option_leverage() -> None:
assert abs((option_leverage(1850, 18.5) or 0) - 100) < 1e-9
assert option_leverage(1850, 0) is None
def test_atm_allows_open_offset() -> None:
from app.strategy.selection import atm_allows_open, atm_open_offset
assert atm_open_offset(1850, 1852.5) == 2.5
assert atm_allows_open(1850, 1852.5, max_offset=3) is True
assert atm_allows_open(1850, 1854, max_offset=3) is False
assert atm_allows_open(1850, 1860, max_offset=3) is False
+4 -1
View File
@@ -56,6 +56,7 @@
→ 非周末跳过(若开启)
→ 选到期:剩余时长 ≥ min_option_hours(默认 12h
→ 该到期 ATM 行权价(最接近标的)
→ |ATM 标的| ≤ max_atm_open_offset(默认 3)否则跳过该到期
→ Call/Put 卖一比价选方向
→ 期权杠杆 = 标的价 ÷ 卖一权利金 ≥ min_option_leverage(默认 100
→ 开永续 + 开期权(一组)
@@ -68,7 +69,8 @@
|------|------|------|
| `min_option_hours` | 12 | 过滤过近到期,减少刚开仓就到期 |
| `min_option_leverage` | 100 | 权利金相对标的不能太贵(现价/卖一) |
| ATM | | 同到期、最接近指数/标记价的行权价 |
| `max_atm_open_offset` | 3 | 开仓:\|ATM 行权价 − 标的\| 超过则不开 |
| ATM | — | 同到期、最接近指数/标记价的行权价(展示可偏离;开仓另受偏差上限约束) |
无合格合约时:状态停留等待,记录「无合格期权…」,不硬开。
@@ -236,6 +238,7 @@
| `skip_weekends` | true | 时间 |
| `min_option_hours` | 12 | 选约 |
| `min_option_leverage` | 100 | 选约 |
| `max_atm_open_offset` | 3 | 开仓 ATM 偏差 |
| `close_bid_mark_max_pct` | 30 | 平仓流动性 |
---
+2
View File
@@ -131,6 +131,7 @@ export type PlanState = {
leverage: number;
min_option_hours: number;
min_option_leverage: number;
max_atm_open_offset?: number;
can_open: boolean;
last_error: string | null;
position: {
@@ -179,6 +180,7 @@ export type StrategySettings = {
leverage: number;
min_option_hours: number;
min_option_leverage: number;
max_atm_open_offset: number;
close_bid_mark_max_pct: number;
perp_qty_eth: number;
option_qty_eth: number;
+7 -4
View File
@@ -197,7 +197,8 @@ export default function PlanPage() {
<span></span>
<span className="mono">
{fmt(plan?.min_option_hours, 0)}h ·
{fmt(plan?.min_option_leverage, 0)}x
{fmt(plan?.min_option_leverage, 0)}x · ATM偏差
{fmt(plan?.max_atm_open_offset ?? 3, 0)}
</span>
</div>
<div className="kv">
@@ -415,9 +416,11 @@ export default function PlanPage() {
? (snap.perp.bid + snap.perp.ask) / 2
: null);
if (strike == null || px == null) return "—";
const d = strike - px;
const sign = d > 0 ? "+" : "";
return `${sign}${d.toFixed(0)}(最近可用行权价)`;
const abs = Math.abs(strike - px);
const sign = strike - px > 0 ? "+" : "";
const lim = plan?.max_atm_open_offset ?? 3;
const ok = abs <= lim + 1e-9;
return `${sign}${(strike - px).toFixed(0)} · 开仓${ok ? "可" : "不可"}(|Δ|≤${lim})`;
})()}
</span>
</div>
+18
View File
@@ -31,6 +31,7 @@ export default function SettingsPage() {
const [leverage, setLeverage] = useState(3);
const [minHours, setMinHours] = useState(12);
const [minOptLev, setMinOptLev] = useState(100);
const [maxAtmOff, setMaxAtmOff] = useState(3);
const [closeDevPct, setCloseDevPct] = useState(30);
const [perpQty, setPerpQty] = useState(1);
const [optQty, setOptQty] = useState(2);
@@ -50,6 +51,7 @@ export default function SettingsPage() {
setLeverage(s.leverage ?? 3);
setMinHours(s.min_option_hours ?? 12);
setMinOptLev(s.min_option_leverage ?? 100);
setMaxAtmOff(s.max_atm_open_offset ?? 3);
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
setPerpQty(s.perp_qty_eth ?? 1);
setOptQty(s.option_qty_eth ?? 2);
@@ -107,6 +109,7 @@ export default function SettingsPage() {
leverage,
min_option_hours: minHours,
min_option_leverage: minOptLev,
max_atm_open_offset: maxAtmOff,
close_bid_mark_max_pct: closeDevPct,
perp_qty_eth: perpQty,
option_qty_eth: optQty,
@@ -257,6 +260,21 @@ export default function SettingsPage() {
onChange={(e) => setMinOptLev(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="atmoff"> ATM </label>
<input
id="atmoff"
className="mono"
type="number"
step="0.5"
min="0"
value={maxAtmOff}
onChange={(e) => setMaxAtmOff(Number(e.target.value))}
/>
<p className="settings-hint">
|ATM | 3
</p>
</div>
</div>
</section>