Skip used/residual expiries so ATM monitors the next day.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -59,6 +59,20 @@ def used_expiry_ymds_for_day(db: Any, now: datetime | None = None) -> set[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pending_residual_expiry_ymds(db: Any) -> set[str]:
|
||||||
|
"""待到期结算的残留期权到期日(YYMMDD);监控/下一组开仓应跳过这些档。"""
|
||||||
|
rows = db.fetchall(
|
||||||
|
"""SELECT DISTINCT expiry_ymd FROM residual_options
|
||||||
|
WHERE status='pending' AND expiry_ymd IS NOT NULL AND TRIM(expiry_ymd) != ''"""
|
||||||
|
)
|
||||||
|
out: set[str] = set()
|
||||||
|
for r in rows or []:
|
||||||
|
y = str(r["expiry_ymd"] or "").strip()
|
||||||
|
if y:
|
||||||
|
out.add(y)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def expiry_blocked_by_one_per_day(
|
def expiry_blocked_by_one_per_day(
|
||||||
expiry_ymd: str | None,
|
expiry_ymd: str | None,
|
||||||
used: set[str],
|
used: set[str],
|
||||||
|
|||||||
@@ -69,6 +69,31 @@ def _as_bool_setting(raw: str | None, default: bool) -> bool:
|
|||||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
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_for_day
|
||||||
|
|
||||||
|
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_for_day(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]:
|
def _strategy_floats() -> tuple[float, float, float, bool]:
|
||||||
"""min_hours, min_leverage, max_atm_open_offset, atm_open_offset_enabled"""
|
"""min_hours, min_leverage, max_atm_open_offset, atm_open_offset_enabled"""
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
@@ -264,12 +289,32 @@ class StrategySession:
|
|||||||
fixed_on, fixed_perp = _fixed_direction()
|
fixed_on, fixed_perp = _fixed_direction()
|
||||||
opt_side = _option_side_for_perp(fixed_perp) if fixed_on else None
|
opt_side = _option_side_for_perp(fixed_perp) if fixed_on else None
|
||||||
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
contracts = self.ex.list_option_contracts(s.option_inst_family)
|
||||||
pair = select_option_pair(
|
eligible = list_eligible_expiry_ymds(contracts, min_hours=min_hours)
|
||||||
contracts,
|
skip = _skip_expiry_ymds_for_next()
|
||||||
mark_px=float(mark),
|
pair = None
|
||||||
min_hours=min_hours,
|
for ymd in eligible:
|
||||||
option_side=opt_side,
|
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:
|
if pair is None:
|
||||||
kind = f"实值/平值 {opt_side}" if opt_side else "ATM"
|
kind = f"实值/平值 {opt_side}" if opt_side else "ATM"
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
@@ -295,26 +340,12 @@ class StrategySession:
|
|||||||
logger.info("no expiry with hours>=%.1f", min_hours)
|
logger.info("no expiry with hours>=%.1f", min_hours)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
used_expiries: set[str] = set()
|
skip_expiries = _skip_expiry_ymds_for_next()
|
||||||
one_exp_day = bool(s.one_expiry_per_day)
|
|
||||||
try:
|
|
||||||
from ..models.db import get_db
|
|
||||||
from .clock import used_expiry_ymds_for_day
|
|
||||||
|
|
||||||
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:
|
|
||||||
used_expiries = used_expiry_ymds_for_day(db)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("one_expiry_per_day lookup failed; continue without skip")
|
|
||||||
|
|
||||||
for ymd in eligible:
|
for ymd in eligible:
|
||||||
if one_exp_day and ymd in used_expiries:
|
if ymd in skip_expiries:
|
||||||
logger.info(
|
logger.info(
|
||||||
"skip expiry=%s: already used today (one_expiry_per_day)",
|
"skip expiry=%s: used today and/or residual pending",
|
||||||
ymd,
|
ymd,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -459,6 +490,9 @@ class StrategySession:
|
|||||||
< min_hours
|
< min_hours
|
||||||
):
|
):
|
||||||
return True
|
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()
|
mark = mark_px if mark_px is not None else self._mark_for_atm()
|
||||||
if mark is None or mark <= 0:
|
if mark is None or mark <= 0:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""空仓选约:跳过当日已用到期与残留待结算到期。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.strategy.clock import (
|
||||||
|
expiry_blocked_by_one_per_day,
|
||||||
|
pending_residual_expiry_ymds,
|
||||||
|
used_expiry_ymds_for_day,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDB:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
groups: list[dict] | None = None,
|
||||||
|
residuals: list[dict] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._groups = groups or []
|
||||||
|
self._residuals = residuals or []
|
||||||
|
|
||||||
|
def fetchall(self, sql: str, params: tuple = ()) -> list[dict]:
|
||||||
|
s = " ".join(sql.split()).lower()
|
||||||
|
if "from groups" in s:
|
||||||
|
like = params[0] if params else ""
|
||||||
|
prefix = like.replace("%", "")
|
||||||
|
return [
|
||||||
|
r
|
||||||
|
for r in self._groups
|
||||||
|
if str(r.get("group_id", "")).startswith(prefix)
|
||||||
|
]
|
||||||
|
if "from residual_options" in s:
|
||||||
|
return [r for r in self._residuals if r.get("status") == "pending"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def test_used_expiry_ymds_for_day() -> None:
|
||||||
|
db = _FakeDB(
|
||||||
|
groups=[
|
||||||
|
{"group_id": "G-20260802-01", "expiry_ymd": "260803"},
|
||||||
|
{"group_id": "G-20260801-01", "expiry_ymd": "260802"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
now = datetime(2026, 8, 2, 12, 0, tzinfo=ZoneInfo("Asia/Shanghai"))
|
||||||
|
assert used_expiry_ymds_for_day(db, now) == {"260803"}
|
||||||
|
assert expiry_blocked_by_one_per_day("260803", {"260803"}, enabled=True)
|
||||||
|
assert not expiry_blocked_by_one_per_day("260804", {"260803"}, enabled=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_residual_expiry_ymds() -> None:
|
||||||
|
db = _FakeDB(
|
||||||
|
residuals=[
|
||||||
|
{"expiry_ymd": "260803", "status": "pending"},
|
||||||
|
{"expiry_ymd": "260802", "status": "settled"},
|
||||||
|
{"expiry_ymd": "", "status": "pending"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert pending_residual_expiry_ymds(db) == {"260803"}
|
||||||
Reference in New Issue
Block a user