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_perp_side",
|
||||
"close_bid_mark_max_pct",
|
||||
"residual_min_premium_pct",
|
||||
"residual_close_check_sec",
|
||||
"perp_qty_eth",
|
||||
"option_qty_eth",
|
||||
"show_manual_trade_buttons",
|
||||
@@ -82,6 +84,8 @@ class StrategySettingsBody(BaseModel):
|
||||
fixed_direction_enabled: bool | None = None
|
||||
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)
|
||||
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)
|
||||
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||
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))
|
||||
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(
|
||||
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_perp_side: str = "long" # long|short;long→买Put,short→买Call
|
||||
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
|
||||
option_qty_eth: float = 2.0
|
||||
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(
|
||||
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
||||
) -> 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(
|
||||
self, *, reason: str = "target_perp_only", require_deep_otm: bool = True
|
||||
) -> CloseResult:
|
||||
|
||||
@@ -37,6 +37,7 @@ CLOSE_REASON_ZH: dict[str, str] = {
|
||||
"manual": "手动全平",
|
||||
"perp_pending_retry": "续平永续",
|
||||
"liquidity_retry": "等待流动性后全平",
|
||||
"residual_premium_close": "残留期权·权利金回收中途平",
|
||||
"unknown": "未知原因",
|
||||
}
|
||||
|
||||
|
||||
@@ -829,6 +829,232 @@ class Matcher:
|
||||
)
|
||||
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]]:
|
||||
"""到期结算所有 pending 残留期权(不扫描进活跃组平仓)。"""
|
||||
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._retry_gate = LiveRetryGate()
|
||||
self._extra_sleep_sec = 0.0
|
||||
self._last_residual_premium_check_ms = 0
|
||||
|
||||
def refresh_executor(self) -> None:
|
||||
"""MODE 变更后刷新执行器。"""
|
||||
@@ -545,6 +546,41 @@ class StrategyEngine:
|
||||
async def _settle_residuals(self) -> None:
|
||||
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:
|
||||
"""若持仓已到期则强制全平。返回是否触发到期平仓。"""
|
||||
await self._settle_residuals()
|
||||
@@ -613,6 +649,7 @@ class StrategyEngine:
|
||||
async def _tick_manage_positions(self) -> None:
|
||||
"""有仓时的盯盘:残留结算 / 半仓修复 / 目标平 / 到期平。不新开仓。"""
|
||||
await self._settle_residuals()
|
||||
await self._maybe_close_residuals_by_premium()
|
||||
|
||||
s = get_settings()
|
||||
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)。
|
||||
- 动作:
|
||||
1. **只市价平掉永续**,兑现净利里永续那一截;
|
||||
2. 本张期权 **不再盯盘、不再参与平仓扫描**,归档为「到期残留」;
|
||||
3. 因期权 **逐仓**,残留 **不占用活跃持仓**,**不挡住下一组开仓**;
|
||||
4. 下一组开平仓 **只扫当前活跃组期权**,不扫描历史残留腿;
|
||||
5. 残留期权到到期日再按 **内在价值** 单独结算(多半接近 0);
|
||||
6. 页面:**活跃持仓区变空**;归档腿出现在「残留期权(待到期)」;下方期权盘口 **切回新 ATM**(见 4.6)。
|
||||
2. 本张期权归档为「残留」:不占用活跃持仓、**不挡住下一组开仓**;下一组只扫当前活跃组期权;
|
||||
3. **中途回收(可配置)**:默认每 **5 分钟**巡检 pending 残留;当 **买一权利金 ≥ 初始权利金 × 比例**(默认 **20%**,系统设置「残留期权回收」可改)且通过买一流动性闸门时,**市价卖掉**该残留并结清;
|
||||
4. 未达比例或闸门不过 → 继续等到下次巡检,或到期按 **内在价值** 结算(多半接近 0);
|
||||
5. 页面:**活跃持仓区变空**;归档腿出现在「残留期权(待到期)」;下方期权盘口 **切回新 ATM**(见 4.6)。
|
||||
|
||||
残留到期结算口径:
|
||||
|
||||
@@ -213,7 +212,7 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10
|
||||
- 买一相对标记偏差默认 ≤ **30%**(`close_bid_mark_max_pct`);
|
||||
- 不满足 → `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) |
|
||||
| `target_perp_only` | 目标平仓 · 只平永续,期权归档到期(4.1.B) |
|
||||
| `residual_premium_close` | 残留期权 · 权利金回升达标后中途平 |
|
||||
| `expiry` | 到期结算(活跃组或残留期权) |
|
||||
| `emergency` | 界面紧急全平 |
|
||||
| `manual` | 手动平仓 |
|
||||
@@ -237,7 +237,8 @@ k = floor(budget / (2A + I×fee_rate×3) × 10) / 10
|
||||
└─ 否 → 持有直到到期 → 内在价值结算(+ 若有永续则平永续)
|
||||
|
||||
残留期权(已归档)
|
||||
└─ 仅到期结算;不参与盯盘、不参与下一组平仓扫描
|
||||
├─ 周期性:买一权利金 / 初始 ≥ 设置% 且流动性过 → 市价卖出结清
|
||||
└─ 否则到期内在价值结算;不挡下一组开仓
|
||||
```
|
||||
|
||||
### 4.6 行情监控与页面展示
|
||||
|
||||
@@ -339,6 +339,8 @@ export type StrategySettings = {
|
||||
fixed_direction_enabled?: boolean;
|
||||
fixed_perp_side?: "long" | "short";
|
||||
close_bid_mark_max_pct?: number;
|
||||
residual_min_premium_pct?: number;
|
||||
residual_close_check_sec?: number;
|
||||
perp_qty_eth?: number;
|
||||
option_qty_eth?: number;
|
||||
show_manual_trade_buttons?: boolean;
|
||||
|
||||
@@ -27,6 +27,7 @@ const CLOSE_REASON_ZH: Record<string, string> = {
|
||||
fixed_usdt: "固定净盈利达标·双腿全平",
|
||||
premium_multiple: "权利金倍数达标·双腿全平",
|
||||
target_perp_only: "净盈利达标·只平永续(期权归档)",
|
||||
residual_premium_close: "残留期权·权利金回收中途平",
|
||||
expiry: "到期结算",
|
||||
emergency: "紧急全平",
|
||||
manual: "手动平仓",
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function SettingsPage() {
|
||||
const [fixedDirOn, setFixedDirOn] = useState(false);
|
||||
const [fixedPerpSide, setFixedPerpSide] = useState<"long" | "short">("long");
|
||||
const [closeDevPct, setCloseDevPct] = useState(30);
|
||||
const [residualMinPremPct, setResidualMinPremPct] = useState(20);
|
||||
const [perpQty, setPerpQty] = useState(1);
|
||||
const [optQty, setOptQty] = useState(2);
|
||||
const [showManualTrade, setShowManualTrade] = useState(false);
|
||||
@@ -196,6 +197,7 @@ export default function SettingsPage() {
|
||||
setFixedDirOn(s.fixed_direction_enabled === true);
|
||||
setFixedPerpSide(s.fixed_perp_side === "short" ? "short" : "long");
|
||||
setCloseDevPct(s.close_bid_mark_max_pct ?? 30);
|
||||
setResidualMinPremPct(s.residual_min_premium_pct ?? 20);
|
||||
setPerpQty(s.perp_qty_eth ?? 1);
|
||||
setOptQty(s.option_qty_eth ?? 2);
|
||||
setShowManualTrade(s.show_manual_trade_buttons === true);
|
||||
@@ -362,6 +364,7 @@ export default function SettingsPage() {
|
||||
fixed_direction_enabled: fixedDirOn,
|
||||
fixed_perp_side: fixedPerpSide,
|
||||
close_bid_mark_max_pct: closeDevPct,
|
||||
residual_min_premium_pct: residualMinPremPct,
|
||||
show_manual_trade_buttons: showManualTrade,
|
||||
sizing_mode: sizingMode,
|
||||
risk_leverage_basis: riskLeverageBasis,
|
||||
@@ -1160,6 +1163,23 @@ export default function SettingsPage() {
|
||||
onChange={(e) => setCloseDevPct(Number(e.target.value))}
|
||||
/>
|
||||
</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>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -1297,10 +1317,16 @@ export default function SettingsPage() {
|
||||
</>
|
||||
) : null}
|
||||
{stratSub === "exit" ? (
|
||||
<li>
|
||||
平仓买一/标记最大偏差:主要用于 SIM
|
||||
流动性闸门;LIVE 以交易所能否成交为准。
|
||||
</li>
|
||||
<>
|
||||
<li>
|
||||
平仓买一/标记最大偏差:主要用于 SIM
|
||||
流动性闸门;LIVE 以交易所能否成交为准。
|
||||
</li>
|
||||
<li>
|
||||
残留期权回收比例:只平永续后,当买一权利金回升到初始权利金的该比例及以上时,才尝试中途卖掉归档期权;默认
|
||||
20%。未达标则等到期按内在价值结算。
|
||||
</li>
|
||||
</>
|
||||
) : null}
|
||||
{stratSub === "pace" ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user