Add one-expiry-per-day open rhythm gate (default on).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,6 +35,7 @@ KEYS = (
|
||||
"rest_seconds",
|
||||
"live_order_interval_sec",
|
||||
"skip_weekends",
|
||||
"one_expiry_per_day",
|
||||
"initial_equity",
|
||||
"leverage",
|
||||
"perp_margin_mode",
|
||||
@@ -69,6 +70,7 @@ class StrategySettingsBody(BaseModel):
|
||||
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
||||
live_order_interval_sec: float | None = Field(default=None, ge=0.2, le=30)
|
||||
skip_weekends: bool | None = None
|
||||
one_expiry_per_day: bool | None = None
|
||||
initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000)
|
||||
leverage: float | None = Field(default=None, ge=1, le=125)
|
||||
perp_margin_mode: str | None = Field(default=None, pattern="^(cross|isolated)$")
|
||||
@@ -144,6 +146,10 @@ def _read_settings() -> dict:
|
||||
"skip_weekends": _as_bool(
|
||||
db.get_setting("skip_weekends", str(s.skip_weekends)), s.skip_weekends
|
||||
),
|
||||
"one_expiry_per_day": _as_bool(
|
||||
db.get_setting("one_expiry_per_day", str(s.one_expiry_per_day)),
|
||||
s.one_expiry_per_day,
|
||||
),
|
||||
"initial_equity": float(
|
||||
db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity
|
||||
),
|
||||
|
||||
+18
-1
@@ -59,7 +59,8 @@ async def sim_open_group(
|
||||
if ex.has_open_position():
|
||||
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
|
||||
s = get_settings()
|
||||
skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends)
|
||||
ledger = Ledger()
|
||||
skip_weekends = ledger.get_setting_bool("skip_weekends", s.skip_weekends)
|
||||
if not can_open_new(skip_weekends=skip_weekends):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
@@ -72,6 +73,22 @@ async def sim_open_group(
|
||||
status_code=409,
|
||||
detail="无合格期权:请检查剩余时长、ATM开仓偏差(若已开启)与杠杆(现价/卖一)",
|
||||
)
|
||||
one_expiry = ledger.get_setting_bool("one_expiry_per_day", s.one_expiry_per_day)
|
||||
if one_expiry:
|
||||
from ..strategy.clock import (
|
||||
expiry_blocked_by_one_per_day,
|
||||
used_expiry_ymds_for_day,
|
||||
)
|
||||
|
||||
used = used_expiry_ymds_for_day(get_db())
|
||||
if expiry_blocked_by_one_per_day(pick.pair.expiry_ymd, used, enabled=True):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
f"同到期日一天只开一次:今日已用过 {pick.pair.expiry_ymd},"
|
||||
"请等下一到期日"
|
||||
),
|
||||
)
|
||||
|
||||
force = (body.force_option_side if body else None) or None
|
||||
if force in ("call", "put"):
|
||||
|
||||
@@ -67,6 +67,8 @@ class Settings(BaseSettings):
|
||||
rest_seconds: int = 300
|
||||
live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒)
|
||||
skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平)
|
||||
# 同到期日一天只开一次:当日已开过某 expiry_ymd 后,平仓也不可再开同到期,只能开更远到期
|
||||
one_expiry_per_day: bool = True
|
||||
leverage: float = 3.0 # 永续杠杆
|
||||
# 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cash(OKX 逐仓/现金)
|
||||
perp_margin_mode: str = "cross"
|
||||
|
||||
@@ -232,6 +232,7 @@ class Database:
|
||||
"rest_seconds": str(s.rest_seconds),
|
||||
"live_order_interval_sec": str(s.live_order_interval_sec),
|
||||
"skip_weekends": str(s.skip_weekends),
|
||||
"one_expiry_per_day": str(s.one_expiry_per_day),
|
||||
"max_rounds": str(s.max_rounds),
|
||||
"leverage": str(s.leverage),
|
||||
"perp_margin_mode": str(s.perp_margin_mode),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
@@ -38,3 +39,34 @@ def can_open_new(
|
||||
|
||||
def group_date_ymd(now: datetime | None = None) -> str:
|
||||
return window_key(now)
|
||||
|
||||
|
||||
def used_expiry_ymds_for_day(db: Any, now: datetime | None = None) -> set[str]:
|
||||
"""
|
||||
上海日历日已开过的期权到期日(groups.expiry_ymd,YYMMDD)。
|
||||
按当日组号 G-{YYYYMMDD}-% 统计;含已平仓,用于「同到期一天只开一次」。
|
||||
"""
|
||||
wkey = window_key(now)
|
||||
rows = db.fetchall(
|
||||
"SELECT DISTINCT expiry_ymd FROM groups WHERE group_id LIKE ?",
|
||||
(f"G-{wkey}-%",),
|
||||
)
|
||||
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(
|
||||
expiry_ymd: str | None,
|
||||
used: set[str],
|
||||
*,
|
||||
enabled: bool = True,
|
||||
) -> bool:
|
||||
"""开启时:候选到期已在当日用过则拦截。"""
|
||||
if not enabled:
|
||||
return False
|
||||
y = str(expiry_ymd or "").strip()
|
||||
return bool(y and y in used)
|
||||
|
||||
@@ -82,6 +82,9 @@ class StrategyEngine:
|
||||
)
|
||||
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
|
||||
skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends)
|
||||
one_expiry_per_day = self.ledger.get_setting_bool(
|
||||
"one_expiry_per_day", s.one_expiry_per_day
|
||||
)
|
||||
leverage = self.ledger.get_setting_float("leverage", s.leverage)
|
||||
perp_mm = str(
|
||||
self.ledger.get_setting_str("perp_margin_mode", s.perp_margin_mode)
|
||||
@@ -213,6 +216,7 @@ class StrategyEngine:
|
||||
"rest_left_sec": rest_left,
|
||||
"rest_seconds": rest_sec,
|
||||
"skip_weekends": skip_weekends,
|
||||
"one_expiry_per_day": one_expiry_per_day,
|
||||
"exit_mode": exit_mode,
|
||||
"net_profit_target": net_target,
|
||||
"premium_exit_multiple": prem_mult,
|
||||
@@ -824,6 +828,27 @@ class StrategyEngine:
|
||||
|
||||
self._set_state(phase="wait_signal")
|
||||
pick = await get_session().pick_for_open_async()
|
||||
one_expiry_per_day = self.ledger.get_setting_bool(
|
||||
"one_expiry_per_day", s.one_expiry_per_day
|
||||
)
|
||||
if pick is not None and one_expiry_per_day:
|
||||
from .clock import (
|
||||
expiry_blocked_by_one_per_day,
|
||||
used_expiry_ymds_for_day,
|
||||
)
|
||||
|
||||
used = used_expiry_ymds_for_day(self.db)
|
||||
if expiry_blocked_by_one_per_day(
|
||||
pick.pair.expiry_ymd, used, enabled=True
|
||||
):
|
||||
self._set_state(
|
||||
phase="idle",
|
||||
last_error=(
|
||||
f"同到期日一天只开一次:今日已用过 {pick.pair.expiry_ymd},"
|
||||
"请等下一到期日"
|
||||
),
|
||||
)
|
||||
return
|
||||
if pick is None:
|
||||
try:
|
||||
from .open_capacity import assess_open_capacity, funds_gate_blocks
|
||||
|
||||
@@ -295,7 +295,29 @@ class StrategySession:
|
||||
logger.info("no expiry with hours>=%.1f", min_hours)
|
||||
return None
|
||||
|
||||
used_expiries: set[str] = set()
|
||||
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:
|
||||
if one_exp_day and ymd in used_expiries:
|
||||
logger.info(
|
||||
"skip expiry=%s: already used today (one_expiry_per_day)",
|
||||
ymd,
|
||||
)
|
||||
continue
|
||||
pair = select_option_pair(
|
||||
contracts,
|
||||
mark_px=underlying,
|
||||
|
||||
@@ -3,7 +3,12 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
from app.sim.liquidity import bid_mark_ok
|
||||
from app.sim.pricing import option_fill, perp_fill
|
||||
from app.strategy.clock import can_open_new, window_key
|
||||
from app.strategy.clock import (
|
||||
can_open_new,
|
||||
expiry_blocked_by_one_per_day,
|
||||
used_expiry_ymds_for_day,
|
||||
window_key,
|
||||
)
|
||||
from app.strategy.exits import check_expiry_close, check_exits
|
||||
from app.strategy.signal import decide
|
||||
|
||||
@@ -142,6 +147,30 @@ def test_window_weekend_skip() -> None:
|
||||
assert window_key(fri) == "20260724"
|
||||
|
||||
|
||||
def test_one_expiry_per_day(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("MODE", "SIM")
|
||||
from app.models.db import Database
|
||||
|
||||
db = Database(tmp_path / "one_exp.db")
|
||||
day = datetime(2026, 8, 1, 12, 0, tzinfo=_SH)
|
||||
with db._lock:
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms)
|
||||
VALUES ('G-20260801-01','closed','260802',1)"""
|
||||
)
|
||||
db._conn.execute(
|
||||
"""INSERT INTO groups(group_id, status, expiry_ymd, open_at_ms)
|
||||
VALUES ('G-20260731-01','closed','260801',1)"""
|
||||
)
|
||||
db._conn.commit()
|
||||
used = used_expiry_ymds_for_day(db, day)
|
||||
assert used == {"260802"}
|
||||
assert expiry_blocked_by_one_per_day("260802", used, enabled=True) is True
|
||||
assert expiry_blocked_by_one_per_day("260803", used, enabled=True) is False
|
||||
assert expiry_blocked_by_one_per_day("260802", used, enabled=False) is False
|
||||
db.close()
|
||||
|
||||
|
||||
def test_bid_mark_deviation_30pct() -> None:
|
||||
# |7-10|/10 = 30% → 允许(≤30%)
|
||||
ok, _ = bid_mark_ok(bid=7.0, mark=10.0, max_dev_pct=30)
|
||||
|
||||
@@ -225,6 +225,7 @@ export type PlanState = {
|
||||
rest_left_sec: number;
|
||||
rest_seconds: number;
|
||||
skip_weekends?: boolean;
|
||||
one_expiry_per_day?: boolean;
|
||||
exit_move_pct?: number;
|
||||
exit_mode: "fixed_usdt" | "premium_multiple";
|
||||
net_profit_target: number;
|
||||
@@ -315,6 +316,7 @@ export type StrategySettings = {
|
||||
rest_seconds: number;
|
||||
live_order_interval_sec?: number;
|
||||
skip_weekends?: boolean;
|
||||
one_expiry_per_day?: boolean;
|
||||
initial_equity?: number;
|
||||
leverage?: number;
|
||||
perp_margin_mode?: "cross" | "isolated";
|
||||
|
||||
@@ -298,6 +298,9 @@ export default function PlanPage() {
|
||||
{fmt(plan?.min_option_hours, 0)}h · 期权杠杆≥
|
||||
{fmt(plan?.min_option_leverage, 0)}x · {atmRule}
|
||||
{plan?.skip_weekends ? " · 周末跳过开仓" : ""}
|
||||
{plan?.one_expiry_per_day !== false
|
||||
? " · 同到期日一天只开一次"
|
||||
: ""}
|
||||
</p>
|
||||
<p>
|
||||
<span className="plan-rules-k">平仓</span>
|
||||
@@ -306,6 +309,9 @@ export default function PlanPage() {
|
||||
<p>
|
||||
<span className="plan-rules-k">节奏</span>
|
||||
组间休息 {plan?.rest_seconds ?? "—"}s · 可开判定看交易账户
|
||||
{plan?.one_expiry_per_day !== false
|
||||
? " · 同到期平仓后改开下一到期"
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -73,6 +73,7 @@ export default function SettingsPage() {
|
||||
const [rest, setRest] = useState(300);
|
||||
const [orderInterval, setOrderInterval] = useState(1);
|
||||
const [skipWeekends, setSkipWeekends] = useState(true);
|
||||
const [oneExpiryPerDay, setOneExpiryPerDay] = useState(true);
|
||||
const [leverage, setLeverage] = useState(3);
|
||||
const [perpMarginMode, setPerpMarginMode] = useState<"cross" | "isolated">(
|
||||
"cross",
|
||||
@@ -180,6 +181,7 @@ export default function SettingsPage() {
|
||||
setRest(s.rest_seconds);
|
||||
setOrderInterval(s.live_order_interval_sec ?? 1);
|
||||
setSkipWeekends(s.skip_weekends !== false);
|
||||
setOneExpiryPerDay(s.one_expiry_per_day !== false);
|
||||
setLeverage(s.leverage ?? 3);
|
||||
setPerpMarginMode(
|
||||
s.perp_margin_mode === "isolated" ? "isolated" : "cross",
|
||||
@@ -344,6 +346,7 @@ export default function SettingsPage() {
|
||||
rest_seconds: rest,
|
||||
live_order_interval_sec: orderInterval,
|
||||
skip_weekends: skipWeekends,
|
||||
one_expiry_per_day: oneExpiryPerDay,
|
||||
leverage,
|
||||
perp_margin_mode: perpMarginMode,
|
||||
min_option_hours: minHours,
|
||||
@@ -1167,6 +1170,25 @@ export default function SettingsPage() {
|
||||
<option value="0">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="oneExpDay">
|
||||
同到期日一天只开一次(节奏)
|
||||
</label>
|
||||
<select
|
||||
id="oneExpDay"
|
||||
className="mono"
|
||||
value={oneExpiryPerDay ? "1" : "0"}
|
||||
onChange={(e) =>
|
||||
setOneExpiryPerDay(e.target.value === "1")
|
||||
}
|
||||
>
|
||||
<option value="1">开启(推荐)</option>
|
||||
<option value="0">关闭</option>
|
||||
</select>
|
||||
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
|
||||
开启后:当日已开过某到期日(如 0802)并平仓后,不能再开同到期,只能开下一到期(如 0803)。
|
||||
</p>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="fee">手续费率</label>
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user