f1e3d5527a
Monitor no longer shows ATM Put bias while semi-auto is authorized for Call. Co-authored-by: Cursor <cursoragent@cursor.com>
1313 lines
48 KiB
Python
1313 lines
48 KiB
Python
"""策略行情会话:在交易所适配器之上做 ATM 对齐与开仓选约。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from ..config import Settings, get_settings
|
||
from ..exchange import get_exchange, set_exchange, build_exchange
|
||
from ..exchange.option_ids import pair_from_option_inst
|
||
from ..exchange.protocol import ExchangeMarket
|
||
from ..exchange.types import MarketSnapshot, OptionPair
|
||
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,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_ATM_DRIFT_POINTS = 5.0
|
||
_session: StrategySession | None = None
|
||
|
||
|
||
def _has_open_position() -> bool:
|
||
try:
|
||
from ..models.db import get_db
|
||
from ..sim.matcher import BLOCKING_STATUSES
|
||
|
||
row = get_db().fetchone("SELECT status, group_id, option_inst_id FROM positions WHERE id=1")
|
||
if not row:
|
||
return False
|
||
st = str(row["status"] or "")
|
||
if st not in BLOCKING_STATUSES:
|
||
return False
|
||
return bool(row["group_id"] or row["option_inst_id"])
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _held_option_inst_id() -> str | None:
|
||
"""活跃持仓期权合约;无仓返回 None。"""
|
||
try:
|
||
from ..models.db import get_db
|
||
|
||
row = get_db().fetchone(
|
||
"SELECT status, option_inst_id FROM positions WHERE id=1"
|
||
)
|
||
if not row or row["status"] not in ("open", "half_open", "option_closed_perp_pending"):
|
||
return None
|
||
# 期权已平待平永续:不再钉期权盘口
|
||
if row["status"] == "option_closed_perp_pending":
|
||
return None
|
||
inst = str(row["option_inst_id"] or "").strip()
|
||
return inst or None
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _held_option_legs() -> tuple[str | None, str | None]:
|
||
"""期期持仓:返回 (call_inst, put_inst);非期期或无仓则 put 为空。"""
|
||
try:
|
||
from ..models.db import get_db
|
||
|
||
row = get_db().fetchone(
|
||
"""SELECT status, hedge_mode, option_inst_id, option2_inst_id
|
||
FROM positions WHERE id=1"""
|
||
)
|
||
if not row or row["status"] not in ("open", "half_open"):
|
||
return None, None
|
||
call_id = str(row["option_inst_id"] or "").strip() or None
|
||
put_id = str(row["option2_inst_id"] or "").strip() or None
|
||
if str(row["hedge_mode"] or "").strip().lower() != "option_option":
|
||
return call_id, None
|
||
return call_id, put_id
|
||
except Exception:
|
||
return None, None
|
||
|
||
|
||
def _as_bool_setting(raw: str | None, default: bool) -> bool:
|
||
if raw is None or raw == "":
|
||
return default
|
||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||
|
||
|
||
def _skip_expiry_ymds_for_next() -> set[str]:
|
||
"""
|
||
空仓选约/监控应跳过的到期日:
|
||
- 历史上已开过该到期(one_expiry_per_day,跨日)
|
||
- 仍有待结算残留期权的到期档(该档已「完成」开平,盯下一档)
|
||
"""
|
||
skip: set[str] = set()
|
||
try:
|
||
from ..models.db import get_db
|
||
from .clock import pending_residual_expiry_ymds, used_expiry_ymds
|
||
|
||
s = get_settings()
|
||
db = get_db()
|
||
one_exp_day = _as_bool_setting(
|
||
db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)),
|
||
s.one_expiry_per_day,
|
||
)
|
||
if one_exp_day:
|
||
skip |= used_expiry_ymds(db)
|
||
skip |= pending_residual_expiry_ymds(db)
|
||
except Exception:
|
||
logger.exception("skip-expiry lookup failed; continue without skip")
|
||
return skip
|
||
|
||
|
||
def _strategy_floats() -> tuple[float, float, float, bool]:
|
||
"""min_hours, min_leverage, max_atm_open_offset, atm_open_offset_enabled"""
|
||
s = get_settings()
|
||
try:
|
||
from ..models.db import get_db
|
||
|
||
db = get_db()
|
||
hours = float(
|
||
db.get_setting("min_option_hours", str(s.min_option_hours))
|
||
or s.min_option_hours
|
||
)
|
||
lev = float(
|
||
db.get_setting("min_option_leverage", str(s.min_option_leverage))
|
||
or s.min_option_leverage
|
||
)
|
||
atm_off = float(
|
||
db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset))
|
||
or s.max_atm_open_offset
|
||
)
|
||
atm_on = _as_bool_setting(
|
||
db.get_setting("atm_open_offset_enabled", str(s.atm_open_offset_enabled)),
|
||
s.atm_open_offset_enabled,
|
||
)
|
||
return hours, lev, atm_off, atm_on
|
||
except Exception:
|
||
return (
|
||
s.min_option_hours,
|
||
s.min_option_leverage,
|
||
s.max_atm_open_offset,
|
||
s.atm_open_offset_enabled,
|
||
)
|
||
|
||
|
||
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"
|
||
|
||
|
||
def _hedge_mode() -> str:
|
||
s = get_settings()
|
||
try:
|
||
from ..models.db import get_db
|
||
|
||
raw = str(
|
||
get_db().get_setting("hedge_mode", s.hedge_mode) or s.hedge_mode
|
||
).strip().lower()
|
||
if raw in ("perp_option", "option_option"):
|
||
return raw
|
||
except Exception:
|
||
pass
|
||
return "perp_option"
|
||
|
||
|
||
def _oo_settings() -> tuple[float, float, float, float, float, bool]:
|
||
"""amplitude_pct, amplitude_hours, min_option_hours, min_leverage, strike_max_dev_pct, amp_filter_on"""
|
||
s = get_settings()
|
||
try:
|
||
from ..models.db import get_db
|
||
|
||
db = get_db()
|
||
filt_raw = db.get_setting(
|
||
"oo_amplitude_filter_enabled", str(s.oo_amplitude_filter_enabled)
|
||
)
|
||
filt_on = str(filt_raw or "").strip().lower() in (
|
||
"1",
|
||
"true",
|
||
"yes",
|
||
"on",
|
||
)
|
||
if filt_raw is None or filt_raw == "":
|
||
filt_on = bool(s.oo_amplitude_filter_enabled)
|
||
return (
|
||
float(db.get_setting("oo_amplitude_pct", str(s.oo_amplitude_pct)) or s.oo_amplitude_pct),
|
||
float(
|
||
db.get_setting("oo_amplitude_hours", str(s.oo_amplitude_hours))
|
||
or s.oo_amplitude_hours
|
||
),
|
||
float(
|
||
db.get_setting("oo_min_option_hours", str(s.oo_min_option_hours))
|
||
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
|
||
),
|
||
filt_on,
|
||
)
|
||
except Exception:
|
||
return (
|
||
s.oo_amplitude_pct,
|
||
s.oo_amplitude_hours,
|
||
s.oo_min_option_hours,
|
||
s.oo_min_leverage,
|
||
s.oo_strike_max_dev_pct,
|
||
bool(s.oo_amplitude_filter_enabled),
|
||
)
|
||
|
||
|
||
@dataclass(slots=True)
|
||
class OpenPick:
|
||
pair: OptionPair
|
||
option_side: str
|
||
perp_side: str
|
||
bias: str
|
||
call_ask: float
|
||
put_ask: float
|
||
option_ask: float
|
||
option_leverage: float
|
||
hours_left: float
|
||
underlying_px: float
|
||
hedge_mode: str = "perp_option"
|
||
call_inst_id: str | None = None
|
||
put_inst_id: str | None = None
|
||
call_strike: float | None = None
|
||
put_strike: float | None = None
|
||
call_leverage: float | None = None
|
||
put_leverage: float | None = None
|
||
amplitude_high: float | None = None
|
||
amplitude_low: float | None = None
|
||
amplitude_range_pct: float | None = None
|
||
oo_detail: str | None = None
|
||
|
||
|
||
class StrategySession:
|
||
"""策略侧会话;交易所实现由 exchange 模块注入。"""
|
||
|
||
def __init__(
|
||
self,
|
||
settings: Settings | None = None,
|
||
exchange: ExchangeMarket | None = None,
|
||
) -> None:
|
||
self.settings = settings or get_settings()
|
||
self.ex = exchange or get_exchange()
|
||
self._pair: OptionPair | None = None
|
||
self._oo_amp: dict[str, Any] | None = None
|
||
self._last_pick_fail: str | None = None
|
||
self._refresh_task: asyncio.Task[None] | None = None
|
||
self._started = False
|
||
|
||
@property
|
||
def pair(self) -> OptionPair | None:
|
||
return self._pair
|
||
|
||
def _watch_ids(self, pair: OptionPair | None = None) -> list[str]:
|
||
"""永续 + 监控对 + 持仓腿(有仓时绝不能 drop 持仓盘口)。"""
|
||
s = self.settings
|
||
p = pair if pair is not None else self._pair
|
||
ids: list[str] = [s.perp_inst_id]
|
||
if p is not None:
|
||
ids.extend([p.call_inst_id, p.put_inst_id])
|
||
held, held2 = _held_option_legs()
|
||
if held:
|
||
ids.append(held)
|
||
if held2:
|
||
ids.append(held2)
|
||
# 去重保序
|
||
out: list[str] = []
|
||
seen: set[str] = set()
|
||
for i in ids:
|
||
if i and i not in seen:
|
||
seen.add(i)
|
||
out.append(i)
|
||
return out
|
||
|
||
async def start(self) -> None:
|
||
if self._started:
|
||
return
|
||
self._started = True
|
||
await self.ex.start()
|
||
try:
|
||
# 有持仓时必须钉在持仓行权价,禁止重启后漂到新 ATM
|
||
if _has_open_position():
|
||
await asyncio.to_thread(self.align_to_held_position)
|
||
else:
|
||
await asyncio.to_thread(self.align_instruments)
|
||
except Exception as e:
|
||
# eapi 418/429 时允许先起会话,后续 refresh 再对齐
|
||
logger.warning("initial ATM align failed (will retry): %s", e)
|
||
await self.ex.resubscribe(self._watch_ids())
|
||
self._refresh_task = asyncio.create_task(self._refresh_loop(), name="strategy-align")
|
||
|
||
async def stop(self) -> None:
|
||
self._started = False
|
||
if self._refresh_task:
|
||
self._refresh_task.cancel()
|
||
try:
|
||
await self._refresh_task
|
||
except asyncio.CancelledError:
|
||
pass
|
||
self._refresh_task = None
|
||
await self.ex.stop()
|
||
|
||
def _apply_pair(self, pair: OptionPair, *, mark: float, idx: float | None) -> OptionPair:
|
||
s = self.settings
|
||
self._pair = pair
|
||
self.ex.set_pair(pair)
|
||
if idx is not None:
|
||
self.ex.set_index_px(idx)
|
||
self.ex.warm_and_subscribe(self._watch_ids(pair))
|
||
logger.info(
|
||
"aligned pair exchange=%s expiry=%s strike=%s mark=%.2f hours=%.1f",
|
||
getattr(self.ex, "name", "?"),
|
||
pair.expiry_ymd,
|
||
pair.strike,
|
||
mark,
|
||
hours_until_expiry(pair.expiry_ymd, expiry_ms=pair.expiry_ms),
|
||
)
|
||
return pair
|
||
|
||
def align_to_held_position(self) -> OptionPair | None:
|
||
"""有活跃仓时:监控对锁定为持仓合约的到期/行权价。"""
|
||
self.refresh_oo_amplitude()
|
||
call_id, put_id = _held_option_legs()
|
||
held = call_id or _held_option_inst_id()
|
||
if not held:
|
||
return None
|
||
mark = self._mark_for_atm()
|
||
idx = None
|
||
try:
|
||
idx = self.ex.fetch_index(self.settings.index_inst_id)
|
||
except Exception:
|
||
pass
|
||
if put_id and call_id:
|
||
# 期期:双腿分别钉住
|
||
try:
|
||
from ..models.db import get_db
|
||
|
||
row = get_db().fetchone(
|
||
"SELECT strike2 FROM positions WHERE id=1"
|
||
)
|
||
except Exception:
|
||
row = None
|
||
cpair = pair_from_option_inst(call_id)
|
||
ppair = pair_from_option_inst(put_id)
|
||
if cpair is None:
|
||
logger.warning("cannot rebuild call pair from held %s", call_id)
|
||
return None
|
||
put_strike = None
|
||
if row and row["strike2"] is not None:
|
||
put_strike = float(row["strike2"])
|
||
elif ppair is not None:
|
||
put_strike = float(ppair.strike)
|
||
pair = OptionPair(
|
||
expiry_ymd=cpair.expiry_ymd,
|
||
expiry_ms=cpair.expiry_ms,
|
||
strike=float(cpair.strike),
|
||
call_inst_id=call_id,
|
||
put_inst_id=put_id,
|
||
put_strike=put_strike,
|
||
)
|
||
logger.info(
|
||
"pin OO watch call=%s put=%s C@%.0f P@%.0f",
|
||
call_id,
|
||
put_id,
|
||
pair.strike,
|
||
float(put_strike or pair.strike),
|
||
)
|
||
return self._apply_pair(
|
||
pair, mark=float(mark or pair.strike), idx=idx
|
||
)
|
||
pair = pair_from_option_inst(held)
|
||
if pair is None:
|
||
logger.warning("cannot rebuild pair from held option %s", held)
|
||
return None
|
||
mark = mark or float(pair.strike)
|
||
logger.info(
|
||
"pin watch to held option %s strike=%.0f expiry=%s",
|
||
held,
|
||
pair.strike,
|
||
pair.expiry_ymd,
|
||
)
|
||
return self._apply_pair(pair, mark=float(mark), idx=idx)
|
||
|
||
def align_instruments(self) -> OptionPair | None:
|
||
# 重启/刷新时若仍有仓,绝不切到新 ATM
|
||
if _has_open_position():
|
||
return self.align_to_held_position()
|
||
if _hedge_mode() == "option_option":
|
||
return self.align_oo_instruments()
|
||
# 永期:刷新振幅供 Plan(过滤关也展示;开仓门禁在 pick)
|
||
self.refresh_oo_amplitude()
|
||
s = self.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:
|
||
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)
|
||
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours)
|
||
skip = _skip_expiry_ymds_for_next()
|
||
pair = None
|
||
for ymd in eligible:
|
||
if ymd in skip:
|
||
logger.info(
|
||
"align skip expiry=%s (used today and/or residual pending)",
|
||
ymd,
|
||
)
|
||
continue
|
||
pair = select_option_pair(
|
||
contracts,
|
||
mark_px=float(mark),
|
||
expiry_ymd=ymd,
|
||
option_side=opt_side,
|
||
)
|
||
if pair is not None:
|
||
break
|
||
if pair is None and eligible:
|
||
# 候选都被跳过时仍回退最近合格档,避免盘口空白
|
||
pair = select_option_pair(
|
||
contracts,
|
||
mark_px=float(mark),
|
||
expiry_ymd=eligible[0],
|
||
option_side=opt_side,
|
||
)
|
||
if pair is None:
|
||
kind = f"实值/平值 {opt_side}" if opt_side else "ATM"
|
||
raise RuntimeError(
|
||
f"未找到剩余≥{min_hours}h 的 {kind} Call/Put (family={s.option_inst_family})"
|
||
)
|
||
return self._apply_pair(pair, mark=float(mark), idx=idx)
|
||
|
||
def refresh_oo_amplitude(self) -> dict[str, Any] | None:
|
||
"""刷新振幅高低(永期/期期;有仓/无仓都要,否则 UI 指数/振幅会空)。"""
|
||
try:
|
||
from ..exchange.candles import fetch_amplitude_hl_for_runtime
|
||
from .amplitude_gate import evaluate_amplitude_gate
|
||
|
||
amp_pct, amp_hours, _, _, _, amp_filt = _oo_settings()
|
||
amp = fetch_amplitude_hl_for_runtime(amp_hours)
|
||
gate = evaluate_amplitude_gate(
|
||
filter_enabled=bool(amp_filt),
|
||
amp=amp,
|
||
max_pct=float(amp_pct),
|
||
hours=float(amp_hours),
|
||
)
|
||
self._oo_amp = dict(gate["snapshot"])
|
||
return self._oo_amp
|
||
except Exception:
|
||
logger.exception("refresh_oo_amplitude failed")
|
||
return self._oo_amp
|
||
|
||
def _apply_amplitude_first_gate(self) -> bool:
|
||
"""
|
||
振幅过滤为开仓第一关。返回 True=可继续选约;False=拒开。
|
||
无论是否开启过滤都写入 _oo_amp 供 Plan 展示。
|
||
"""
|
||
from ..exchange.candles import fetch_amplitude_hl_for_runtime
|
||
from .amplitude_gate import evaluate_amplitude_gate
|
||
|
||
amp_pct, amp_hours, _, _, _, amp_filt = _oo_settings()
|
||
amp = fetch_amplitude_hl_for_runtime(amp_hours)
|
||
gate = evaluate_amplitude_gate(
|
||
filter_enabled=bool(amp_filt),
|
||
amp=amp,
|
||
max_pct=float(amp_pct),
|
||
hours=float(amp_hours),
|
||
)
|
||
self._oo_amp = dict(gate["snapshot"])
|
||
if gate["blocked"]:
|
||
logger.info("amplitude gate blocked: %s", gate.get("reason"))
|
||
return False
|
||
return True
|
||
|
||
def last_pick_fail_reason(self) -> str | None:
|
||
return self._last_pick_fail
|
||
|
||
def amplitude_gate_fail_reason(self) -> str | None:
|
||
"""若最近一次振幅快照显示过滤开启且未过关,返回文案。"""
|
||
amp = self._oo_amp
|
||
if not amp or not amp.get("filter_enabled"):
|
||
return None
|
||
if amp.get("ok") is True:
|
||
return None
|
||
hours = amp.get("hours")
|
||
range_pct = amp.get("range_pct")
|
||
max_pct = amp.get("max_pct")
|
||
if range_pct is None:
|
||
return f"振幅未过关:无法获取近 {hours:g}h K 线高低" if hours is not None else "振幅未过关"
|
||
try:
|
||
return (
|
||
f"振幅未过关:{float(hours):g}h 内 "
|
||
f"{float(range_pct):.2f}% > {float(max_pct):g}%"
|
||
)
|
||
except (TypeError, ValueError):
|
||
return "振幅未过关"
|
||
|
||
def align_oo_instruments(self) -> OptionPair | None:
|
||
"""期期监控:按振幅高低点选虚值 Call/Put(展示用;振幅超限仍对齐候选)。"""
|
||
from .oo_selection import select_oo_pair
|
||
|
||
# 有仓也刷新振幅(钉仓不再走选约,否则 _oo_amp 一直空)
|
||
self.refresh_oo_amplitude()
|
||
if _has_open_position():
|
||
return self.align_to_held_position()
|
||
s = self.settings
|
||
amp_pct, amp_hours, min_hours, _min_lev, max_dev, _amp_filt = _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:
|
||
raise RuntimeError("无法获取标的标记/指数价格,无法选期期虚值")
|
||
underlying = float(mark)
|
||
if self._oo_amp is None:
|
||
raise RuntimeError("无法获取振幅 K 线高低点")
|
||
amp_high = float(self._oo_amp["high"])
|
||
amp_low = float(self._oo_amp["low"])
|
||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||
skip = _skip_expiry_ymds_for_next()
|
||
picked = select_oo_pair(
|
||
contracts,
|
||
spot=underlying,
|
||
high=amp_high,
|
||
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 且贴高低≤{max_dev:g}% 的虚值 Call/Put"
|
||
)
|
||
ymd, ems, ck, pk, call_inst, put_inst = picked
|
||
pair = OptionPair(
|
||
expiry_ymd=ymd,
|
||
expiry_ms=int(ems),
|
||
strike=float(ck),
|
||
call_inst_id=call_inst,
|
||
put_inst_id=put_inst,
|
||
put_strike=float(pk),
|
||
)
|
||
return self._apply_pair(pair, mark=underlying, idx=idx)
|
||
|
||
def pick_for_open(self) -> OpenPick | None:
|
||
if _hedge_mode() == "option_option":
|
||
return self._pick_for_open_oo()
|
||
return self._pick_for_open_perp()
|
||
|
||
def _pick_for_open_oo(self) -> OpenPick | None:
|
||
from ..exchange.candles import fetch_amplitude_hl_for_runtime
|
||
from .oo_selection import (
|
||
pick_otm_call_strike,
|
||
pick_otm_put_strike,
|
||
select_oo_pair,
|
||
)
|
||
from .selection import _complete_by_expiry, option_leverage
|
||
|
||
s = self.settings
|
||
amp_pct, amp_hours, min_hours, min_lev, max_dev, amp_filt = _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:
|
||
return None
|
||
underlying = float(mark)
|
||
amp = fetch_amplitude_hl_for_runtime(amp_hours)
|
||
if amp is None:
|
||
logger.info("oo: amplitude candles unavailable")
|
||
return None
|
||
# 无论是否超限都写入,供「指数/振幅」面板展示
|
||
self._oo_amp = {
|
||
"high": float(amp.high),
|
||
"low": float(amp.low),
|
||
"mid": float(amp.mid),
|
||
"range_pct": float(amp.range_pct),
|
||
"hours": float(amp_hours),
|
||
"max_pct": float(amp_pct),
|
||
"filter_enabled": bool(amp_filt),
|
||
"ok": (
|
||
True
|
||
if not amp_filt
|
||
else float(amp.range_pct) <= float(amp_pct) + 1e-12
|
||
),
|
||
}
|
||
if amp_filt and float(amp.range_pct) > float(amp_pct) + 1e-12:
|
||
logger.info(
|
||
"oo: amplitude %.3f%% > max %.3f%% (H=%.2f L=%.2f)",
|
||
amp.range_pct,
|
||
amp_pct,
|
||
amp.high,
|
||
amp.low,
|
||
)
|
||
return None
|
||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||
skip = _skip_expiry_ymds_for_next()
|
||
picked = select_oo_pair(
|
||
contracts,
|
||
spot=underlying,
|
||
high=float(amp.high),
|
||
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 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)
|
||
put_bids, put_asks, _ = self.ex.fetch_book(put_inst, depth=5)
|
||
call_ask = call_asks[0].px if call_asks else None
|
||
put_ask = put_asks[0].px if put_asks else None
|
||
if call_ask is None:
|
||
cq = self.ex.quote(call_inst)
|
||
call_ask = cq.ask if cq else None
|
||
if put_ask is None:
|
||
pq = self.ex.quote(put_inst)
|
||
put_ask = pq.ask if pq else None
|
||
if call_ask is None or put_ask is None or call_ask <= 0 or put_ask <= 0:
|
||
logger.info("oo: missing ask call=%s put=%s", call_ask, put_ask)
|
||
return None
|
||
c_lev = option_leverage(underlying, float(call_ask))
|
||
p_lev = option_leverage(underlying, float(put_ask))
|
||
if (
|
||
c_lev is None
|
||
or p_lev is None
|
||
or c_lev + 1e-9 < min_lev
|
||
or p_lev + 1e-9 < min_lev
|
||
):
|
||
logger.info(
|
||
"oo: leverage too low call=%s put=%s need>=%.0f",
|
||
f"{c_lev:.1f}" if c_lev else "n/a",
|
||
f"{p_lev:.1f}" if p_lev else "n/a",
|
||
min_lev,
|
||
)
|
||
return None
|
||
# 监控用:Call/Put 不同行权价
|
||
pair = OptionPair(
|
||
expiry_ymd=ymd,
|
||
expiry_ms=int(ems),
|
||
strike=float(ck),
|
||
call_inst_id=call_inst,
|
||
put_inst_id=put_inst,
|
||
put_strike=float(pk),
|
||
)
|
||
self._oo_amp = {
|
||
"high": float(amp.high),
|
||
"low": float(amp.low),
|
||
"mid": float(amp.mid),
|
||
"range_pct": float(amp.range_pct),
|
||
"hours": float(amp_hours),
|
||
"max_pct": float(amp_pct),
|
||
"ok": True,
|
||
}
|
||
self._apply_pair(pair, mark=underlying, idx=idx)
|
||
if hasattr(self.ex, "cache"):
|
||
from ..exchange.book_cache import BookCache
|
||
|
||
cache: BookCache = self.ex.cache # type: ignore[attr-defined]
|
||
cache.upsert_book(call_inst, bids=call_bids, asks=call_asks)
|
||
cache.upsert_book(put_inst, bids=put_bids, asks=put_asks)
|
||
hours_left = hours_until_expiry(ymd, expiry_ms=ems)
|
||
return OpenPick(
|
||
pair=pair,
|
||
option_side="call",
|
||
perp_side="",
|
||
bias="option_option",
|
||
call_ask=float(call_ask),
|
||
put_ask=float(put_ask),
|
||
option_ask=float(call_ask),
|
||
option_leverage=float(min(c_lev, p_lev)),
|
||
hours_left=hours_left,
|
||
underlying_px=underlying,
|
||
hedge_mode="option_option",
|
||
call_inst_id=call_inst,
|
||
put_inst_id=put_inst,
|
||
call_strike=float(ck),
|
||
put_strike=float(pk),
|
||
call_leverage=float(c_lev),
|
||
put_leverage=float(p_lev),
|
||
amplitude_high=float(amp.high),
|
||
amplitude_low=float(amp.low),
|
||
amplitude_range_pct=float(amp.range_pct),
|
||
oo_detail=(
|
||
f"amp={amp.range_pct:.2f}% H={amp.high:.2f} L={amp.low:.2f} "
|
||
f"C@{ck:g} P@{pk:g}"
|
||
),
|
||
)
|
||
|
||
def _pick_for_open_perp(self) -> OpenPick | None:
|
||
from .signal import decide, decide_fixed
|
||
from .semi_auto import is_armed, is_semi_auto, read_semi_params
|
||
|
||
self._last_pick_fail = None
|
||
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():
|
||
self._last_pick_fail = "半自动未授权"
|
||
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
|
||
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():
|
||
self._last_pick_fail = "振幅门未过"
|
||
return 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:
|
||
self._last_pick_fail = "无标的价"
|
||
return None
|
||
underlying = float(mark)
|
||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours)
|
||
if not eligible:
|
||
logger.info("no expiry with hours>=%.1f", min_hours)
|
||
self._last_pick_fail = f"无剩余≥{min_hours:g}h 的到期"
|
||
return None
|
||
|
||
skip_expiries = _skip_expiry_ymds_for_next()
|
||
last_skip = ""
|
||
|
||
for ymd in eligible:
|
||
if ymd in skip_expiries:
|
||
logger.info(
|
||
"skip expiry=%s: used today and/or residual pending",
|
||
ymd,
|
||
)
|
||
continue
|
||
pair = select_option_pair(
|
||
contracts,
|
||
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":
|
||
last_skip = (
|
||
f"{ymd} 无{opt_side_hint or '?'}虚值"
|
||
f"(偏离≤{float(semi_otm_off or 0):g})"
|
||
)
|
||
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,
|
||
)
|
||
else:
|
||
last_skip = f"{ymd} 无合格行权价"
|
||
continue
|
||
if fixed_on:
|
||
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,
|
||
):
|
||
last_skip = f"{ymd} K{pair.strike:g} 非虚值"
|
||
continue
|
||
if (
|
||
atm_open_offset(pair.strike, underlying)
|
||
> float(semi_otm_off or 0) + 1e-9
|
||
):
|
||
last_skip = (
|
||
f"{ymd} K{pair.strike:g} 偏离>"
|
||
f"{float(semi_otm_off or 0):g}"
|
||
)
|
||
continue
|
||
elif semi_on and semi_mny == "atm":
|
||
# 平值:须为该到期最接近标的的档
|
||
pass
|
||
elif not is_itm_or_atm(
|
||
option_side=side,
|
||
strike=pair.strike,
|
||
mark_px=underlying,
|
||
):
|
||
last_skip = f"{ymd} K{pair.strike:g} 非实值/平值"
|
||
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,
|
||
underlying,
|
||
max_offset=max_atm_off,
|
||
enabled=atm_off_on,
|
||
):
|
||
last_skip = f"{ymd} ATM偏离{offset:.1f}>{max_atm_off:g}"
|
||
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
|
||
put_ask = put_asks[0].px if put_asks else None
|
||
# REST 被限流时回退 WS/缓存盘口
|
||
if call_ask is None:
|
||
cq = self.ex.quote(pair.call_inst_id)
|
||
call_ask = cq.ask if cq else None
|
||
if put_ask is None:
|
||
pq = self.ex.quote(pair.put_inst_id)
|
||
put_ask = pq.ask if pq else None
|
||
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:
|
||
need = "Call" if (opt_side_hint == "call") else (
|
||
"Put" if opt_side_hint == "put" else "Call/Put"
|
||
)
|
||
last_skip = f"{ymd} K{pair.strike:g} 缺{need}卖一"
|
||
continue
|
||
opt_ask = sig.call_ask if sig.option_side == "call" else sig.put_ask
|
||
lev = option_leverage(underlying, opt_ask)
|
||
hours_left = hours_until_expiry(ymd, expiry_ms=pair.expiry_ms)
|
||
if lev is None or lev + 1e-9 < min_lev:
|
||
last_skip = (
|
||
f"{ymd} {sig.option_side.upper()}@{pair.strike:g} "
|
||
f"杠杆{(f'{lev:.0f}x' if lev else 'n/a')}<{min_lev:g}x"
|
||
)
|
||
logger.info(
|
||
"skip expiry=%s strike=%.0f side=%s lev=%s need>=%.0f hours=%.1f",
|
||
ymd,
|
||
pair.strike,
|
||
sig.option_side,
|
||
f"{lev:.1f}" if lev else "n/a",
|
||
min_lev,
|
||
hours_left,
|
||
)
|
||
continue
|
||
self._apply_pair(pair, mark=underlying, idx=idx)
|
||
# warm_and_subscribe 已写盘口;再覆盖刚拉的 ask 侧
|
||
from ..exchange.book_cache import BookCache
|
||
|
||
# 直接通过 exchange quote path:再 upsert
|
||
if hasattr(self.ex, "cache"):
|
||
cache: BookCache = self.ex.cache # type: ignore[attr-defined]
|
||
cache.upsert_book(pair.call_inst_id, bids=call_bids, asks=call_asks)
|
||
cache.upsert_book(pair.put_inst_id, bids=put_bids, asks=put_asks)
|
||
self._last_pick_fail = None
|
||
return OpenPick(
|
||
pair=pair,
|
||
option_side=sig.option_side,
|
||
perp_side=sig.perp_side,
|
||
bias=sig.bias,
|
||
call_ask=float(sig.call_ask),
|
||
put_ask=float(sig.put_ask),
|
||
option_ask=float(opt_ask),
|
||
option_leverage=float(lev),
|
||
hours_left=hours_left,
|
||
underlying_px=underlying,
|
||
hedge_mode="perp_option",
|
||
)
|
||
if last_skip:
|
||
hint = ""
|
||
if semi_on:
|
||
hint = (
|
||
f"(半自动{opt_side_hint or '?'}·"
|
||
f"{semi_mny or '?'}·≥{min_lev:g}x·≥{min_hours:g}h)"
|
||
)
|
||
self._last_pick_fail = f"最近跳过: {last_skip}{hint}"
|
||
else:
|
||
self._last_pick_fail = "合格到期均被跳过(一日一到期/残余等)"
|
||
return None
|
||
|
||
async def realign_async(self) -> OptionPair | None:
|
||
old = self._pair
|
||
pair = await asyncio.to_thread(self.align_instruments)
|
||
if old is None or (
|
||
pair
|
||
and (
|
||
pair.call_inst_id != old.call_inst_id
|
||
or pair.put_inst_id != old.put_inst_id
|
||
)
|
||
):
|
||
await self.ex.resubscribe(self._watch_ids(pair))
|
||
return pair
|
||
|
||
async def pick_for_open_async(self) -> OpenPick | None:
|
||
old = self._pair
|
||
pick = await asyncio.to_thread(self.pick_for_open)
|
||
if pick and (
|
||
old is None
|
||
or pick.pair.call_inst_id != old.call_inst_id
|
||
or pick.pair.put_inst_id != old.put_inst_id
|
||
):
|
||
await self.ex.resubscribe(self._watch_ids(pick.pair))
|
||
return pick
|
||
|
||
def _mark_for_atm(self) -> float | None:
|
||
snap = self.snapshot()
|
||
if snap.perp and snap.perp.mark_px:
|
||
return float(snap.perp.mark_px)
|
||
if snap.index_px:
|
||
return float(snap.index_px)
|
||
if snap.perp and snap.perp.bid and snap.perp.ask:
|
||
return (float(snap.perp.bid) + float(snap.perp.ask)) / 2
|
||
return None
|
||
|
||
def atm_needs_realign(self, mark_px: float | None = None) -> bool:
|
||
if _hedge_mode() == "option_option":
|
||
return self.oo_needs_realign()
|
||
if self._pair is None:
|
||
return True
|
||
min_hours, _, _, _ = _strategy_floats()
|
||
if (
|
||
hours_until_expiry(self._pair.expiry_ymd, expiry_ms=self._pair.expiry_ms)
|
||
+ 1e-9
|
||
< min_hours
|
||
):
|
||
return True
|
||
skip = _skip_expiry_ymds_for_next()
|
||
if str(self._pair.expiry_ymd or "") in skip:
|
||
return True
|
||
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()
|
||
# 半自动虚值/平值:监控对齐勿按「必须实值」强行重钉,否则 OTM 会一直 realign
|
||
try:
|
||
from .semi_auto import is_semi_auto, read_semi_params
|
||
from .selection import is_otm
|
||
|
||
if is_semi_auto():
|
||
sp = read_semi_params()
|
||
mny = str(sp.get("moneyness") or "otm")
|
||
opt = _option_side_for_perp(str(sp["perp_side"]))
|
||
if mny == "otm":
|
||
off = float(sp.get("otm_max_offset") or 0)
|
||
if not is_otm(
|
||
option_side=opt,
|
||
strike=float(self._pair.strike),
|
||
mark_px=float(mark),
|
||
):
|
||
return True
|
||
if atm_open_offset(self._pair.strike, mark) > off + 1e-9:
|
||
return True
|
||
return False
|
||
if mny == "atm":
|
||
return (
|
||
abs(float(self._pair.strike) - float(mark))
|
||
>= _ATM_DRIFT_POINTS
|
||
)
|
||
except Exception:
|
||
logger.debug("semi atm_needs_realign check failed", exc_info=True)
|
||
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
|
||
|
||
def oo_needs_realign(self) -> bool:
|
||
if self._pair is None:
|
||
return True
|
||
_amp_pct, amp_hours, min_hours, _, max_dev, amp_filt = _oo_settings()
|
||
if (
|
||
hours_until_expiry(self._pair.expiry_ymd, expiry_ms=self._pair.expiry_ms)
|
||
+ 1e-9
|
||
< min_hours
|
||
):
|
||
return True
|
||
skip = _skip_expiry_ymds_for_next()
|
||
if str(self._pair.expiry_ymd or "") in skip:
|
||
return True
|
||
try:
|
||
from ..exchange.candles import fetch_amplitude_hl_for_runtime
|
||
from .oo_selection import select_oo_pair
|
||
|
||
mark = self._mark_for_atm()
|
||
if mark is None or mark <= 0:
|
||
return False
|
||
amp = fetch_amplitude_hl_for_runtime(amp_hours)
|
||
if amp is None:
|
||
return False
|
||
self._oo_amp = {
|
||
"high": float(amp.high),
|
||
"low": float(amp.low),
|
||
"mid": float(amp.mid),
|
||
"range_pct": float(amp.range_pct),
|
||
"hours": float(amp_hours),
|
||
"max_pct": float(_amp_pct),
|
||
"filter_enabled": bool(amp_filt),
|
||
"ok": (
|
||
True
|
||
if not amp_filt
|
||
else float(amp.range_pct) <= float(_amp_pct) + 1e-12
|
||
),
|
||
}
|
||
contracts = self.ex.list_option_contracts(self.settings.option_inst_family)
|
||
picked = select_oo_pair(
|
||
contracts,
|
||
spot=float(mark),
|
||
high=float(amp.high),
|
||
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
|
||
_ymd, _ems, _ck, _pk, call_inst, put_inst = picked
|
||
return (
|
||
call_inst != self._pair.call_inst_id
|
||
or put_inst != self._pair.put_inst_id
|
||
)
|
||
except Exception:
|
||
logger.exception("oo_needs_realign failed")
|
||
return False
|
||
|
||
async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None:
|
||
if _has_open_position():
|
||
# 持仓期间:钉住持仓行权价(禁止漂到新 ATM/虚值);仍刷新振幅供 UI
|
||
await asyncio.to_thread(self.refresh_oo_amplitude)
|
||
call_id, put_id = _held_option_legs()
|
||
held = call_id or _held_option_inst_id()
|
||
if held and (
|
||
self._pair is None
|
||
or held not in (self._pair.call_inst_id, self._pair.put_inst_id)
|
||
or (
|
||
put_id
|
||
and put_id
|
||
not in (self._pair.call_inst_id, self._pair.put_inst_id)
|
||
)
|
||
):
|
||
return await asyncio.to_thread(self.align_to_held_position)
|
||
return self._pair
|
||
if force or self.atm_needs_realign():
|
||
logger.info(
|
||
"%s realign force=%s old_strike=%s old_exp=%s",
|
||
"OO" if _hedge_mode() == "option_option" else "ATM",
|
||
force,
|
||
self._pair.strike if self._pair else None,
|
||
self._pair.expiry_ymd if self._pair else None,
|
||
)
|
||
return await self.realign_async()
|
||
return self._pair
|
||
|
||
def snapshot(self) -> MarketSnapshot:
|
||
return self.ex.snapshot(self.settings.perp_inst_id)
|
||
|
||
def snapshot_dict(self) -> dict[str, Any]:
|
||
d = self.ex.snapshot_dict(self.settings.perp_inst_id)
|
||
d["exchange"] = getattr(self.ex, "name", self.settings.exchange)
|
||
d["perp_inst_id"] = self.settings.perp_inst_id
|
||
hm = _hedge_mode()
|
||
d["hedge_mode"] = hm
|
||
if self._pair is not None:
|
||
pd = self._pair.to_dict()
|
||
d["pair"] = pd
|
||
if self._oo_amp is not None:
|
||
d["oo_amplitude"] = dict(self._oo_amp)
|
||
if hm == "option_option":
|
||
ac = dict(d.get("ask_compare") or {})
|
||
ac["bias"] = "option_option"
|
||
d["ask_compare"] = ac
|
||
return d
|
||
|
||
def option_ladder(
|
||
self,
|
||
*,
|
||
wings: int = 5,
|
||
side: str = "call",
|
||
min_hours: float = 30.0,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
半自动单边报价:ATM 上下若干档。
|
||
到期:剩余时长 ≥ min_hours 的最近一档(与选约门槛一致)。
|
||
"""
|
||
s = self.settings
|
||
wings = max(1, min(12, int(wings)))
|
||
min_h = max(1.0, float(min_hours))
|
||
opt_side = "put" if str(side).strip().lower() == "put" else "call"
|
||
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 float(mark) <= 0:
|
||
return {
|
||
"ok": False,
|
||
"detail": "无标的价",
|
||
"rows": [],
|
||
"index_px": None,
|
||
"side": opt_side,
|
||
"min_hours": min_h,
|
||
}
|
||
underlying = float(mark)
|
||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||
from .selection import _complete_by_expiry, pick_atm_strike
|
||
|
||
# 与半自动选约一致:只考虑剩余 ≥ min_hours 的到期,取最近一档
|
||
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_h)
|
||
ymd = eligible[0] if eligible else None
|
||
if not ymd:
|
||
return {
|
||
"ok": False,
|
||
"detail": f"无剩余≥{min_h:g}h 的到期",
|
||
"rows": [],
|
||
"index_px": underlying,
|
||
"side": opt_side,
|
||
"min_hours": min_h,
|
||
}
|
||
complete = _complete_by_expiry(contracts)
|
||
if ymd not in complete:
|
||
return {
|
||
"ok": False,
|
||
"detail": f"到期 {ymd} 无完整对",
|
||
"rows": [],
|
||
"index_px": underlying,
|
||
"expiry_ymd": ymd,
|
||
"side": opt_side,
|
||
"min_hours": min_h,
|
||
}
|
||
_ems, strikes_map = complete[ymd]
|
||
hours_left = hours_until_expiry(ymd, expiry_ms=_ems)
|
||
strikes = sorted(float(k) for k in strikes_map.keys())
|
||
atm = pick_atm_strike(strikes, underlying)
|
||
if atm is None:
|
||
return {
|
||
"ok": False,
|
||
"detail": "无 ATM",
|
||
"rows": [],
|
||
"index_px": underlying,
|
||
"expiry_ymd": ymd,
|
||
"side": opt_side,
|
||
}
|
||
atm_i = min(range(len(strikes)), key=lambda i: abs(strikes[i] - float(atm)))
|
||
lo = max(0, atm_i - wings)
|
||
hi = min(len(strikes), atm_i + wings + 1)
|
||
|
||
def _quote_side(inst_id: str | None) -> tuple[float | None, float | None]:
|
||
if not inst_id:
|
||
return None, None
|
||
q = self.ex.quote(str(inst_id))
|
||
ask = float(q.ask) if q and q.ask is not None else None
|
||
ask_sz = float(q.ask_sz) if q and q.ask_sz is not None else None
|
||
if ask is not None:
|
||
return ask, ask_sz
|
||
try:
|
||
_bids, asks, _ = self.ex.fetch_book(str(inst_id), depth=1)
|
||
if asks:
|
||
return float(asks[0].px), (
|
||
float(asks[0].sz) if asks[0].sz is not None else None
|
||
)
|
||
except Exception:
|
||
logger.debug("ladder fetch_book failed inst=%s", inst_id, exc_info=True)
|
||
return None, None
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
for k in strikes[lo:hi]:
|
||
legs = strikes_map[k]
|
||
inst = legs.get("C" if opt_side == "call" else "P")
|
||
ask, ask_sz = _quote_side(inst)
|
||
off = float(k) - underlying
|
||
if abs(float(k) - float(atm)) < 1e-9:
|
||
tag = "atm"
|
||
elif opt_side == "call":
|
||
tag = "itm" if float(k) < underlying - 1e-9 else "otm"
|
||
else:
|
||
tag = "itm" if float(k) > underlying + 1e-9 else "otm"
|
||
rows.append(
|
||
{
|
||
"strike": float(k),
|
||
"offset": round(off, 2),
|
||
"tag": tag,
|
||
"ask": ask,
|
||
"ask_sz": ask_sz,
|
||
"lev": option_leverage(underlying, ask) if ask else None,
|
||
"inst_id": inst,
|
||
}
|
||
)
|
||
# Call:高行权价在上(虚值在上);Put:低行权价在上(虚值在上)
|
||
if opt_side == "call":
|
||
rows.sort(key=lambda r: -float(r["strike"]))
|
||
else:
|
||
rows.sort(key=lambda r: float(r["strike"]))
|
||
return {
|
||
"ok": True,
|
||
"detail": "",
|
||
"side": opt_side,
|
||
"index_px": underlying,
|
||
"expiry_ymd": ymd,
|
||
"hours_left": round(float(hours_left), 1) if hours_left is not None else None,
|
||
"min_hours": min_h,
|
||
"atm_strike": float(atm),
|
||
"rows": rows,
|
||
}
|
||
|
||
async def _refresh_loop(self) -> None:
|
||
while True:
|
||
await asyncio.sleep(30 if self._pair is not None else 10)
|
||
try:
|
||
idx = await asyncio.to_thread(
|
||
self.ex.fetch_index, self.settings.index_inst_id
|
||
)
|
||
self.ex.set_index_px(idx)
|
||
mark = await asyncio.to_thread(
|
||
self.ex.fetch_mark, self.settings.perp_inst_id
|
||
)
|
||
if mark:
|
||
self.ex.set_mark_px(self.settings.perp_inst_id, mark)
|
||
if self._pair is None:
|
||
await self.ensure_atm_async(force=True)
|
||
else:
|
||
await self.ensure_atm_async(force=False)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as e:
|
||
logger.warning("strategy align refresh failed: %s", e)
|
||
|
||
|
||
def get_session() -> StrategySession:
|
||
global _session
|
||
if _session is None:
|
||
_session = StrategySession()
|
||
return _session
|
||
|
||
|
||
def set_session(s: StrategySession | None) -> None:
|
||
global _session
|
||
_session = s
|
||
|
||
|
||
# 兼容旧名
|
||
MarketGateway = StrategySession
|
||
get_gateway = get_session
|
||
set_gateway = set_session
|
||
|
||
|
||
def bootstrap_session(settings: Settings | None = None) -> StrategySession:
|
||
"""main 启动:创建交易所 + 策略会话。始终以 DB 覆盖后的 runtime 为准。"""
|
||
from ..exchange.runtime import load_runtime_settings
|
||
|
||
# 忽略裸 get_settings():重启后必须跟 DB 里选的交易所一致
|
||
try:
|
||
s = load_runtime_settings()
|
||
except Exception:
|
||
s = settings or get_settings()
|
||
ex = build_exchange(s)
|
||
set_exchange(ex)
|
||
sess = StrategySession(s, ex)
|
||
set_session(sess)
|
||
return sess
|