Add dual exit modes: fixed USDT or premium multiple.
Net PnL (after estimated close fees) drives auto close; Plan/Settings expose the choice. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,6 +36,9 @@ LEVERAGE=3
|
|||||||
MIN_OPTION_HOURS=12
|
MIN_OPTION_HOURS=12
|
||||||
MIN_OPTION_LEVERAGE=100
|
MIN_OPTION_LEVERAGE=100
|
||||||
EXIT_MOVE_PCT=2
|
EXIT_MOVE_PCT=2
|
||||||
|
EXIT_MODE=fixed_usdt
|
||||||
|
NET_PROFIT_TARGET=15
|
||||||
|
PREMIUM_EXIT_MULTIPLE=1
|
||||||
CLOSE_BID_MARK_MAX_PCT=30
|
CLOSE_BID_MARK_MAX_PCT=30
|
||||||
REST_SECONDS=300
|
REST_SECONDS=300
|
||||||
PERP_QTY_ETH=1
|
PERP_QTY_ETH=1
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
|||||||
KEYS = (
|
KEYS = (
|
||||||
"fee_rate",
|
"fee_rate",
|
||||||
"exit_move_pct",
|
"exit_move_pct",
|
||||||
|
"exit_mode",
|
||||||
|
"net_profit_target",
|
||||||
|
"premium_exit_multiple",
|
||||||
"rest_seconds",
|
"rest_seconds",
|
||||||
"initial_equity",
|
"initial_equity",
|
||||||
"leverage",
|
"leverage",
|
||||||
@@ -29,6 +32,9 @@ KEYS = (
|
|||||||
class StrategySettingsBody(BaseModel):
|
class StrategySettingsBody(BaseModel):
|
||||||
fee_rate: float | None = Field(default=None, ge=0, le=0.05)
|
fee_rate: float | None = Field(default=None, ge=0, le=0.05)
|
||||||
exit_move_pct: float | None = Field(default=None, ge=0.1, le=50)
|
exit_move_pct: float | None = Field(default=None, ge=0.1, le=50)
|
||||||
|
exit_mode: str | None = Field(default=None, pattern="^(fixed_usdt|premium_multiple)$")
|
||||||
|
net_profit_target: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||||
|
premium_exit_multiple: float | None = Field(default=None, ge=0.1, le=100)
|
||||||
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
rest_seconds: int | None = Field(default=None, ge=0, le=3600)
|
||||||
initial_equity: float | None = Field(default=None, ge=1000)
|
initial_equity: float | None = Field(default=None, ge=1000)
|
||||||
leverage: float | None = Field(default=None, ge=1, le=125)
|
leverage: float | None = Field(default=None, ge=1, le=125)
|
||||||
@@ -42,11 +48,23 @@ class StrategySettingsBody(BaseModel):
|
|||||||
def _read_settings() -> dict:
|
def _read_settings() -> dict:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
mode = str(db.get_setting("exit_mode", s.exit_mode) or s.exit_mode)
|
||||||
|
if mode not in ("fixed_usdt", "premium_multiple"):
|
||||||
|
mode = "fixed_usdt"
|
||||||
return {
|
return {
|
||||||
"fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate),
|
"fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate),
|
||||||
"exit_move_pct": float(
|
"exit_move_pct": float(
|
||||||
db.get_setting("exit_move_pct", str(s.exit_move_pct)) or s.exit_move_pct
|
db.get_setting("exit_move_pct", str(s.exit_move_pct)) or s.exit_move_pct
|
||||||
),
|
),
|
||||||
|
"exit_mode": mode,
|
||||||
|
"net_profit_target": float(
|
||||||
|
db.get_setting("net_profit_target", str(s.net_profit_target))
|
||||||
|
or s.net_profit_target
|
||||||
|
),
|
||||||
|
"premium_exit_multiple": float(
|
||||||
|
db.get_setting("premium_exit_multiple", str(s.premium_exit_multiple))
|
||||||
|
or s.premium_exit_multiple
|
||||||
|
),
|
||||||
"rest_seconds": int(
|
"rest_seconds": int(
|
||||||
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -42,8 +42,11 @@ class Settings(BaseSettings):
|
|||||||
max_rounds: int = 3 # 已不再强管控,仅兼容旧字段
|
max_rounds: int = 3 # 已不再强管控,仅兼容旧字段
|
||||||
open_hhmm: str = "16:00" # 已废弃开仓窗
|
open_hhmm: str = "16:00" # 已废弃开仓窗
|
||||||
stop_open_hhmm: str = "08:00" # 已废弃开仓窗
|
stop_open_hhmm: str = "08:00" # 已废弃开仓窗
|
||||||
exit_move_points: float = 30.0 # 旧字段,改用 exit_move_pct
|
exit_move_points: float = 30.0 # 旧字段,已废弃
|
||||||
exit_move_pct: float = 2.0 # 相对开仓指数波动 % 全平
|
exit_move_pct: float = 2.0 # 旧字段,已废弃(改用净盈利出场)
|
||||||
|
exit_mode: str = "fixed_usdt" # fixed_usdt | premium_multiple
|
||||||
|
net_profit_target: float = 15.0 # fixed_usdt:净盈利 ≥ 该值(USDT)
|
||||||
|
premium_exit_multiple: float = 1.0 # premium_multiple:净盈利 ≥ 权利金×倍数
|
||||||
rest_seconds: int = 300
|
rest_seconds: int = 300
|
||||||
leverage: float = 3.0 # 永续杠杆
|
leverage: float = 3.0 # 永续杠杆
|
||||||
min_option_hours: float = 12.0 # 期权最小剩余小时
|
min_option_hours: float = 12.0 # 期权最小剩余小时
|
||||||
|
|||||||
@@ -150,6 +150,9 @@ class Database:
|
|||||||
"initial_equity": str(s.initial_equity),
|
"initial_equity": str(s.initial_equity),
|
||||||
"exit_move_points": str(s.exit_move_points),
|
"exit_move_points": str(s.exit_move_points),
|
||||||
"exit_move_pct": str(s.exit_move_pct),
|
"exit_move_pct": str(s.exit_move_pct),
|
||||||
|
"exit_mode": str(s.exit_mode),
|
||||||
|
"net_profit_target": str(s.net_profit_target),
|
||||||
|
"premium_exit_multiple": str(s.premium_exit_multiple),
|
||||||
"rest_seconds": str(s.rest_seconds),
|
"rest_seconds": str(s.rest_seconds),
|
||||||
"max_rounds": str(s.max_rounds),
|
"max_rounds": str(s.max_rounds),
|
||||||
"leverage": str(s.leverage),
|
"leverage": str(s.leverage),
|
||||||
|
|||||||
@@ -59,3 +59,9 @@ class Ledger:
|
|||||||
|
|
||||||
def get_setting_int(self, key: str, default: int) -> int:
|
def get_setting_int(self, key: str, default: int) -> int:
|
||||||
return int(self.get_setting_float(key, float(default)))
|
return int(self.get_setting_float(key, float(default)))
|
||||||
|
|
||||||
|
def get_setting_str(self, key: str, default: str) -> str:
|
||||||
|
v = self.db.get_setting(key)
|
||||||
|
if v is None or v == "":
|
||||||
|
return default
|
||||||
|
return str(v)
|
||||||
|
|||||||
+53
-17
@@ -412,6 +412,8 @@ class Matcher:
|
|||||||
"has_position": False,
|
"has_position": False,
|
||||||
"perp_upl": 0.0,
|
"perp_upl": 0.0,
|
||||||
"option_upl": 0.0,
|
"option_upl": 0.0,
|
||||||
|
"net_pnl": 0.0,
|
||||||
|
"est_close_fees": 0.0,
|
||||||
"index_px": None,
|
"index_px": None,
|
||||||
"move_points": 0.0,
|
"move_points": 0.0,
|
||||||
"move_pct": 0.0,
|
"move_pct": 0.0,
|
||||||
@@ -420,21 +422,40 @@ class Matcher:
|
|||||||
sess = get_session()
|
sess = get_session()
|
||||||
snap = sess.snapshot()
|
snap = sess.snapshot()
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
fee_rate = self._fee_rate()
|
||||||
index_px = snap.index_px
|
index_px = snap.index_px
|
||||||
if index_px is None and snap.perp:
|
if index_px is None and snap.perp:
|
||||||
index_px = snap.perp.mark_px
|
index_px = snap.perp.mark_px
|
||||||
perp_side = str(pos["perp_side"])
|
perp_side = str(pos["perp_side"])
|
||||||
perp_entry = float(pos["perp_entry_px"])
|
perp_entry = float(pos["perp_entry_px"])
|
||||||
perp_qty = float(pos["perp_qty_eth"])
|
perp_qty = float(pos["perp_qty_eth"])
|
||||||
mark = None
|
opt_qty = float(pos["option_qty_eth"] or 0)
|
||||||
if snap.perp:
|
opt_entry = float(pos["option_entry_px"] or 0)
|
||||||
# 浮盈用对手方可平价粗估
|
|
||||||
if perp_side == "long":
|
# 与平仓一致:用对手价估算可平盈亏 + 手续费
|
||||||
mark = snap.perp.bid
|
|
||||||
else:
|
|
||||||
mark = snap.perp.ask
|
|
||||||
mark = mark or snap.perp.mark_px
|
|
||||||
perp_upl = 0.0
|
perp_upl = 0.0
|
||||||
|
est_perp_close_fee = 0.0
|
||||||
|
mark = None
|
||||||
|
if snap.perp and snap.perp.bid is not None and snap.perp.ask is not None:
|
||||||
|
pf = perp_fill(
|
||||||
|
side=perp_side,
|
||||||
|
action="close",
|
||||||
|
bid=float(snap.perp.bid),
|
||||||
|
ask=float(snap.perp.ask),
|
||||||
|
qty_eth=perp_qty,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
)
|
||||||
|
if perp_side == "long":
|
||||||
|
perp_upl = (pf.fill_px - perp_entry) * perp_qty
|
||||||
|
else:
|
||||||
|
perp_upl = (perp_entry - pf.fill_px) * perp_qty
|
||||||
|
est_perp_close_fee = pf.fee
|
||||||
|
mark = pf.fill_px
|
||||||
|
elif snap.perp:
|
||||||
|
if perp_side == "long":
|
||||||
|
mark = snap.perp.bid or snap.perp.mark_px
|
||||||
|
else:
|
||||||
|
mark = snap.perp.ask or snap.perp.mark_px
|
||||||
if mark is not None:
|
if mark is not None:
|
||||||
if perp_side == "long":
|
if perp_side == "long":
|
||||||
perp_upl = (float(mark) - perp_entry) * perp_qty
|
perp_upl = (float(mark) - perp_entry) * perp_qty
|
||||||
@@ -442,19 +463,32 @@ class Matcher:
|
|||||||
perp_upl = (perp_entry - float(mark)) * perp_qty
|
perp_upl = (perp_entry - float(mark)) * perp_qty
|
||||||
|
|
||||||
option_side = str(pos["option_side"])
|
option_side = str(pos["option_side"])
|
||||||
# 优先用持仓合约盘口,避免 ATM 切换后盯错合约
|
|
||||||
opt_inst = str(pos.get("option_inst_id") or "")
|
opt_inst = str(pos.get("option_inst_id") or "")
|
||||||
oq = get_exchange().quote(opt_inst) if opt_inst else None
|
oq = get_exchange().quote(opt_inst) if opt_inst else None
|
||||||
if oq is None:
|
if oq is None:
|
||||||
oq = snap.call if option_side == "call" else snap.put
|
oq = snap.call if option_side == "call" else snap.put
|
||||||
opt_mark = None
|
|
||||||
if oq:
|
|
||||||
opt_mark = oq.bid or oq.mark_px
|
|
||||||
option_upl = 0.0
|
option_upl = 0.0
|
||||||
if opt_mark is not None:
|
est_opt_close_fee = 0.0
|
||||||
option_upl = (float(opt_mark) - float(pos["option_entry_px"])) * float(
|
opt_mark = None
|
||||||
pos["option_qty_eth"]
|
if oq and oq.bid is not None:
|
||||||
|
of = option_fill(
|
||||||
|
action="close",
|
||||||
|
bid=float(oq.bid),
|
||||||
|
ask=float(oq.ask or oq.bid),
|
||||||
|
qty_eth=opt_qty,
|
||||||
|
fee_rate=fee_rate,
|
||||||
)
|
)
|
||||||
|
option_upl = (of.fill_px - opt_entry) * opt_qty
|
||||||
|
est_opt_close_fee = of.fee
|
||||||
|
opt_mark = of.fill_px
|
||||||
|
elif oq:
|
||||||
|
opt_mark = oq.bid or oq.mark_px
|
||||||
|
if opt_mark is not None:
|
||||||
|
option_upl = (float(opt_mark) - opt_entry) * opt_qty
|
||||||
|
|
||||||
|
est_close_fees = est_perp_close_fee + est_opt_close_fee
|
||||||
|
# 净盈利口径与平仓结算一致:双腿盈亏 − 预估平仓手续费
|
||||||
|
net_pnl = perp_upl + option_upl - est_close_fees
|
||||||
|
|
||||||
entry_idx = float(pos["entry_index_px"] or 0)
|
entry_idx = float(pos["entry_index_px"] or 0)
|
||||||
move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0
|
move = abs(float(index_px) - entry_idx) if index_px is not None and entry_idx else 0.0
|
||||||
@@ -492,14 +526,16 @@ class Matcher:
|
|||||||
"perp_margin": margin,
|
"perp_margin": margin,
|
||||||
"leverage": leverage,
|
"leverage": leverage,
|
||||||
"option_inst_id": pos.get("option_inst_id"),
|
"option_inst_id": pos.get("option_inst_id"),
|
||||||
"option_entry_px": float(pos["option_entry_px"] or 0),
|
"option_entry_px": opt_entry,
|
||||||
"option_qty_eth": float(pos["option_qty_eth"] or 0),
|
"option_qty_eth": opt_qty,
|
||||||
"option_qty_contracts": float(pos["option_qty_contracts"] or 0),
|
"option_qty_contracts": float(pos["option_qty_contracts"] or 0),
|
||||||
"option_mark_px": float(opt_mark) if opt_mark is not None else None,
|
"option_mark_px": float(opt_mark) if opt_mark is not None else None,
|
||||||
"strike": strike,
|
"strike": strike,
|
||||||
"expiry_ymd": expiry_ymd,
|
"expiry_ymd": expiry_ymd,
|
||||||
"perp_upl": perp_upl,
|
"perp_upl": perp_upl,
|
||||||
"option_upl": option_upl,
|
"option_upl": option_upl,
|
||||||
|
"est_close_fees": est_close_fees,
|
||||||
|
"net_pnl": net_pnl,
|
||||||
"index_px": index_px,
|
"index_px": index_px,
|
||||||
"entry_index_px": entry_idx,
|
"entry_index_px": entry_idx,
|
||||||
"move_points": move,
|
"move_points": move,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from ..models.db import get_db
|
|||||||
from ..sim.ledger import Ledger
|
from ..sim.ledger import Ledger
|
||||||
from ..sim.matcher import Matcher
|
from ..sim.matcher import Matcher
|
||||||
from .clock import window_key
|
from .clock import window_key
|
||||||
from .exits import check_exits
|
from .exits import check_exits, resolve_exit_target
|
||||||
from .group import next_group_id
|
from .group import next_group_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -32,7 +32,19 @@ class StrategyEngine:
|
|||||||
assert row is not None
|
assert row is not None
|
||||||
upl = self.matcher.unrealized()
|
upl = self.matcher.unrealized()
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
|
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
|
||||||
|
net_target = self.ledger.get_setting_float(
|
||||||
|
"net_profit_target", s.net_profit_target
|
||||||
|
)
|
||||||
|
prem_mult = self.ledger.get_setting_float(
|
||||||
|
"premium_exit_multiple", s.premium_exit_multiple
|
||||||
|
)
|
||||||
|
exit_amt, _ = resolve_exit_target(
|
||||||
|
exit_mode=exit_mode,
|
||||||
|
net_profit_target=net_target,
|
||||||
|
premium_exit_multiple=prem_mult,
|
||||||
|
initial_premium=float(upl.get("initial_premium") or 0),
|
||||||
|
)
|
||||||
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
|
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
|
||||||
leverage = self.ledger.get_setting_float("leverage", s.leverage)
|
leverage = self.ledger.get_setting_float("leverage", s.leverage)
|
||||||
min_hours = self.ledger.get_setting_float("min_option_hours", s.min_option_hours)
|
min_hours = self.ledger.get_setting_float("min_option_hours", s.min_option_hours)
|
||||||
@@ -55,7 +67,10 @@ class StrategyEngine:
|
|||||||
"rest_until_ms": rest_until,
|
"rest_until_ms": rest_until,
|
||||||
"rest_left_sec": rest_left,
|
"rest_left_sec": rest_left,
|
||||||
"rest_seconds": rest_sec,
|
"rest_seconds": rest_sec,
|
||||||
"exit_move_pct": exit_pct,
|
"exit_mode": exit_mode,
|
||||||
|
"net_profit_target": net_target,
|
||||||
|
"premium_exit_multiple": prem_mult,
|
||||||
|
"exit_target_usdt": exit_amt,
|
||||||
"leverage": leverage,
|
"leverage": leverage,
|
||||||
"min_option_hours": min_hours,
|
"min_option_hours": min_hours,
|
||||||
"min_option_leverage": min_opt_lev,
|
"min_option_leverage": min_opt_lev,
|
||||||
@@ -153,17 +168,24 @@ class StrategyEngine:
|
|||||||
|
|
||||||
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
|
||||||
assert st is not None
|
assert st is not None
|
||||||
exit_pct = self.ledger.get_setting_float("exit_move_pct", s.exit_move_pct)
|
exit_mode = self.ledger.get_setting_str("exit_mode", s.exit_mode)
|
||||||
|
net_target = self.ledger.get_setting_float(
|
||||||
|
"net_profit_target", s.net_profit_target
|
||||||
|
)
|
||||||
|
prem_mult = self.ledger.get_setting_float(
|
||||||
|
"premium_exit_multiple", s.premium_exit_multiple
|
||||||
|
)
|
||||||
pos = self.matcher.current_position()
|
pos = self.matcher.current_position()
|
||||||
|
|
||||||
# 有未平仓:只盯平仓,绝不开下一组
|
# 有未平仓:只盯平仓,绝不开下一组
|
||||||
if pos.get("status") == "open":
|
if pos.get("status") == "open":
|
||||||
upl = self.matcher.unrealized()
|
upl = self.matcher.unrealized()
|
||||||
decision = check_exits(
|
decision = check_exits(
|
||||||
perp_upl=float(upl["perp_upl"]),
|
net_pnl=float(upl.get("net_pnl") or 0),
|
||||||
initial_premium=float(upl["initial_premium"] or 0),
|
exit_mode=exit_mode,
|
||||||
move_pct=float(upl.get("move_pct") or 0),
|
net_profit_target=net_target,
|
||||||
exit_move_pct=exit_pct,
|
premium_exit_multiple=prem_mult,
|
||||||
|
initial_premium=float(upl.get("initial_premium") or 0),
|
||||||
)
|
)
|
||||||
pending_close = st["phase"] in ("liquidity_wait", "closing")
|
pending_close = st["phase"] in ("liquidity_wait", "closing")
|
||||||
if decision.should_close or pending_close:
|
if decision.should_close or pending_close:
|
||||||
|
|||||||
@@ -2,22 +2,48 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
EXIT_MODE_FIXED = "fixed_usdt"
|
||||||
|
EXIT_MODE_PREMIUM = "premium_multiple"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class ExitDecision:
|
class ExitDecision:
|
||||||
should_close: bool
|
should_close: bool
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
target: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_exit_target(
|
||||||
|
*,
|
||||||
|
exit_mode: str,
|
||||||
|
net_profit_target: float,
|
||||||
|
premium_exit_multiple: float,
|
||||||
|
initial_premium: float,
|
||||||
|
) -> tuple[float, str]:
|
||||||
|
"""返回 (出场目标金额 USDT, 模式标记)。"""
|
||||||
|
mode = (exit_mode or EXIT_MODE_FIXED).strip().lower()
|
||||||
|
if mode == EXIT_MODE_PREMIUM:
|
||||||
|
mult = max(0.0, float(premium_exit_multiple))
|
||||||
|
return float(initial_premium) * mult, EXIT_MODE_PREMIUM
|
||||||
|
return float(net_profit_target), EXIT_MODE_FIXED
|
||||||
|
|
||||||
|
|
||||||
def check_exits(
|
def check_exits(
|
||||||
*,
|
*,
|
||||||
perp_upl: float,
|
net_pnl: float,
|
||||||
|
exit_mode: str,
|
||||||
|
net_profit_target: float,
|
||||||
|
premium_exit_multiple: float,
|
||||||
initial_premium: float,
|
initial_premium: float,
|
||||||
move_pct: float,
|
|
||||||
exit_move_pct: float,
|
|
||||||
) -> ExitDecision:
|
) -> ExitDecision:
|
||||||
if initial_premium > 0 and perp_upl + 1e-9 >= initial_premium:
|
"""净盈利(预估全平后)≥ 所选模式目标则全平。"""
|
||||||
return ExitDecision(True, "premium_cover")
|
target, mode = resolve_exit_target(
|
||||||
if exit_move_pct > 0 and move_pct + 1e-9 >= exit_move_pct:
|
exit_mode=exit_mode,
|
||||||
return ExitDecision(True, "move_pct")
|
net_profit_target=net_profit_target,
|
||||||
return ExitDecision(False, "")
|
premium_exit_multiple=premium_exit_multiple,
|
||||||
|
initial_premium=initial_premium,
|
||||||
|
)
|
||||||
|
if target > 0 and net_pnl + 1e-9 >= target:
|
||||||
|
reason = "premium_multiple" if mode == EXIT_MODE_PREMIUM else "fixed_usdt"
|
||||||
|
return ExitDecision(True, reason, target)
|
||||||
|
return ExitDecision(False, "", target)
|
||||||
|
|||||||
@@ -28,19 +28,47 @@ def test_signal_equal() -> None:
|
|||||||
assert decide(10.0, 10.0) is None
|
assert decide(10.0, 10.0) is None
|
||||||
|
|
||||||
|
|
||||||
def test_exit_premium_and_move_pct() -> None:
|
def test_exit_fixed_and_premium_multiple() -> None:
|
||||||
|
fixed = check_exits(
|
||||||
|
net_pnl=15.0,
|
||||||
|
exit_mode="fixed_usdt",
|
||||||
|
net_profit_target=15,
|
||||||
|
premium_exit_multiple=1,
|
||||||
|
initial_premium=40,
|
||||||
|
)
|
||||||
|
assert fixed.reason == "fixed_usdt"
|
||||||
|
assert fixed.target == 15
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
check_exits(
|
check_exits(
|
||||||
perp_upl=50, initial_premium=40, move_pct=0.1, exit_move_pct=2
|
net_pnl=14.9,
|
||||||
).reason
|
exit_mode="fixed_usdt",
|
||||||
== "premium_cover"
|
net_profit_target=15,
|
||||||
|
premium_exit_multiple=1,
|
||||||
|
initial_premium=40,
|
||||||
|
).should_close
|
||||||
|
is False
|
||||||
)
|
)
|
||||||
assert (
|
|
||||||
check_exits(
|
prem = check_exits(
|
||||||
perp_upl=1, initial_premium=40, move_pct=2.0, exit_move_pct=2
|
net_pnl=40.0,
|
||||||
).reason
|
exit_mode="premium_multiple",
|
||||||
== "move_pct"
|
net_profit_target=15,
|
||||||
|
premium_exit_multiple=1,
|
||||||
|
initial_premium=40,
|
||||||
)
|
)
|
||||||
|
assert prem.reason == "premium_multiple"
|
||||||
|
assert prem.target == 40
|
||||||
|
|
||||||
|
half = check_exits(
|
||||||
|
net_pnl=20.0,
|
||||||
|
exit_mode="premium_multiple",
|
||||||
|
net_profit_target=15,
|
||||||
|
premium_exit_multiple=0.5,
|
||||||
|
initial_premium=40,
|
||||||
|
)
|
||||||
|
assert half.should_close is True
|
||||||
|
assert half.target == 20
|
||||||
|
|
||||||
|
|
||||||
def test_perp_pricing() -> None:
|
def test_perp_pricing() -> None:
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ export type PlanState = {
|
|||||||
window_key: string | null;
|
window_key: string | null;
|
||||||
rest_left_sec: number;
|
rest_left_sec: number;
|
||||||
rest_seconds: number;
|
rest_seconds: number;
|
||||||
exit_move_pct: number;
|
exit_move_pct?: number;
|
||||||
|
exit_mode: "fixed_usdt" | "premium_multiple";
|
||||||
|
net_profit_target: number;
|
||||||
|
premium_exit_multiple: number;
|
||||||
|
exit_target_usdt: number;
|
||||||
leverage: number;
|
leverage: number;
|
||||||
min_option_hours: number;
|
min_option_hours: number;
|
||||||
min_option_leverage: number;
|
min_option_leverage: number;
|
||||||
@@ -147,6 +151,8 @@ export type PlanState = {
|
|||||||
expiry_ymd?: string | null;
|
expiry_ymd?: string | null;
|
||||||
perp_upl?: number;
|
perp_upl?: number;
|
||||||
option_upl?: number;
|
option_upl?: number;
|
||||||
|
est_close_fees?: number;
|
||||||
|
net_pnl?: number;
|
||||||
index_px?: number | null;
|
index_px?: number | null;
|
||||||
entry_index_px?: number;
|
entry_index_px?: number;
|
||||||
move_points?: number;
|
move_points?: number;
|
||||||
@@ -159,7 +165,10 @@ export type PlanState = {
|
|||||||
|
|
||||||
export type StrategySettings = {
|
export type StrategySettings = {
|
||||||
fee_rate: number;
|
fee_rate: number;
|
||||||
exit_move_pct: number;
|
exit_move_pct?: number;
|
||||||
|
exit_mode: "fixed_usdt" | "premium_multiple";
|
||||||
|
net_profit_target: number;
|
||||||
|
premium_exit_multiple: number;
|
||||||
rest_seconds: number;
|
rest_seconds: number;
|
||||||
initial_equity: number;
|
initial_equity: number;
|
||||||
leverage: number;
|
leverage: number;
|
||||||
|
|||||||
@@ -75,15 +75,20 @@ export default function PlanPage() {
|
|||||||
|
|
||||||
const pos = plan?.position;
|
const pos = plan?.position;
|
||||||
const open = !!pos?.has_position;
|
const open = !!pos?.has_position;
|
||||||
const exitPct = plan?.exit_move_pct ?? 2;
|
const exitMode = plan?.exit_mode ?? "fixed_usdt";
|
||||||
const movePct = pos?.move_pct ?? 0;
|
const exitTarget = plan?.exit_target_usdt ?? plan?.net_profit_target ?? 15;
|
||||||
|
const netPnl = pos?.net_pnl ?? 0;
|
||||||
|
const exitRuleLabel =
|
||||||
|
exitMode === "premium_multiple"
|
||||||
|
? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`
|
||||||
|
: `固定 ${fmt(plan?.net_profit_target ?? 15)} U`;
|
||||||
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
|
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
<h2 style={{ marginTop: 0 }}>自动对冲计划</h2>
|
||||||
<p style={{ color: "var(--muted)", marginTop: -8 }}>
|
<p style={{ color: "var(--muted)", marginTop: -8 }}>
|
||||||
SIM 本地撮合 · 期权只买 · 剩余时长选到期 · 波动按比例全平
|
SIM 本地撮合 · 期权只买 · 剩余时长选到期 · 净盈利达标全平
|
||||||
</p>
|
</p>
|
||||||
{err ? <div className="err">{err}</div> : null}
|
{err ? <div className="err">{err}</div> : null}
|
||||||
|
|
||||||
@@ -174,17 +179,25 @@ export default function PlanPage() {
|
|||||||
<span className="mono">{biasTag}</span>
|
<span className="mono">{biasTag}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>永续浮盈 / 距覆盖</span>
|
<span>出场规则</span>
|
||||||
|
<span className="mono">{exitRuleLabel}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kv">
|
||||||
|
<span>净盈利 / 目标</span>
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
<span className={pnlClass(pos?.perp_upl)}>{fmt(pos?.perp_upl)}</span>
|
<span className={pnlClass(open ? netPnl : null)}>
|
||||||
|
{open ? fmt(netPnl) : "—"}
|
||||||
|
</span>
|
||||||
{" / "}
|
{" / "}
|
||||||
{fmt(pos?.premium_gap)}
|
{open || exitMode === "fixed_usdt" ? fmt(exitTarget) : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>波动进度</span>
|
<span>永续浮盈 / 期权浮盈</span>
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
{fmt(movePct, 2)}% / {fmt(exitPct, 2)}%
|
<span className={pnlClass(pos?.perp_upl)}>{fmt(pos?.perp_upl)}</span>
|
||||||
|
{" / "}
|
||||||
|
<span className={pnlClass(pos?.option_upl)}>{fmt(pos?.option_upl)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{plan?.last_error ? (
|
{plan?.last_error ? (
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export default function SettingsPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [fee, setFee] = useState(0.0005);
|
const [fee, setFee] = useState(0.0005);
|
||||||
const [exitPct, setExitPct] = useState(2);
|
const [exitMode, setExitMode] = useState<"fixed_usdt" | "premium_multiple">(
|
||||||
|
"fixed_usdt",
|
||||||
|
);
|
||||||
|
const [netTarget, setNetTarget] = useState(15);
|
||||||
|
const [premMult, setPremMult] = useState(1);
|
||||||
const [rest, setRest] = useState(300);
|
const [rest, setRest] = useState(300);
|
||||||
const [leverage, setLeverage] = useState(3);
|
const [leverage, setLeverage] = useState(3);
|
||||||
const [minHours, setMinHours] = useState(12);
|
const [minHours, setMinHours] = useState(12);
|
||||||
@@ -35,7 +39,9 @@ export default function SettingsPage() {
|
|||||||
apiFetch<StrategySettings>("/api/settings/strategy")
|
apiFetch<StrategySettings>("/api/settings/strategy")
|
||||||
.then((s) => {
|
.then((s) => {
|
||||||
setFee(s.fee_rate);
|
setFee(s.fee_rate);
|
||||||
setExitPct(s.exit_move_pct ?? 2);
|
setExitMode(s.exit_mode === "premium_multiple" ? "premium_multiple" : "fixed_usdt");
|
||||||
|
setNetTarget(s.net_profit_target ?? 15);
|
||||||
|
setPremMult(s.premium_exit_multiple ?? 1);
|
||||||
setRest(s.rest_seconds);
|
setRest(s.rest_seconds);
|
||||||
setLeverage(s.leverage ?? 3);
|
setLeverage(s.leverage ?? 3);
|
||||||
setMinHours(s.min_option_hours ?? 12);
|
setMinHours(s.min_option_hours ?? 12);
|
||||||
@@ -87,7 +93,9 @@ export default function SettingsPage() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
fee_rate: fee,
|
fee_rate: fee,
|
||||||
exit_move_pct: exitPct,
|
exit_mode: exitMode,
|
||||||
|
net_profit_target: netTarget,
|
||||||
|
premium_exit_multiple: premMult,
|
||||||
rest_seconds: rest,
|
rest_seconds: rest,
|
||||||
leverage,
|
leverage,
|
||||||
min_option_hours: minHours,
|
min_option_hours: minHours,
|
||||||
@@ -126,7 +134,7 @@ export default function SettingsPage() {
|
|||||||
{tab === "strategy" ? (
|
{tab === "strategy" ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||||||
无开仓时间窗;期权按剩余时长选到期 → 平值 → 校验杠杆;波动按百分比全平。
|
无开仓时间窗;期权按剩余时长选到期 → 平值 → 校验杠杆;出场可选固定金额或权利金倍数。
|
||||||
</p>
|
</p>
|
||||||
{stratOk ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{stratOk}</div> : null}
|
{stratOk ? <div style={{ color: "var(--up)", marginBottom: 12 }}>{stratOk}</div> : null}
|
||||||
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
|
{err && tab === "strategy" ? <div className="err">{err}</div> : null}
|
||||||
@@ -171,17 +179,50 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="exit">波动出场比例(%)</label>
|
<label htmlFor="exitMode">出场规则</label>
|
||||||
|
<select
|
||||||
|
id="exitMode"
|
||||||
|
className="mono"
|
||||||
|
value={exitMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setExitMode(
|
||||||
|
e.target.value === "premium_multiple"
|
||||||
|
? "premium_multiple"
|
||||||
|
: "fixed_usdt",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="fixed_usdt">固定净盈利(USDT)</option>
|
||||||
|
<option value="premium_multiple">权利金倍数</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{exitMode === "fixed_usdt" ? (
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="netTarget">净盈利出场目标(USDT)</label>
|
||||||
<input
|
<input
|
||||||
id="exit"
|
id="netTarget"
|
||||||
className="mono"
|
className="mono"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
value={exitPct}
|
value={netTarget}
|
||||||
onChange={(e) => setExitPct(Number(e.target.value))}
|
onChange={(e) => setNetTarget(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="premMult">权利金倍数(1 = 一倍权利金)</label>
|
||||||
|
<input
|
||||||
|
id="premMult"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0.1"
|
||||||
|
value={premMult}
|
||||||
|
onChange={(e) => setPremMult(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="closeDev">平仓买一/标记最大偏差(%)</label>
|
<label htmlFor="closeDev">平仓买一/标记最大偏差(%)</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -225,7 +225,8 @@ input {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input {
|
.field input,
|
||||||
|
.field select {
|
||||||
background: var(--input);
|
background: var(--input);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -234,7 +235,8 @@ input {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input:focus {
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
border-color: rgba(240, 185, 11, 0.55);
|
border-color: rgba(240, 185, 11, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user