diff --git a/.env.example b/.env.example index b65affa..738cace 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,9 @@ LEVERAGE=3 MIN_OPTION_HOURS=12 MIN_OPTION_LEVERAGE=100 EXIT_MOVE_PCT=2 +EXIT_MODE=fixed_usdt +NET_PROFIT_TARGET=15 +PREMIUM_EXIT_MULTIPLE=1 CLOSE_BID_MARK_MAX_PCT=30 REST_SECONDS=300 PERP_QTY_ETH=1 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 87570eb..c6d1fd3 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -15,6 +15,9 @@ router = APIRouter(prefix="/api/settings", tags=["settings"]) KEYS = ( "fee_rate", "exit_move_pct", + "exit_mode", + "net_profit_target", + "premium_exit_multiple", "rest_seconds", "initial_equity", "leverage", @@ -29,6 +32,9 @@ KEYS = ( class StrategySettingsBody(BaseModel): 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_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) initial_equity: float | None = Field(default=None, ge=1000) leverage: float | None = Field(default=None, ge=1, le=125) @@ -42,11 +48,23 @@ class StrategySettingsBody(BaseModel): def _read_settings() -> dict: db = get_db() 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 { "fee_rate": float(db.get_setting("fee_rate", str(s.fee_rate)) or s.fee_rate), "exit_move_pct": float( 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( float(db.get_setting("rest_seconds", str(s.rest_seconds)) or s.rest_seconds) ), diff --git a/backend/app/config.py b/backend/app/config.py index ab64219..2e6e878 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -42,8 +42,11 @@ class Settings(BaseSettings): max_rounds: int = 3 # 已不再强管控,仅兼容旧字段 open_hhmm: str = "16:00" # 已废弃开仓窗 stop_open_hhmm: str = "08:00" # 已废弃开仓窗 - exit_move_points: float = 30.0 # 旧字段,改用 exit_move_pct - exit_move_pct: float = 2.0 # 相对开仓指数波动 % 全平 + exit_move_points: float = 30.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 leverage: float = 3.0 # 永续杠杆 min_option_hours: float = 12.0 # 期权最小剩余小时 diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 9a7a10d..b8e49d5 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -150,6 +150,9 @@ class Database: "initial_equity": str(s.initial_equity), "exit_move_points": str(s.exit_move_points), "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), "max_rounds": str(s.max_rounds), "leverage": str(s.leverage), diff --git a/backend/app/sim/ledger.py b/backend/app/sim/ledger.py index 5d9c259..add5111 100644 --- a/backend/app/sim/ledger.py +++ b/backend/app/sim/ledger.py @@ -59,3 +59,9 @@ class Ledger: def get_setting_int(self, key: str, default: int) -> int: 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) diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index 4491e69..3650293 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -412,6 +412,8 @@ class Matcher: "has_position": False, "perp_upl": 0.0, "option_upl": 0.0, + "net_pnl": 0.0, + "est_close_fees": 0.0, "index_px": None, "move_points": 0.0, "move_pct": 0.0, @@ -420,41 +422,73 @@ class Matcher: sess = get_session() snap = sess.snapshot() s = get_settings() + fee_rate = self._fee_rate() index_px = snap.index_px if index_px is None and snap.perp: index_px = snap.perp.mark_px perp_side = str(pos["perp_side"]) perp_entry = float(pos["perp_entry_px"]) perp_qty = float(pos["perp_qty_eth"]) - mark = None - if snap.perp: - # 浮盈用对手方可平价粗估 - if perp_side == "long": - mark = snap.perp.bid - else: - mark = snap.perp.ask - mark = mark or snap.perp.mark_px + opt_qty = float(pos["option_qty_eth"] or 0) + opt_entry = float(pos["option_entry_px"] or 0) + + # 与平仓一致:用对手价估算可平盈亏 + 手续费 perp_upl = 0.0 - if mark is not None: + 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 = (float(mark) - perp_entry) * perp_qty + perp_upl = (pf.fill_px - perp_entry) * perp_qty else: - perp_upl = (perp_entry - float(mark)) * perp_qty + 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 perp_side == "long": + perp_upl = (float(mark) - perp_entry) * perp_qty + else: + perp_upl = (perp_entry - float(mark)) * perp_qty option_side = str(pos["option_side"]) - # 优先用持仓合约盘口,避免 ATM 切换后盯错合约 opt_inst = str(pos.get("option_inst_id") or "") oq = get_exchange().quote(opt_inst) if opt_inst else None if oq is None: 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 - if opt_mark is not None: - option_upl = (float(opt_mark) - float(pos["option_entry_px"])) * float( - pos["option_qty_eth"] + est_opt_close_fee = 0.0 + opt_mark = None + 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) 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, "leverage": leverage, "option_inst_id": pos.get("option_inst_id"), - "option_entry_px": float(pos["option_entry_px"] or 0), - "option_qty_eth": float(pos["option_qty_eth"] or 0), + "option_entry_px": opt_entry, + "option_qty_eth": opt_qty, "option_qty_contracts": float(pos["option_qty_contracts"] or 0), "option_mark_px": float(opt_mark) if opt_mark is not None else None, "strike": strike, "expiry_ymd": expiry_ymd, "perp_upl": perp_upl, "option_upl": option_upl, + "est_close_fees": est_close_fees, + "net_pnl": net_pnl, "index_px": index_px, "entry_index_px": entry_idx, "move_points": move, diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 1cbaa31..51d3a65 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -13,7 +13,7 @@ from ..models.db import get_db from ..sim.ledger import Ledger from ..sim.matcher import Matcher from .clock import window_key -from .exits import check_exits +from .exits import check_exits, resolve_exit_target from .group import next_group_id logger = logging.getLogger(__name__) @@ -32,7 +32,19 @@ class StrategyEngine: assert row is not None upl = self.matcher.unrealized() 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) leverage = self.ledger.get_setting_float("leverage", s.leverage) 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_left_sec": rest_left, "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, "min_option_hours": min_hours, "min_option_leverage": min_opt_lev, @@ -153,17 +168,24 @@ class StrategyEngine: st = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1") 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() # 有未平仓:只盯平仓,绝不开下一组 if pos.get("status") == "open": upl = self.matcher.unrealized() decision = check_exits( - perp_upl=float(upl["perp_upl"]), - initial_premium=float(upl["initial_premium"] or 0), - move_pct=float(upl.get("move_pct") or 0), - exit_move_pct=exit_pct, + net_pnl=float(upl.get("net_pnl") or 0), + exit_mode=exit_mode, + net_profit_target=net_target, + premium_exit_multiple=prem_mult, + initial_premium=float(upl.get("initial_premium") or 0), ) pending_close = st["phase"] in ("liquidity_wait", "closing") if decision.should_close or pending_close: diff --git a/backend/app/strategy/exits.py b/backend/app/strategy/exits.py index 5c0b746..a9e21d6 100644 --- a/backend/app/strategy/exits.py +++ b/backend/app/strategy/exits.py @@ -2,22 +2,48 @@ from __future__ import annotations from dataclasses import dataclass +EXIT_MODE_FIXED = "fixed_usdt" +EXIT_MODE_PREMIUM = "premium_multiple" + @dataclass(slots=True) class ExitDecision: should_close: bool 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( *, - perp_upl: float, + net_pnl: float, + exit_mode: str, + net_profit_target: float, + premium_exit_multiple: float, initial_premium: float, - move_pct: float, - exit_move_pct: float, ) -> ExitDecision: - if initial_premium > 0 and perp_upl + 1e-9 >= initial_premium: - return ExitDecision(True, "premium_cover") - if exit_move_pct > 0 and move_pct + 1e-9 >= exit_move_pct: - return ExitDecision(True, "move_pct") - return ExitDecision(False, "") + """净盈利(预估全平后)≥ 所选模式目标则全平。""" + target, mode = resolve_exit_target( + exit_mode=exit_mode, + net_profit_target=net_profit_target, + 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) diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index 590066f..0bdb66c 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -28,19 +28,47 @@ def test_signal_equal() -> 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 ( check_exits( - perp_upl=50, initial_premium=40, move_pct=0.1, exit_move_pct=2 - ).reason - == "premium_cover" + net_pnl=14.9, + exit_mode="fixed_usdt", + net_profit_target=15, + premium_exit_multiple=1, + initial_premium=40, + ).should_close + is False ) - assert ( - check_exits( - perp_upl=1, initial_premium=40, move_pct=2.0, exit_move_pct=2 - ).reason - == "move_pct" + + prem = check_exits( + net_pnl=40.0, + exit_mode="premium_multiple", + 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: diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0952d34..2208680 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -120,7 +120,11 @@ export type PlanState = { window_key: string | null; rest_left_sec: 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; min_option_hours: number; min_option_leverage: number; @@ -147,6 +151,8 @@ export type PlanState = { expiry_ymd?: string | null; perp_upl?: number; option_upl?: number; + est_close_fees?: number; + net_pnl?: number; index_px?: number | null; entry_index_px?: number; move_points?: number; @@ -159,7 +165,10 @@ export type PlanState = { export type StrategySettings = { 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; initial_equity: number; leverage: number; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 0cf978e..b0f6200 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -75,15 +75,20 @@ export default function PlanPage() { const pos = plan?.position; const open = !!pos?.has_position; - const exitPct = plan?.exit_move_pct ?? 2; - const movePct = pos?.move_pct ?? 0; + const exitMode = plan?.exit_mode ?? "fixed_usdt"; + 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 || "—"; return (
- SIM 本地撮合 · 期权只买 · 剩余时长选到期 · 波动按比例全平 + SIM 本地撮合 · 期权只买 · 剩余时长选到期 · 净盈利达标全平
{err ?- 无开仓时间窗;期权按剩余时长选到期 → 平值 → 校验杠杆;波动按百分比全平。 + 无开仓时间窗;期权按剩余时长选到期 → 平值 → 校验杠杆;出场可选固定金额或权利金倍数。
{stratOk ?