Close residual options when premium recovers above configurable threshold.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -46,6 +46,8 @@ KEYS = (
|
|||||||
"fixed_direction_enabled",
|
"fixed_direction_enabled",
|
||||||
"fixed_perp_side",
|
"fixed_perp_side",
|
||||||
"close_bid_mark_max_pct",
|
"close_bid_mark_max_pct",
|
||||||
|
"residual_min_premium_pct",
|
||||||
|
"residual_close_check_sec",
|
||||||
"perp_qty_eth",
|
"perp_qty_eth",
|
||||||
"option_qty_eth",
|
"option_qty_eth",
|
||||||
"show_manual_trade_buttons",
|
"show_manual_trade_buttons",
|
||||||
@@ -82,6 +84,8 @@ class StrategySettingsBody(BaseModel):
|
|||||||
fixed_direction_enabled: bool | None = None
|
fixed_direction_enabled: bool | None = None
|
||||||
fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$")
|
fixed_perp_side: str | None = Field(default=None, pattern="^(long|short)$")
|
||||||
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
|
close_bid_mark_max_pct: float | None = Field(default=None, ge=1, le=100)
|
||||||
|
residual_min_premium_pct: float | None = Field(default=None, ge=1, le=100)
|
||||||
|
residual_close_check_sec: int | None = Field(default=None, ge=30, le=86400)
|
||||||
perp_qty_eth: float | None = Field(default=None, ge=0.01, 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)
|
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||||
show_manual_trade_buttons: bool | None = None
|
show_manual_trade_buttons: bool | None = None
|
||||||
@@ -213,6 +217,20 @@ def _read_settings() -> dict:
|
|||||||
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
|
db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct))
|
||||||
or s.close_bid_mark_max_pct
|
or s.close_bid_mark_max_pct
|
||||||
),
|
),
|
||||||
|
"residual_min_premium_pct": float(
|
||||||
|
db.get_setting(
|
||||||
|
"residual_min_premium_pct", str(s.residual_min_premium_pct)
|
||||||
|
)
|
||||||
|
or s.residual_min_premium_pct
|
||||||
|
),
|
||||||
|
"residual_close_check_sec": int(
|
||||||
|
float(
|
||||||
|
db.get_setting(
|
||||||
|
"residual_close_check_sec", str(s.residual_close_check_sec)
|
||||||
|
)
|
||||||
|
or s.residual_close_check_sec
|
||||||
|
)
|
||||||
|
),
|
||||||
"perp_qty_eth": float(
|
"perp_qty_eth": float(
|
||||||
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
|
db.get_setting("perp_qty_eth", str(s.perp_qty_eth)) or s.perp_qty_eth
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ class Settings(BaseSettings):
|
|||||||
fixed_direction_enabled: bool = False
|
fixed_direction_enabled: bool = False
|
||||||
fixed_perp_side: str = "long" # long|short;long→买Put,short→买Call
|
fixed_perp_side: str = "long" # long|short;long→买Put,short→买Call
|
||||||
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
|
close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差%
|
||||||
|
# 残留期权中途平:当前买一权利金 ≥ 初始权利金 × 该% 才尝试卖出
|
||||||
|
residual_min_premium_pct: float = 20.0
|
||||||
|
residual_close_check_sec: int = 300 # 残留巡检间隔(秒)
|
||||||
perp_qty_eth: float = 1.0
|
perp_qty_eth: float = 1.0
|
||||||
option_qty_eth: float = 2.0
|
option_qty_eth: float = 2.0
|
||||||
db_path: str = "" # empty -> backend/data/hedge.db
|
db_path: str = "" # empty -> backend/data/hedge.db
|
||||||
|
|||||||
@@ -1149,6 +1149,69 @@ class BinanceLiveExecutor(Matcher):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def try_close_one_residual(self, row: dict) -> dict | None:
|
||||||
|
"""LIVE-BN:权利金达标后交易所市价卖出归档期权。"""
|
||||||
|
err = self._guard_live()
|
||||||
|
if err:
|
||||||
|
logger.warning("residual premium close blocked: %s", err)
|
||||||
|
return None
|
||||||
|
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
|
||||||
|
if skip or close_bid is None:
|
||||||
|
if skip:
|
||||||
|
logger.debug(
|
||||||
|
"residual premium close skip %s: %s",
|
||||||
|
row.get("group_id"),
|
||||||
|
skip,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
|
opt_contracts = float(row.get("option_qty_contracts") or 0)
|
||||||
|
opt_qty = float(row.get("option_qty_eth") or 0)
|
||||||
|
if opt_contracts <= 0:
|
||||||
|
ct = self._ct_mult(option_inst_id)
|
||||||
|
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
|
||||||
|
if opt_contracts <= 0:
|
||||||
|
logger.warning(
|
||||||
|
"residual premium close skip %s: bad contracts", row.get("group_id")
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
client = self._client()
|
||||||
|
try:
|
||||||
|
opt_live = client.place_option_market(
|
||||||
|
symbol=option_inst_id,
|
||||||
|
side="SELL",
|
||||||
|
quantity=max(1.0, opt_contracts),
|
||||||
|
reduce_only=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"residual premium close exchange sell failed %s: %s",
|
||||||
|
row.get("group_id"),
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
of_px = float(opt_live.avg_px)
|
||||||
|
of_fee = float(opt_live.fee)
|
||||||
|
filled_c = (
|
||||||
|
float(opt_live.sz)
|
||||||
|
if opt_live.sz and float(opt_live.sz) > 0
|
||||||
|
else opt_contracts
|
||||||
|
)
|
||||||
|
opt_qty = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
||||||
|
row = {**row, "option_qty_eth": opt_qty, "option_qty_contracts": filled_c}
|
||||||
|
of_notional = of_px * opt_qty
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
return self._book_residual_market_close(
|
||||||
|
row,
|
||||||
|
fill_px=of_px,
|
||||||
|
fee=of_fee,
|
||||||
|
notional=of_notional,
|
||||||
|
slip=0.0,
|
||||||
|
now_ms=now_ms,
|
||||||
|
note="LIVE-BN residual mid-close by premium recovery",
|
||||||
|
exec_mode="LIVE",
|
||||||
|
)
|
||||||
|
|
||||||
def close_perp_abandon_option(
|
def close_perp_abandon_option(
|
||||||
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
||||||
) -> CloseResult:
|
) -> CloseResult:
|
||||||
|
|||||||
@@ -1192,6 +1192,70 @@ class OkxLiveExecutor(Matcher):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def try_close_one_residual(self, row: dict) -> dict | None:
|
||||||
|
"""LIVE:权利金达标后交易所市价卖出归档期权。"""
|
||||||
|
err = self._guard_live()
|
||||||
|
if err:
|
||||||
|
logger.warning("residual premium close blocked: %s", err)
|
||||||
|
return None
|
||||||
|
skip, close_bid, _oq = self._evaluate_residual_premium_close(row)
|
||||||
|
if skip or close_bid is None:
|
||||||
|
if skip:
|
||||||
|
logger.debug(
|
||||||
|
"residual premium close skip %s: %s",
|
||||||
|
row.get("group_id"),
|
||||||
|
skip,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
|
opt_contracts = float(row.get("option_qty_contracts") or 0)
|
||||||
|
opt_qty = float(row.get("option_qty_eth") or 0)
|
||||||
|
if opt_contracts <= 0:
|
||||||
|
ct = self._ct_mult(option_inst_id)
|
||||||
|
opt_contracts = float(contracts_for_eth(opt_qty, ct) or 0)
|
||||||
|
if opt_contracts <= 0:
|
||||||
|
logger.warning(
|
||||||
|
"residual premium close skip %s: bad contracts", row.get("group_id")
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
client = self._client()
|
||||||
|
try:
|
||||||
|
opt_live = client.place_market(
|
||||||
|
inst_id=option_inst_id,
|
||||||
|
side="sell",
|
||||||
|
sz=str(max(1, int(round(opt_contracts)))),
|
||||||
|
td_mode="cash",
|
||||||
|
reduce_only=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"residual premium close exchange sell failed %s: %s",
|
||||||
|
row.get("group_id"),
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
of_px = float(opt_live.avg_px)
|
||||||
|
of_fee = float(opt_live.fee)
|
||||||
|
filled_c = (
|
||||||
|
float(opt_live.sz)
|
||||||
|
if opt_live.sz and float(opt_live.sz) > 0
|
||||||
|
else opt_contracts
|
||||||
|
)
|
||||||
|
opt_qty = eth_from_contracts(filled_c, self._ct_mult(option_inst_id))
|
||||||
|
row = {**row, "option_qty_eth": opt_qty, "option_qty_contracts": filled_c}
|
||||||
|
of_notional = of_px * opt_qty
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
return self._book_residual_market_close(
|
||||||
|
row,
|
||||||
|
fill_px=of_px,
|
||||||
|
fee=of_fee,
|
||||||
|
notional=of_notional,
|
||||||
|
slip=0.0,
|
||||||
|
now_ms=now_ms,
|
||||||
|
note="LIVE residual mid-close by premium recovery",
|
||||||
|
exec_mode="LIVE",
|
||||||
|
)
|
||||||
|
|
||||||
def close_perp_abandon_option(
|
def close_perp_abandon_option(
|
||||||
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
||||||
) -> CloseResult:
|
) -> CloseResult:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ CLOSE_REASON_ZH: dict[str, str] = {
|
|||||||
"manual": "手动全平",
|
"manual": "手动全平",
|
||||||
"perp_pending_retry": "续平永续",
|
"perp_pending_retry": "续平永续",
|
||||||
"liquidity_retry": "等待流动性后全平",
|
"liquidity_retry": "等待流动性后全平",
|
||||||
|
"residual_premium_close": "残留期权·权利金回收中途平",
|
||||||
"unknown": "未知原因",
|
"unknown": "未知原因",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -829,6 +829,232 @@ class Matcher:
|
|||||||
)
|
)
|
||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def _residual_min_premium_pct(self) -> float:
|
||||||
|
s = get_settings()
|
||||||
|
return float(
|
||||||
|
self.ledger.get_setting_float(
|
||||||
|
"residual_min_premium_pct", s.residual_min_premium_pct
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _evaluate_residual_premium_close(
|
||||||
|
self, row: dict[str, Any]
|
||||||
|
) -> tuple[str | None, float | None, Any]:
|
||||||
|
"""
|
||||||
|
残留中途平前置:权利金比例 + 买一流动性。
|
||||||
|
返回 (skip_reason, close_bid, option_quote);skip_reason 非空则本轮不卖。
|
||||||
|
"""
|
||||||
|
s = get_settings()
|
||||||
|
initial_premium = float(row.get("initial_premium") or 0)
|
||||||
|
opt_qty = float(row.get("option_qty_eth") or 0)
|
||||||
|
if initial_premium <= 0 or opt_qty <= 0:
|
||||||
|
return ("invalid_initial_premium_or_qty", None, None)
|
||||||
|
|
||||||
|
option_inst_id = str(row.get("option_inst_id") or "")
|
||||||
|
if not option_inst_id:
|
||||||
|
return ("missing_option_inst", None, None)
|
||||||
|
|
||||||
|
oq = self._quote_held_option(option_inst_id)
|
||||||
|
if oq is None or oq.bid is None:
|
||||||
|
return ("option_bid_unavailable", None, None)
|
||||||
|
|
||||||
|
close_bid = float(oq.bid)
|
||||||
|
current_premium = close_bid * opt_qty
|
||||||
|
min_pct = self._residual_min_premium_pct()
|
||||||
|
threshold = initial_premium * (min_pct / 100.0)
|
||||||
|
if current_premium + 1e-12 < threshold:
|
||||||
|
return (
|
||||||
|
f"premium_below_threshold curr={current_premium:.4f} "
|
||||||
|
f"need>={threshold:.4f} ({min_pct:g}%)",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
ct_mult = self._ct_mult(option_inst_id)
|
||||||
|
if not bid_covers_eth(
|
||||||
|
bid_sz_contracts=oq.bid_sz,
|
||||||
|
ct_mult=ct_mult,
|
||||||
|
need_eth=opt_qty,
|
||||||
|
):
|
||||||
|
return ("option_bid_liquidity_insufficient", None, None)
|
||||||
|
|
||||||
|
max_dev = self.ledger.get_setting_float(
|
||||||
|
"close_bid_mark_max_pct", s.close_bid_mark_max_pct
|
||||||
|
)
|
||||||
|
ok_dev, why = bid_mark_ok(bid=close_bid, mark=oq.mark_px, max_dev_pct=max_dev)
|
||||||
|
if not ok_dev:
|
||||||
|
return (why or "bid_mark_deviation", None, None)
|
||||||
|
|
||||||
|
strike = row.get("strike")
|
||||||
|
spot = self._close_spot_px(get_session().snapshot())
|
||||||
|
intrinsic: float | None = None
|
||||||
|
if strike is not None and spot is not None:
|
||||||
|
intrinsic = option_intrinsic(
|
||||||
|
option_side=str(row["option_side"]),
|
||||||
|
strike=float(strike),
|
||||||
|
spot=float(spot),
|
||||||
|
)
|
||||||
|
resolved = resolve_option_close_bid(
|
||||||
|
bid=close_bid,
|
||||||
|
mark=oq.mark_px,
|
||||||
|
intrinsic=intrinsic,
|
||||||
|
bypass_liquidity=False,
|
||||||
|
)
|
||||||
|
if resolved is None:
|
||||||
|
return ("option_close_px_unavailable", None, None)
|
||||||
|
return (None, float(resolved), oq)
|
||||||
|
|
||||||
|
def _book_residual_market_close(
|
||||||
|
self,
|
||||||
|
row: dict[str, Any],
|
||||||
|
*,
|
||||||
|
fill_px: float,
|
||||||
|
fee: float,
|
||||||
|
notional: float,
|
||||||
|
slip: float,
|
||||||
|
now_ms: int,
|
||||||
|
note: str,
|
||||||
|
exec_mode: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""买一卖出残留后的入账与结清(SIM/LIVE 共用)。"""
|
||||||
|
group_id = str(row["group_id"])
|
||||||
|
opt_qty = float(row["option_qty_eth"])
|
||||||
|
opt_entry = float(row["option_entry_px"])
|
||||||
|
opt_pnl = (float(fill_px) - opt_entry) * opt_qty
|
||||||
|
opt_cash = float(notional) - float(fee)
|
||||||
|
self.ledger.apply_cash(
|
||||||
|
opt_cash,
|
||||||
|
kind="close_option",
|
||||||
|
group_id=group_id,
|
||||||
|
note=note,
|
||||||
|
allow_negative=not get_settings().is_sim,
|
||||||
|
)
|
||||||
|
|
||||||
|
fill_cols = (
|
||||||
|
"group_id, leg, action, side, inst_id, qty_eth, qty_contracts, "
|
||||||
|
"base_px, fill_px, fee, slip, notional, ts_ms"
|
||||||
|
)
|
||||||
|
fill_vals: list[Any] = [
|
||||||
|
group_id,
|
||||||
|
"option",
|
||||||
|
"close",
|
||||||
|
"flat",
|
||||||
|
str(row["option_inst_id"]),
|
||||||
|
opt_qty,
|
||||||
|
float(row["option_qty_contracts"] or 0),
|
||||||
|
float(fill_px),
|
||||||
|
float(fill_px),
|
||||||
|
float(fee),
|
||||||
|
float(slip),
|
||||||
|
float(notional),
|
||||||
|
now_ms,
|
||||||
|
]
|
||||||
|
if exec_mode:
|
||||||
|
fill_cols += ", exec_mode"
|
||||||
|
fill_vals.append(exec_mode)
|
||||||
|
|
||||||
|
with self.db._lock:
|
||||||
|
self.db._conn.execute(
|
||||||
|
f"""INSERT INTO fills({fill_cols})
|
||||||
|
VALUES ({",".join("?" for _ in fill_vals)})""",
|
||||||
|
tuple(fill_vals),
|
||||||
|
)
|
||||||
|
fills = self.db._conn.execute(
|
||||||
|
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
|
||||||
|
).fetchall()
|
||||||
|
from ..sim.pnl import summarize_fills_pnl
|
||||||
|
|
||||||
|
summary = summarize_fills_pnl(list(fills))
|
||||||
|
net = summary.get("net_pnl")
|
||||||
|
if net is None:
|
||||||
|
net = opt_pnl - float(fee)
|
||||||
|
g = self.db._conn.execute(
|
||||||
|
"SELECT fees, slip_cost FROM groups WHERE group_id=?", (group_id,)
|
||||||
|
).fetchone()
|
||||||
|
fees = float(g["fees"] or 0) + float(fee) if g else float(fee)
|
||||||
|
slip_total = float(g["slip_cost"] or 0) + float(slip) if g else float(slip)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""UPDATE residual_options SET status=?, settled_at_ms=?, settle_px=?, settle_pnl=?, note=?
|
||||||
|
WHERE group_id=?""",
|
||||||
|
(
|
||||||
|
"settled",
|
||||||
|
now_ms,
|
||||||
|
float(fill_px),
|
||||||
|
opt_pnl,
|
||||||
|
note,
|
||||||
|
group_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db._conn.execute(
|
||||||
|
"""UPDATE groups SET status=?, close_at_ms=COALESCE(close_at_ms, ?),
|
||||||
|
close_reason=COALESCE(close_reason, ?), realized_pnl=?, fees=?, slip_cost=?
|
||||||
|
WHERE group_id=?""",
|
||||||
|
(
|
||||||
|
"closed",
|
||||||
|
now_ms,
|
||||||
|
"residual_premium_close",
|
||||||
|
float(net),
|
||||||
|
fees,
|
||||||
|
slip_total,
|
||||||
|
group_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.db._conn.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"group_id": group_id,
|
||||||
|
"option_pnl": opt_pnl,
|
||||||
|
"settle_px": float(fill_px),
|
||||||
|
"net_pnl": float(net),
|
||||||
|
"reason": "residual_premium_close",
|
||||||
|
"current_premium": float(fill_px) * opt_qty,
|
||||||
|
"initial_premium": float(row.get("initial_premium") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
def try_close_one_residual(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""SIM:权利金达标且流动性通过则本地吃买一平残留。"""
|
||||||
|
skip, close_bid, oq = self._evaluate_residual_premium_close(row)
|
||||||
|
if skip or close_bid is None or oq is None:
|
||||||
|
if skip:
|
||||||
|
logger.debug(
|
||||||
|
"residual premium close skip %s: %s",
|
||||||
|
row.get("group_id"),
|
||||||
|
skip,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
of = option_fill(
|
||||||
|
action="close",
|
||||||
|
bid=float(close_bid),
|
||||||
|
ask=float(oq.ask or close_bid),
|
||||||
|
qty_eth=float(row["option_qty_eth"]),
|
||||||
|
fee_rate=self._fee_rate(),
|
||||||
|
)
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
return self._book_residual_market_close(
|
||||||
|
row,
|
||||||
|
fill_px=of.fill_px,
|
||||||
|
fee=of.fee,
|
||||||
|
notional=of.notional,
|
||||||
|
slip=of.slip,
|
||||||
|
now_ms=now_ms,
|
||||||
|
note="residual mid-close by premium recovery",
|
||||||
|
)
|
||||||
|
|
||||||
|
def try_close_pending_residuals(self) -> list[dict[str, Any]]:
|
||||||
|
"""巡检全部 pending 残留,尝试权利金回收平仓。"""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in self.list_residual_options(pending_only=True):
|
||||||
|
try:
|
||||||
|
r = self.try_close_one_residual(row)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"try_close_one_residual failed group=%s", row.get("group_id")
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if r:
|
||||||
|
out.append(r)
|
||||||
|
return out
|
||||||
|
|
||||||
def settle_due_residuals(self, *, now_ms: int | None = None) -> list[dict[str, Any]]:
|
def settle_due_residuals(self, *, now_ms: int | None = None) -> list[dict[str, Any]]:
|
||||||
"""到期结算所有 pending 残留期权(不扫描进活跃组平仓)。"""
|
"""到期结算所有 pending 残留期权(不扫描进活跃组平仓)。"""
|
||||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class StrategyEngine:
|
|||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._retry_gate = LiveRetryGate()
|
self._retry_gate = LiveRetryGate()
|
||||||
self._extra_sleep_sec = 0.0
|
self._extra_sleep_sec = 0.0
|
||||||
|
self._last_residual_premium_check_ms = 0
|
||||||
|
|
||||||
def refresh_executor(self) -> None:
|
def refresh_executor(self) -> None:
|
||||||
"""MODE 变更后刷新执行器。"""
|
"""MODE 变更后刷新执行器。"""
|
||||||
@@ -545,6 +546,41 @@ class StrategyEngine:
|
|||||||
async def _settle_residuals(self) -> None:
|
async def _settle_residuals(self) -> None:
|
||||||
await asyncio.to_thread(self.matcher.settle_due_residuals)
|
await asyncio.to_thread(self.matcher.settle_due_residuals)
|
||||||
|
|
||||||
|
async def _maybe_close_residuals_by_premium(self) -> None:
|
||||||
|
"""残留期权:权利金回升达标时周期性尝试中途平仓。"""
|
||||||
|
s = get_settings()
|
||||||
|
interval_sec = int(
|
||||||
|
self.ledger.get_setting_int(
|
||||||
|
"residual_close_check_sec", s.residual_close_check_sec
|
||||||
|
)
|
||||||
|
)
|
||||||
|
interval_sec = max(30, interval_sec)
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
if now_ms - self._last_residual_premium_check_ms < interval_sec * 1000:
|
||||||
|
return
|
||||||
|
self._last_residual_premium_check_ms = now_ms
|
||||||
|
fn = getattr(self.matcher, "try_close_pending_residuals", None)
|
||||||
|
if not callable(fn):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
closed = await asyncio.to_thread(fn)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("try_close_pending_residuals failed")
|
||||||
|
return
|
||||||
|
if not closed:
|
||||||
|
return
|
||||||
|
for item in closed:
|
||||||
|
try:
|
||||||
|
from ..notify import wecom
|
||||||
|
|
||||||
|
wecom.notify_close(
|
||||||
|
reason="residual_premium_close",
|
||||||
|
detail="残留期权权利金回收中途平",
|
||||||
|
data=item if isinstance(item, dict) else {},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("wecom notify residual premium close failed")
|
||||||
|
|
||||||
async def _maybe_expiry_close(self) -> bool:
|
async def _maybe_expiry_close(self) -> bool:
|
||||||
"""若持仓已到期则强制全平。返回是否触发到期平仓。"""
|
"""若持仓已到期则强制全平。返回是否触发到期平仓。"""
|
||||||
await self._settle_residuals()
|
await self._settle_residuals()
|
||||||
@@ -613,6 +649,7 @@ class StrategyEngine:
|
|||||||
async def _tick_manage_positions(self) -> None:
|
async def _tick_manage_positions(self) -> None:
|
||||||
"""有仓时的盯盘:残留结算 / 半仓修复 / 目标平 / 到期平。不新开仓。"""
|
"""有仓时的盯盘:残留结算 / 半仓修复 / 目标平 / 到期平。不新开仓。"""
|
||||||
await self._settle_residuals()
|
await self._settle_residuals()
|
||||||
|
await self._maybe_close_residuals_by_premium()
|
||||||
|
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""残留期权:权利金回升达标后中途平。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.models.db import Database
|
||||||
|
from app.sim.matcher import Matcher
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_residual(
|
||||||
|
db: Database,
|
||||||
|
*,
|
||||||
|
group_id: str = "G-res",
|
||||||
|
initial_premium: float = 100.0,
|
||||||
|
qty: float = 2.0,
|
||||||
|
entry_px: float = 50.0,
|
||||||
|
) -> None:
|
||||||
|
now = 1_700_000_000_000
|
||||||
|
with db._lock:
|
||||||
|
db._conn.execute(
|
||||||
|
"""INSERT INTO groups(
|
||||||
|
group_id, status, bias, option_side, perp_side, option_inst_id,
|
||||||
|
strike, expiry_ymd, initial_premium, open_at_ms, close_at_ms,
|
||||||
|
close_reason, realized_pnl, fees, slip_cost
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
"option_residual",
|
||||||
|
"test",
|
||||||
|
"call",
|
||||||
|
"short",
|
||||||
|
"ETH-USD_UM-260801-2000-C",
|
||||||
|
2000.0,
|
||||||
|
"260801",
|
||||||
|
initial_premium,
|
||||||
|
now - 10_000,
|
||||||
|
now - 5_000,
|
||||||
|
"target_perp_only",
|
||||||
|
10.0,
|
||||||
|
1.0,
|
||||||
|
0.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db._conn.execute(
|
||||||
|
"""INSERT INTO residual_options(
|
||||||
|
group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts,
|
||||||
|
option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px,
|
||||||
|
initial_premium, status, created_at_ms, note
|
||||||
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||||
|
(
|
||||||
|
group_id,
|
||||||
|
"ETH-USD_UM-260801-2000-C",
|
||||||
|
"call",
|
||||||
|
qty,
|
||||||
|
200.0,
|
||||||
|
entry_px,
|
||||||
|
2000.0,
|
||||||
|
"260801",
|
||||||
|
now + 86_400_000,
|
||||||
|
1900.0,
|
||||||
|
initial_premium,
|
||||||
|
"pending",
|
||||||
|
now - 5_000,
|
||||||
|
"test residual",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db._conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_residual_premium_below_threshold_skips(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
db = Database(tmp_path / "below.db")
|
||||||
|
db.set_setting("residual_min_premium_pct", "20")
|
||||||
|
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||||
|
m = Matcher(db)
|
||||||
|
|
||||||
|
# bid=5 → premium=10 < 20
|
||||||
|
oq = SimpleNamespace(bid=5.0, ask=5.5, bid_sz=10_000.0, mark_px=5.0)
|
||||||
|
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||||
|
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||||
|
|
||||||
|
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
|
||||||
|
row = db.fetchone(
|
||||||
|
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||||
|
)
|
||||||
|
assert row is not None and row["status"] == "pending"
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_residual_premium_above_threshold_closes(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
db = Database(tmp_path / "above.db")
|
||||||
|
db.set_setting("residual_min_premium_pct", "20")
|
||||||
|
_seed_residual(db, initial_premium=100.0, qty=2.0, entry_px=50.0)
|
||||||
|
m = Matcher(db)
|
||||||
|
|
||||||
|
# bid=15 → premium=30 >= 20
|
||||||
|
oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=10_000.0, mark_px=15.0)
|
||||||
|
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||||
|
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||||
|
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||||
|
|
||||||
|
out = m.try_close_one_residual(m.list_residual_options()[0])
|
||||||
|
assert out is not None
|
||||||
|
assert out["reason"] == "residual_premium_close"
|
||||||
|
row = db.fetchone(
|
||||||
|
"SELECT status, settle_px FROM residual_options WHERE group_id=?", ("G-res",)
|
||||||
|
)
|
||||||
|
assert row is not None and row["status"] == "settled"
|
||||||
|
g = db.fetchone("SELECT status FROM groups WHERE group_id=?", ("G-res",))
|
||||||
|
assert g is not None and g["status"] == "closed"
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_residual_liquidity_fail_skips(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
db = Database(tmp_path / "liq.db")
|
||||||
|
db.set_setting("residual_min_premium_pct", "20")
|
||||||
|
_seed_residual(db, initial_premium=100.0, qty=2.0)
|
||||||
|
m = Matcher(db)
|
||||||
|
|
||||||
|
# premium ok but depth tiny
|
||||||
|
oq = SimpleNamespace(bid=15.0, ask=15.5, bid_sz=1.0, mark_px=15.0)
|
||||||
|
monkeypatch.setattr(m, "_quote_held_option", lambda _id: oq)
|
||||||
|
monkeypatch.setattr(m, "_close_spot_px", lambda _snap: 1900.0)
|
||||||
|
monkeypatch.setattr(m, "_ct_mult", lambda _id: 0.01)
|
||||||
|
|
||||||
|
assert m.try_close_one_residual(m.list_residual_options()[0]) is None
|
||||||
|
row = db.fetchone(
|
||||||
|
"SELECT status FROM residual_options WHERE group_id=?", ("G-res",)
|
||||||
|
)
|
||||||
|
assert row is not None and row["status"] == "pending"
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_exposes_residual_min_premium_pct(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
from app.api import settings as settings_api
|
||||||
|
from app.models.db import set_db
|
||||||
|
|
||||||
|
d = Database(tmp_path / "set.db")
|
||||||
|
set_db(d)
|
||||||
|
try:
|
||||||
|
d.set_setting("residual_min_premium_pct", "35")
|
||||||
|
payload = settings_api._read_settings()
|
||||||
|
assert float(payload["residual_min_premium_pct"]) == 35.0
|
||||||
|
finally:
|
||||||
|
set_db(None)
|
||||||
|
d.close()
|
||||||
+8
-7
@@ -180,11 +180,10 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10
|
|||||||
- 条件:净利达标,且期权已是 **虚值且远虚**(内在价值 ≈ 0)。
|
- 条件:净利达标,且期权已是 **虚值且远虚**(内在价值 ≈ 0)。
|
||||||
- 动作:
|
- 动作:
|
||||||
1. **只市价平掉永续**,兑现净利里永续那一截;
|
1. **只市价平掉永续**,兑现净利里永续那一截;
|
||||||
2. 本张期权 **不再盯盘、不再参与平仓扫描**,归档为「到期残留」;
|
2. 本张期权归档为「残留」:不占用活跃持仓、**不挡住下一组开仓**;下一组只扫当前活跃组期权;
|
||||||
3. 因期权 **逐仓**,残留 **不占用活跃持仓**,**不挡住下一组开仓**;
|
3. **中途回收(可配置)**:默认每 **5 分钟**巡检 pending 残留;当 **买一权利金 ≥ 初始权利金 × 比例**(默认 **20%**,系统设置「残留期权回收」可改)且通过买一流动性闸门时,**市价卖掉**该残留并结清;
|
||||||
4. 下一组开平仓 **只扫当前活跃组期权**,不扫描历史残留腿;
|
4. 未达比例或闸门不过 → 继续等到下次巡检,或到期按 **内在价值** 结算(多半接近 0);
|
||||||
5. 残留期权到到期日再按 **内在价值** 单独结算(多半接近 0);
|
5. 页面:**活跃持仓区变空**;归档腿出现在「残留期权(待到期)」;下方期权盘口 **切回新 ATM**(见 4.6)。
|
||||||
6. 页面:**活跃持仓区变空**;归档腿出现在「残留期权(待到期)」;下方期权盘口 **切回新 ATM**(见 4.6)。
|
|
||||||
|
|
||||||
残留到期结算口径:
|
残留到期结算口径:
|
||||||
|
|
||||||
@@ -213,7 +212,7 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10
|
|||||||
- 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`);
|
- 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`);
|
||||||
- 不满足 → `liquidity_wait`,继续等待。
|
- 不满足 → `liquidity_wait`,继续等待。
|
||||||
|
|
||||||
**4.1.B / 到期 / 紧急全平**:不适用「必须卖掉期权买一」这套闸门(到期与残留按内在价值;紧急可绕过)。
|
**4.1.B 归档当下 / 到期 / 紧急全平**:归档与到期不强制吃买一(到期/强制按内在价值;紧急可绕过)。**残留中途回收**仍走买一深度 + 偏差闸门(同 4.1.A)。
|
||||||
|
|
||||||
**紧急全平**:活跃组尽量双腿平掉;残留期权一并按内在价值结算;成功后进入组间休息(与手动全平相同)。
|
**紧急全平**:活跃组尽量双腿平掉;残留期权一并按内在价值结算;成功后进入组间休息(与手动全平相同)。
|
||||||
|
|
||||||
@@ -223,6 +222,7 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10
|
|||||||
|--------|------|
|
|--------|------|
|
||||||
| `fixed_usdt` / `premium_multiple` | 目标平仓 · 双腿全平(4.1.A) |
|
| `fixed_usdt` / `premium_multiple` | 目标平仓 · 双腿全平(4.1.A) |
|
||||||
| `target_perp_only` | 目标平仓 · 只平永续,期权归档到期(4.1.B) |
|
| `target_perp_only` | 目标平仓 · 只平永续,期权归档到期(4.1.B) |
|
||||||
|
| `residual_premium_close` | 残留期权 · 权利金回升达标后中途平 |
|
||||||
| `expiry` | 到期结算(活跃组或残留期权) |
|
| `expiry` | 到期结算(活跃组或残留期权) |
|
||||||
| `emergency` | 界面紧急全平 |
|
| `emergency` | 界面紧急全平 |
|
||||||
| `manual` | 手动平仓 |
|
| `manual` | 手动平仓 |
|
||||||
@@ -237,7 +237,8 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10
|
|||||||
└─ 否 → 持有直到到期 → 内在价值结算(+ 若有永续则平永续)
|
└─ 否 → 持有直到到期 → 内在价值结算(+ 若有永续则平永续)
|
||||||
|
|
||||||
残留期权(已归档)
|
残留期权(已归档)
|
||||||
└─ 仅到期结算;不参与盯盘、不参与下一组平仓扫描
|
├─ 周期性:买一权利金 / 初始 ≥ 设置% 且流动性过 → 市价卖出结清
|
||||||
|
└─ 否则到期内在价值结算;不挡下一组开仓
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.6 行情监控与页面展示
|
### 4.6 行情监控与页面展示
|
||||||
|
|||||||
@@ -339,6 +339,8 @@ export type StrategySettings = {
|
|||||||
fixed_direction_enabled?: boolean;
|
fixed_direction_enabled?: boolean;
|
||||||
fixed_perp_side?: "long" | "short";
|
fixed_perp_side?: "long" | "short";
|
||||||
close_bid_mark_max_pct?: number;
|
close_bid_mark_max_pct?: number;
|
||||||
|
residual_min_premium_pct?: number;
|
||||||
|
residual_close_check_sec?: number;
|
||||||
perp_qty_eth?: number;
|
perp_qty_eth?: number;
|
||||||
option_qty_eth?: number;
|
option_qty_eth?: number;
|
||||||
show_manual_trade_buttons?: boolean;
|
show_manual_trade_buttons?: boolean;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ const CLOSE_REASON_ZH: Record<string, string> = {
|
|||||||
fixed_usdt: "固定净盈利达标·双腿全平",
|
fixed_usdt: "固定净盈利达标·双腿全平",
|
||||||
premium_multiple: "权利金倍数达标·双腿全平",
|
premium_multiple: "权利金倍数达标·双腿全平",
|
||||||
target_perp_only: "净盈利达标·只平永续(期权归档)",
|
target_perp_only: "净盈利达标·只平永续(期权归档)",
|
||||||
|
residual_premium_close: "残留期权·权利金回收中途平",
|
||||||
expiry: "到期结算",
|
expiry: "到期结算",
|
||||||
emergency: "紧急全平",
|
emergency: "紧急全平",
|
||||||
manual: "手动平仓",
|
manual: "手动平仓",
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export default function SettingsPage() {
|
|||||||
const [fixedDirOn, setFixedDirOn] = useState(false);
|
const [fixedDirOn, setFixedDirOn] = useState(false);
|
||||||
const [fixedPerpSide, setFixedPerpSide] = useState<"long" | "short">("long");
|
const [fixedPerpSide, setFixedPerpSide] = useState<"long" | "short">("long");
|
||||||
const [closeDevPct, setCloseDevPct] = useState(30);
|
const [closeDevPct, setCloseDevPct] = useState(30);
|
||||||
|
const [residualMinPremPct, setResidualMinPremPct] = useState(20);
|
||||||
const [perpQty, setPerpQty] = useState(1);
|
const [perpQty, setPerpQty] = useState(1);
|
||||||
const [optQty, setOptQty] = useState(2);
|
const [optQty, setOptQty] = useState(2);
|
||||||
const [showManualTrade, setShowManualTrade] = useState(false);
|
const [showManualTrade, setShowManualTrade] = useState(false);
|
||||||
@@ -196,6 +197,7 @@ export default function SettingsPage() {
|
|||||||
setFixedDirOn(s.fixed_direction_enabled === true);
|
setFixedDirOn(s.fixed_direction_enabled === true);
|
||||||
setFixedPerpSide(s.fixed_perp_side === "short" ? "short" : "long");
|
setFixedPerpSide(s.fixed_perp_side === "short" ? "short" : "long");
|
||||||
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
|
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
|
||||||
|
setResidualMinPremPct(s.residual_min_premium_pct ?? 20);
|
||||||
setPerpQty(s.perp_qty_eth ?? 1);
|
setPerpQty(s.perp_qty_eth ?? 1);
|
||||||
setOptQty(s.option_qty_eth ?? 2);
|
setOptQty(s.option_qty_eth ?? 2);
|
||||||
setShowManualTrade(s.show_manual_trade_buttons === true);
|
setShowManualTrade(s.show_manual_trade_buttons === true);
|
||||||
@@ -362,6 +364,7 @@ export default function SettingsPage() {
|
|||||||
fixed_direction_enabled: fixedDirOn,
|
fixed_direction_enabled: fixedDirOn,
|
||||||
fixed_perp_side: fixedPerpSide,
|
fixed_perp_side: fixedPerpSide,
|
||||||
close_bid_mark_max_pct: closeDevPct,
|
close_bid_mark_max_pct: closeDevPct,
|
||||||
|
residual_min_premium_pct: residualMinPremPct,
|
||||||
show_manual_trade_buttons: showManualTrade,
|
show_manual_trade_buttons: showManualTrade,
|
||||||
sizing_mode: sizingMode,
|
sizing_mode: sizingMode,
|
||||||
risk_leverage_basis: riskLeverageBasis,
|
risk_leverage_basis: riskLeverageBasis,
|
||||||
@@ -1160,6 +1163,23 @@ export default function SettingsPage() {
|
|||||||
onChange={(e) => setCloseDevPct(Number(e.target.value))}
|
onChange={(e) => setCloseDevPct(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="residualPrem">
|
||||||
|
残留期权回收:当前权利金 / 初始权利金 ≥(%)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="residualPrem"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={residualMinPremPct}
|
||||||
|
onChange={(e) =>
|
||||||
|
setResidualMinPremPct(Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1297,10 +1317,16 @@ export default function SettingsPage() {
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{stratSub === "exit" ? (
|
{stratSub === "exit" ? (
|
||||||
<li>
|
<>
|
||||||
平仓买一/标记最大偏差:主要用于 SIM
|
<li>
|
||||||
流动性闸门;LIVE 以交易所能否成交为准。
|
平仓买一/标记最大偏差:主要用于 SIM
|
||||||
</li>
|
流动性闸门;LIVE 以交易所能否成交为准。
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
残留期权回收比例:只平永续后,当买一权利金回升到初始权利金的该比例及以上时,才尝试中途卖掉归档期权;默认
|
||||||
|
20%。未达标则等到期按内在价值结算。
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{stratSub === "pace" ? (
|
{stratSub === "pace" ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
Reference in New Issue
Block a user