Reject OO strikes more than 1% from amplitude high/low.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-07 17:50:12 +08:00
parent 6d63c52ae0
commit 0daac05ef5
9 changed files with 124 additions and 19 deletions
+9
View File
@@ -71,6 +71,7 @@ KEYS = (
"oo_min_leverage",
"oo_reward_ratio",
"oo_budget_cushion",
"oo_strike_max_dev_pct",
)
@@ -126,6 +127,7 @@ class StrategySettingsBody(BaseModel):
oo_min_leverage: float | None = Field(default=None, ge=1, le=10000)
oo_reward_ratio: float | None = Field(default=None, ge=0.5, le=20)
oo_budget_cushion: float | None = Field(default=None, ge=0.5, le=1.0)
oo_strike_max_dev_pct: float | None = Field(default=None, ge=0.1, le=10)
def _as_bool(raw: str | None, default: bool) -> bool:
@@ -379,6 +381,12 @@ def _read_settings() -> dict:
db.get_setting("oo_budget_cushion", str(s.oo_budget_cushion))
or s.oo_budget_cushion
),
"oo_strike_max_dev_pct": float(
db.get_setting(
"oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct)
)
or s.oo_strike_max_dev_pct
),
"risk_sizing_preview": _risk_preview_safe(),
"exchange": rt.exchange,
"perp_inst_id": rt.perp_inst_id,
@@ -472,6 +480,7 @@ async def put_strategy_settings(
"oo_min_leverage",
"oo_reward_ratio",
"oo_budget_cushion",
"oo_strike_max_dev_pct",
)
hit = [k for k in locked_keys if k in data]
if hit:
+1
View File
@@ -88,6 +88,7 @@ class Settings(BaseSettings):
oo_min_leverage: float = 200.0 # 期期:单腿最低杠杆
oo_reward_ratio: float = 2.0 # 盈亏比:出场目标 = 预算 ×
oo_budget_cushion: float = 0.92 # 定仓预留余地(用于权利金的预算比例)
oo_strike_max_dev_pct: float = 1.0 # 虚值行权价相对振幅高低点最大偏离 %
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点)
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
+6
View File
@@ -297,6 +297,12 @@ class StrategyEngine:
)
or s.oo_reward_ratio
),
"oo_strike_max_dev_pct": float(
self.ledger.get_setting_float(
"oo_strike_max_dev_pct", s.oo_strike_max_dev_pct
)
or s.oo_strike_max_dev_pct
),
"risk_perp_unit": risk_perp_unit,
"risk_option_unit": risk_option_unit,
"risk_exit_unit": risk_exit_unit,
+40 -8
View File
@@ -36,17 +36,42 @@ class OoPickCore:
detail: str = "ok"
def pick_otm_call_strike(strikes: list[float], *, spot: float, high: float) -> float | None:
"""虚值 CallK > spot,优先贴近振幅高点"""
def _within_ref_pct(strike: float, ref: float, max_dev_pct: float) -> bool:
"""|Kref|/ref ≤ max_dev_pct%"""
if ref <= 0 or max_dev_pct < 0:
return False
return abs(float(strike) - float(ref)) / float(ref) * 100.0 <= float(
max_dev_pct
) + 1e-12
def pick_otm_call_strike(
strikes: list[float],
*,
spot: float,
high: float,
max_dev_pct: float = 1.0,
) -> float | None:
"""虚值 CallK > spot,贴近振幅高点,且 |K−高|/高 ≤ max_dev_pct%"""
cands = [float(s) for s in strikes if float(s) > float(spot) + 1e-9]
if max_dev_pct >= 0 and high > 0:
cands = [s for s in cands if _within_ref_pct(s, high, max_dev_pct)]
if not cands:
return None
return min(cands, key=lambda s: (abs(s - float(high)), s))
def pick_otm_put_strike(strikes: list[float], *, spot: float, low: float) -> float | None:
"""虚值 PutK < spot,优先贴近振幅低点。"""
def pick_otm_put_strike(
strikes: list[float],
*,
spot: float,
low: float,
max_dev_pct: float = 1.0,
) -> float | None:
"""虚值 PutK < spot,贴近振幅低点,且 |K−低|/低 ≤ max_dev_pct%"""
cands = [float(s) for s in strikes if float(s) < float(spot) - 1e-9]
if max_dev_pct >= 0 and low > 0:
cands = [s for s in cands if _within_ref_pct(s, low, max_dev_pct)]
if not cands:
return None
return min(cands, key=lambda s: (abs(s - float(low)), s))
@@ -61,10 +86,11 @@ def select_oo_pair(
min_hours: float,
now: datetime | None = None,
skip_expiry_ymds: set[str] | None = None,
max_dev_pct: float = 1.0,
) -> tuple[str, int, float, float, str, str] | None:
"""
返回 (expiry_ymd, expiry_ms, call_strike, put_strike, call_inst, put_inst)。
Call/Put 可不同行权价;须同到期均为虚值。
Call/Put 可不同行权价;须同到期均为虚值,且相对高低点偏离不超过 max_dev_pct%
"""
if spot <= 0 or high <= 0 or low <= 0 or high < low:
return None
@@ -80,8 +106,12 @@ def select_oo_pair(
for ymd in eligible:
ems, strikes_map = complete[ymd]
strikes = list(strikes_map.keys())
ck = pick_otm_call_strike(strikes, spot=spot, high=high)
pk = pick_otm_put_strike(strikes, spot=spot, low=low)
ck = pick_otm_call_strike(
strikes, spot=spot, high=high, max_dev_pct=max_dev_pct
)
pk = pick_otm_put_strike(
strikes, spot=spot, low=low, max_dev_pct=max_dev_pct
)
if ck is None or pk is None:
continue
call_inst = strikes_map[ck].get("C")
@@ -113,8 +143,9 @@ def build_oo_pick_core(
amplitude: AmplitudeHL | None = None,
skip_expiry_ymds: set[str] | None = None,
now: datetime | None = None,
max_dev_pct: float = 1.0,
) -> OoPickCore | None:
"""完整期期选约:振幅门 + 虚值双腿 + 杠杆。"""
"""完整期期选约:振幅门 + 虚值双腿(贴高低≤max_dev% + 杠杆。"""
amp = amplitude or fetch_amplitude_hl_for_runtime(amplitude_hours)
if amp is None:
return None
@@ -130,6 +161,7 @@ def build_oo_pick_core(
min_hours=float(min_hours),
now=now,
skip_expiry_ymds=skip_expiry_ymds,
max_dev_pct=float(max_dev_pct),
)
if picked is None:
return None
+21 -8
View File
@@ -192,8 +192,8 @@ def _hedge_mode() -> str:
return "perp_option"
def _oo_settings() -> tuple[float, float, float, float]:
"""amplitude_pct, amplitude_hours, min_option_hours, min_leverage"""
def _oo_settings() -> tuple[float, float, float, float, float]:
"""amplitude_pct, amplitude_hours, min_option_hours, min_leverage, strike_max_dev_pct"""
s = get_settings()
try:
from ..models.db import get_db
@@ -210,6 +210,12 @@ def _oo_settings() -> tuple[float, float, float, float]:
or s.oo_min_option_hours
),
float(db.get_setting("oo_min_leverage", str(s.oo_min_leverage)) or s.oo_min_leverage),
float(
db.get_setting(
"oo_strike_max_dev_pct", str(s.oo_strike_max_dev_pct)
)
or s.oo_strike_max_dev_pct
),
)
except Exception:
return (
@@ -217,6 +223,7 @@ def _oo_settings() -> tuple[float, float, float, float]:
s.oo_amplitude_hours,
s.oo_min_option_hours,
s.oo_min_leverage,
s.oo_strike_max_dev_pct,
)
@@ -450,7 +457,7 @@ class StrategySession:
try:
from ..exchange.candles import fetch_amplitude_hl_for_runtime
amp_pct, amp_hours, _, _ = _oo_settings()
amp_pct, amp_hours, _, _, _ = _oo_settings()
amp = fetch_amplitude_hl_for_runtime(amp_hours)
if amp is None:
return self._oo_amp
@@ -477,7 +484,7 @@ class StrategySession:
if _has_open_position():
return self.align_to_held_position()
s = self.settings
amp_pct, amp_hours, min_hours, _min_lev = _oo_settings()
amp_pct, amp_hours, min_hours, _min_lev, max_dev = _oo_settings()
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:
@@ -496,10 +503,11 @@ class StrategySession:
low=amp_low,
min_hours=float(min_hours),
skip_expiry_ymds=skip,
max_dev_pct=float(max_dev),
)
if picked is None:
raise RuntimeError(
f"未找到剩余≥{min_hours}h 的虚值 Call@高/Put@低"
f"未找到剩余≥{min_hours}h 且贴高低≤{max_dev:g}% 的虚值 Call/Put"
)
ymd, ems, ck, pk, call_inst, put_inst = picked
pair = OptionPair(
@@ -527,7 +535,7 @@ class StrategySession:
from .selection import _complete_by_expiry, option_leverage
s = self.settings
amp_pct, amp_hours, min_hours, min_lev = _oo_settings()
amp_pct, amp_hours, min_hours, min_lev, max_dev = _oo_settings()
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:
@@ -565,9 +573,13 @@ class StrategySession:
low=float(amp.low),
min_hours=float(min_hours),
skip_expiry_ymds=skip,
max_dev_pct=float(max_dev),
)
if picked is None:
logger.info("oo: no OTM call/put pair for amplitude HL")
logger.info(
"oo: no OTM call/put within %.2f%% of amplitude HL",
max_dev,
)
return None
ymd, ems, ck, pk, call_inst, put_inst = picked
call_bids, call_asks, _ = self.ex.fetch_book(call_inst, depth=5)
@@ -840,7 +852,7 @@ class StrategySession:
def oo_needs_realign(self) -> bool:
if self._pair is None:
return True
_amp_pct, amp_hours, min_hours, _ = _oo_settings()
_amp_pct, amp_hours, min_hours, _, max_dev = _oo_settings()
if (
hours_until_expiry(self._pair.expiry_ymd, expiry_ms=self._pair.expiry_ms)
+ 1e-9
@@ -877,6 +889,7 @@ class StrategySession:
low=float(amp.low),
min_hours=float(min_hours),
skip_expiry_ymds=skip,
max_dev_pct=float(max_dev),
)
if picked is None:
return False
+23 -2
View File
@@ -17,6 +17,23 @@ def test_otm_strikes_near_amplitude() -> None:
assert pick_otm_put_strike(strikes, spot=1950, low=1860) == 1850.0
def test_otm_strikes_reject_beyond_1pct() -> None:
# 高点 2000,最近虚值 Call 仅 2100(偏离 5%)→ 拒绝
strikes = [1900.0, 1950.0, 2100.0]
assert pick_otm_call_strike(strikes, spot=1950, high=2000, max_dev_pct=1.0) is None
# 低点 1900,最近虚值 Put 仅 1800(偏离 ~5.3%)→ 拒绝
assert (
pick_otm_put_strike(
[1800.0, 1950.0, 2000.0], spot=1950, low=1900, max_dev_pct=1.0
)
is None
)
# 高点 2095Call 2100 偏离约 0.24% → 通过
assert (
pick_otm_call_strike(strikes, spot=1950, high=2095, max_dev_pct=1.0) == 2100.0
)
def test_select_oo_pair_same_expiry(tmp_path=None) -> None:
contracts = []
for k in (1900, 2000, 2100):
@@ -36,6 +53,7 @@ def test_select_oo_pair_same_expiry(tmp_path=None) -> None:
high=2105.0,
low=1890.0,
min_hours=1.0,
max_dev_pct=1.0,
)
assert picked is not None
ymd, _ems, ck, pk, call_i, put_i = picked
@@ -114,7 +132,7 @@ def test_amplitude_max_gate() -> None:
from app.strategy.oo_selection import build_oo_pick_core
contracts = []
for k in (1900, 2000, 2100):
for k in (1900, 1975, 2000, 2025, 2100):
for side, letter in (("call", "C"), ("put", "P")):
contracts.append(
{
@@ -141,7 +159,7 @@ def test_amplitude_max_gate() -> None:
)
is None
)
# 3% ≤ 上限 3.5% → 可过振幅门(杠杆/卖一足够)
# 3% ≤ 上限 3.5%,且 2025/1975 贴高低 ≤1% → 通过
ok = build_oo_pick_core(
contracts=contracts,
spot=2000,
@@ -152,5 +170,8 @@ def test_amplitude_max_gate() -> None:
amplitude_hours=12,
amplitude_pct=3.5,
amplitude=amp,
max_dev_pct=1.0,
)
assert ok is not None
assert ok.call.strike == 2025.0
assert ok.put.strike == 1975.0
+3
View File
@@ -373,6 +373,7 @@ export type PlanState = {
oo_min_option_hours?: number;
oo_min_leverage?: number;
oo_reward_ratio?: number;
oo_strike_max_dev_pct?: number;
oo_put_qty_eth?: number;
sizing_mode?: "manual" | "risk_based";
risk_based?: boolean;
@@ -428,6 +429,8 @@ export type StrategySettings = {
oo_min_leverage?: number;
oo_reward_ratio?: number;
oo_budget_cushion?: number;
oo_strike_max_dev_pct?: number;
oo_strike_max_dev_pct?: number;
exchange?: string;
};
+1
View File
@@ -265,6 +265,7 @@ export default function PlanPage() {
: "ATM偏差关";
const ooSelectLabel = [
`振幅≤${fmt(plan?.oo_amplitude_pct ?? 1.5, 1)}%/${fmt(plan?.oo_amplitude_hours ?? 12, 0)}h`,
`贴高低≤${fmt(plan?.oo_strike_max_dev_pct ?? 1, 1)}%`,
"虚值Call@高·Put@低",
`剩余≥${fmt(plan?.oo_min_option_hours ?? 24, 0)}h`,
`杠杆≥${fmt(plan?.oo_min_leverage ?? 200, 0)}x`,
+20 -1
View File
@@ -115,6 +115,7 @@ export default function SettingsPage() {
const [ooAmpHours, setOoAmpHours] = useState(12);
const [ooMinHours, setOoMinHours] = useState(24);
const [ooMinLev, setOoMinLev] = useState(200);
const [ooStrikeDev, setOoStrikeDev] = useState(1);
const [ooRewardRatio, setOoRewardRatio] = useState(2);
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
null,
@@ -236,6 +237,7 @@ export default function SettingsPage() {
setOoAmpHours(s.oo_amplitude_hours ?? 12);
setOoMinHours(s.oo_min_option_hours ?? 24);
setOoMinLev(s.oo_min_leverage ?? 200);
setOoStrikeDev(s.oo_strike_max_dev_pct ?? 1);
setOoRewardRatio(s.oo_reward_ratio ?? 2);
setRiskPreview(
s.risk_sizing_preview && typeof s.risk_sizing_preview === "object"
@@ -412,6 +414,7 @@ export default function SettingsPage() {
oo_amplitude_hours: ooAmpHours,
oo_min_option_hours: ooMinHours,
oo_min_leverage: ooMinLev,
oo_strike_max_dev_pct: ooStrikeDev,
oo_reward_ratio: ooRewardRatio,
exchange,
};
@@ -1281,8 +1284,24 @@ export default function SettingsPage() {
value={ooMinLev}
onChange={(e) => setOoMinLev(Number(e.target.value))}
/>
</div>
<div className="field">
<label htmlFor="ooStrikeDev">%</label>
<input
id="ooStrikeDev"
className="mono"
type="number"
step="0.1"
min="0.1"
max="10"
value={ooStrikeDev}
onChange={(e) =>
setOoStrikeDev(Number(e.target.value))
}
/>
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
Call Put 使 ATM/
Call/Put / |KHL|/HL
1%
</p>
</div>
</>