diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 5b3c208..2cb16ef 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -41,6 +41,8 @@ KEYS = ( "min_option_leverage", "atm_open_offset_enabled", "max_atm_open_offset", + "fixed_direction_enabled", + "fixed_perp_side", "close_bid_mark_max_pct", "perp_qty_eth", "option_qty_eth", @@ -63,6 +65,8 @@ class StrategySettingsBody(BaseModel): min_option_leverage: float | None = Field(default=None, ge=1, le=10000) atm_open_offset_enabled: bool | None = None max_atm_open_offset: float | None = Field(default=None, ge=0, le=100) + 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) 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) @@ -131,6 +135,25 @@ def _read_settings() -> dict: db.get_setting("max_atm_open_offset", str(s.max_atm_open_offset)) or s.max_atm_open_offset ), + "fixed_direction_enabled": _as_bool( + db.get_setting( + "fixed_direction_enabled", str(s.fixed_direction_enabled) + ), + s.fixed_direction_enabled, + ), + "fixed_perp_side": ( + side + if ( + side := str( + db.get_setting("fixed_perp_side", s.fixed_perp_side) + or s.fixed_perp_side + ) + .strip() + .lower() + ) + in ("long", "short") + else "long" + ), "close_bid_mark_max_pct": float( db.get_setting("close_bid_mark_max_pct", str(s.close_bid_mark_max_pct)) or s.close_bid_mark_max_pct diff --git a/backend/app/config.py b/backend/app/config.py index 2640aa6..242df7b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -72,6 +72,9 @@ class Settings(BaseSettings): min_option_leverage: float = 100.0 # 现价/卖一权利金 下限 atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关) max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点) + # 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值 + fixed_direction_enabled: bool = False + fixed_perp_side: str = "long" # long|short;long→买Put,short→买Call close_bid_mark_max_pct: float = 30.0 # 平仓:买一相对标记最大偏差% perp_qty_eth: float = 1.0 option_qty_eth: float = 2.0 diff --git a/backend/app/exchange/types.py b/backend/app/exchange/types.py index 34bbdc5..bb0c404 100644 --- a/backend/app/exchange/types.py +++ b/backend/app/exchange/types.py @@ -88,7 +88,26 @@ def _open_bias( call: Quote | None, put: Quote | None, ) -> str: - """与开仓 decide 一致:先按 ATM 相对现价,贴平时再卖一比价。""" + """与开仓 decide 一致;固定方向开启时显示 fixed_*。""" + try: + from ..config import get_settings + from ..models.db import get_db + + s = get_settings() + db = get_db() + raw = db.get_setting( + "fixed_direction_enabled", str(s.fixed_direction_enabled) + ) + on = str(raw or "").strip().lower() in ("1", "true", "yes", "on") + if on: + side = str( + db.get_setting("fixed_perp_side", s.fixed_perp_side) + or s.fixed_perp_side + or "long" + ).strip().lower() + return "fixed_long_put" if side == "long" else "fixed_short_call" + except Exception: + pass mark = None if index_px is not None and index_px > 0: mark = float(index_px) diff --git a/backend/app/market/instruments.py b/backend/app/market/instruments.py index 3717227..53688de 100644 --- a/backend/app/market/instruments.py +++ b/backend/app/market/instruments.py @@ -40,6 +40,7 @@ def select_option_pair( expiry_ymd: str | None = None, min_hours: float | None = None, now=None, + option_side: str | None = None, ) -> OptionPair | None: contracts = normalize_contracts(instruments) return _select_pair( @@ -48,6 +49,7 @@ def select_option_pair( expiry_ymd=expiry_ymd, min_hours=min_hours, now=now, + option_side=option_side, ) diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 0e12d78..77d2d0f 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -232,6 +232,8 @@ class Database: "min_option_leverage": str(s.min_option_leverage), "atm_open_offset_enabled": str(s.atm_open_offset_enabled), "max_atm_open_offset": str(s.max_atm_open_offset), + "fixed_direction_enabled": str(s.fixed_direction_enabled), + "fixed_perp_side": str(s.fixed_perp_side), "close_bid_mark_max_pct": str(s.close_bid_mark_max_pct), "perp_qty_eth": str(s.perp_qty_eth), "option_qty_eth": str(s.option_qty_eth), diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 556da97..a96b4e2 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -93,6 +93,16 @@ class StrategyEngine: max_atm_off = self.ledger.get_setting_float( "max_atm_open_offset", s.max_atm_open_offset ) + fixed_dir_on = self.ledger.get_setting_bool( + "fixed_direction_enabled", s.fixed_direction_enabled + ) + fixed_perp = str( + self.ledger.get_setting_str("fixed_perp_side", s.fixed_perp_side) + or s.fixed_perp_side + or "long" + ).strip().lower() + if fixed_perp not in ("long", "short"): + fixed_perp = "long" perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth) opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth) rest_until = row["rest_until_ms"] @@ -136,6 +146,8 @@ class StrategyEngine: "min_option_leverage": min_opt_lev, "atm_open_offset_enabled": atm_off_on, "max_atm_open_offset": max_atm_off, + "fixed_direction_enabled": fixed_dir_on, + "fixed_perp_side": fixed_perp, "can_open": allow_open, "open_capacity": open_cap, "last_error": last_error, diff --git a/backend/app/strategy/selection.py b/backend/app/strategy/selection.py index 5e25e6a..8953ce9 100644 --- a/backend/app/strategy/selection.py +++ b/backend/app/strategy/selection.py @@ -46,6 +46,46 @@ def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None: return min(strikes, key=lambda s: (abs(s - mark_px), s)) +def pick_itm_or_atm_strike( + strikes: list[float], + mark_px: float, + *, + option_side: str, +) -> float | None: + """ + 固定方向选约:只要实值或平值,不要虚值。 + - Call:行权价 ≤ 标的(平值/实值) + - Put:行权价 ≥ 标的(平值/实值) + 在合格档中取最接近标的者(优先平值)。 + """ + if not strikes or mark_px <= 0: + return None + side = (option_side or "").strip().lower() + if side == "call": + cands = [float(s) for s in strikes if float(s) <= float(mark_px) + 1e-9] + elif side == "put": + cands = [float(s) for s in strikes if float(s) >= float(mark_px) - 1e-9] + else: + return None + if not cands: + return None + return min(cands, key=lambda s: (abs(s - float(mark_px)), s)) + + +def is_itm_or_atm(*, option_side: str, strike: float, mark_px: float) -> bool: + """Call: K≤S;Put: K≥S。""" + if mark_px <= 0: + return False + side = (option_side or "").strip().lower() + k = float(strike) + s = float(mark_px) + if side == "call": + return k <= s + 1e-9 + if side == "put": + return k >= s - 1e-9 + return False + + def atm_open_offset(strike: float, mark_px: float) -> float: """开仓用:ATM 行权价相对标的的绝对点差。""" return abs(float(strike) - float(mark_px)) @@ -125,7 +165,13 @@ def select_option_pair( expiry_ymd: str | None = None, min_hours: float | None = None, now: datetime | None = None, + option_side: str | None = None, ) -> OptionPair | None: + """ + 选到期 + 行权价。 + option_side 为 call/put 时:按实值/平值选档(固定方向模式); + 否则仍选 ATM(现有规则)。 + """ complete = _complete_by_expiry(contracts) if not complete: return None @@ -148,14 +194,20 @@ def select_option_pair( ymd = eligible[0] ems, strikes_map = complete[ymd] - atm = pick_atm_strike(list(strikes_map.keys()), mark_px) - if atm is None: + side = (option_side or "").strip().lower() or None + if side in ("call", "put"): + strike = pick_itm_or_atm_strike( + list(strikes_map.keys()), mark_px, option_side=side + ) + else: + strike = pick_atm_strike(list(strikes_map.keys()), mark_px) + if strike is None: return None - legs = strikes_map[atm] + legs = strikes_map[strike] return OptionPair( expiry_ymd=ymd, expiry_ms=ems, - strike=atm, + strike=strike, call_inst_id=legs["C"], put_inst_id=legs["P"], ) diff --git a/backend/app/strategy/session.py b/backend/app/strategy/session.py index df52414..1185c69 100644 --- a/backend/app/strategy/session.py +++ b/backend/app/strategy/session.py @@ -16,6 +16,7 @@ from .selection import ( atm_allows_open, atm_open_offset, hours_until_expiry, + is_itm_or_atm, list_eligible_expiry_ymds, option_leverage, select_option_pair, @@ -101,6 +102,36 @@ def _strategy_floats() -> tuple[float, float, float, bool]: ) +def _fixed_direction() -> tuple[bool, str]: + """(enabled, perp_side long|short)。默认关。""" + s = get_settings() + try: + from ..models.db import get_db + + db = get_db() + enabled = _as_bool_setting( + db.get_setting( + "fixed_direction_enabled", str(s.fixed_direction_enabled) + ), + s.fixed_direction_enabled, + ) + side = str( + db.get_setting("fixed_perp_side", s.fixed_perp_side) or s.fixed_perp_side + ).strip().lower() + if side not in ("long", "short"): + side = "long" + return enabled, side + except Exception: + side = str(s.fixed_perp_side or "long").strip().lower() + if side not in ("long", "short"): + side = "long" + return bool(s.fixed_direction_enabled), side + + +def _option_side_for_perp(perp_side: str) -> str: + return "put" if (perp_side or "").strip().lower() == "long" else "call" + + @dataclass(slots=True) class OpenPick: pair: OptionPair @@ -230,19 +261,29 @@ class StrategySession: if mark is None or mark <= 0: raise RuntimeError("无法获取标的标记/指数价格,无法选 ATM") min_hours, _, _, _ = _strategy_floats() + fixed_on, fixed_perp = _fixed_direction() + opt_side = _option_side_for_perp(fixed_perp) if fixed_on else None contracts = self.ex.list_option_contracts(s.option_inst_family) - pair = select_option_pair(contracts, mark_px=float(mark), min_hours=min_hours) + pair = select_option_pair( + contracts, + mark_px=float(mark), + min_hours=min_hours, + option_side=opt_side, + ) if pair is None: + kind = f"实值/平值 {opt_side}" if opt_side else "ATM" raise RuntimeError( - f"未找到剩余≥{min_hours}h 的 ATM Call/Put (family={s.option_inst_family})" + f"未找到剩余≥{min_hours}h 的 {kind} Call/Put (family={s.option_inst_family})" ) return self._apply_pair(pair, mark=float(mark), idx=idx) def pick_for_open(self) -> OpenPick | None: - from .signal import decide + from .signal import decide, decide_fixed s = self.settings min_hours, min_lev, max_atm_off, atm_off_on = _strategy_floats() + fixed_on, fixed_perp = _fixed_direction() + opt_side_hint = _option_side_for_perp(fixed_perp) if fixed_on else None idx = self.ex.fetch_index(s.index_inst_id) mark = self.ex.fetch_mark(s.perp_inst_id) or idx if mark is None or mark <= 0: @@ -255,24 +296,44 @@ class StrategySession: return None for ymd in eligible: - pair = select_option_pair(contracts, mark_px=underlying, expiry_ymd=ymd) + pair = select_option_pair( + contracts, + mark_px=underlying, + expiry_ymd=ymd, + option_side=opt_side_hint, + ) if pair is None: continue - offset = atm_open_offset(pair.strike, underlying) - if not atm_allows_open( - pair.strike, - underlying, - max_offset=max_atm_off, - enabled=atm_off_on, - ): - logger.info( - "skip expiry=%s strike=%.0f atm_offset=%.1f > max=%.1f", - ymd, + if fixed_on: + if not is_itm_or_atm( + option_side=opt_side_hint or "", + strike=pair.strike, + mark_px=underlying, + ): + logger.info( + "skip expiry=%s strike=%.0f not ITM/ATM for %s mark=%.2f", + ymd, + pair.strike, + opt_side_hint, + underlying, + ) + continue + else: + offset = atm_open_offset(pair.strike, underlying) + if not atm_allows_open( pair.strike, - offset, - max_atm_off, - ) - continue + underlying, + max_offset=max_atm_off, + enabled=atm_off_on, + ): + logger.info( + "skip expiry=%s strike=%.0f atm_offset=%.1f > max=%.1f", + ymd, + pair.strike, + offset, + max_atm_off, + ) + continue call_bids, call_asks, _ = self.ex.fetch_book(pair.call_inst_id, depth=5) put_bids, put_asks, _ = self.ex.fetch_book(pair.put_inst_id, depth=5) call_ask = call_asks[0].px if call_asks else None @@ -284,12 +345,15 @@ class StrategySession: if put_ask is None: pq = self.ex.quote(pair.put_inst_id) put_ask = pq.ask if pq else None - sig = decide( - call_ask, - put_ask, - strike=pair.strike, - mark_px=underlying, - ) + if fixed_on: + sig = decide_fixed(call_ask, put_ask, perp_side=fixed_perp) + else: + sig = decide( + call_ask, + put_ask, + strike=pair.strike, + mark_px=underlying, + ) if sig is None: continue opt_ask = sig.call_ask if sig.option_side == "call" else sig.put_ask @@ -376,6 +440,13 @@ class StrategySession: mark = mark_px if mark_px is not None else self._mark_for_atm() if mark is None or mark <= 0: return False + fixed_on, fixed_perp = _fixed_direction() + if fixed_on: + opt = _option_side_for_perp(fixed_perp) + if not is_itm_or_atm( + option_side=opt, strike=float(self._pair.strike), mark_px=float(mark) + ): + return True return abs(float(self._pair.strike) - float(mark)) >= _ATM_DRIFT_POINTS async def ensure_atm_async(self, *, force: bool = False) -> OptionPair | None: diff --git a/backend/app/strategy/signal.py b/backend/app/strategy/signal.py index def2b8d..c2a3875 100644 --- a/backend/app/strategy/signal.py +++ b/backend/app/strategy/signal.py @@ -66,3 +66,38 @@ def decide( put_ask=pa, ) return None + + +def decide_fixed( + call_ask: float | None, + put_ask: float | None, + *, + perp_side: str, +) -> Signal | None: + """ + 固定方向: + - 永续多 → 买 Put + - 永续空 → 买 Call + """ + if call_ask is None or put_ask is None: + return None + side = (perp_side or "").strip().lower() + ca = float(call_ask) + pa = float(put_ask) + if side == "long": + return Signal( + bias="fixed_long_put", + option_side="put", + perp_side="long", + call_ask=ca, + put_ask=pa, + ) + if side == "short": + return Signal( + bias="fixed_short_call", + option_side="call", + perp_side="short", + call_ask=ca, + put_ask=pa, + ) + return None diff --git a/backend/tests/test_instruments.py b/backend/tests/test_instruments.py index 01a010e..37f8708 100644 --- a/backend/tests/test_instruments.py +++ b/backend/tests/test_instruments.py @@ -27,6 +27,43 @@ def test_pick_atm_strike() -> None: assert pick_atm_strike([3400, 3500, 3600], 3510) == 3500 +def test_pick_itm_or_atm_strike() -> None: + from app.strategy.selection import is_itm_or_atm, pick_itm_or_atm_strike + + strikes = [3400, 3500, 3600] + # Call:K≤S,现价 3510 → 3500(平值侧最近) + assert pick_itm_or_atm_strike(strikes, 3510, option_side="call") == 3500 + # Put:K≥S,现价 3510 → 3600(实值最近;无 3510 档) + assert pick_itm_or_atm_strike(strikes, 3510, option_side="put") == 3600 + # Put 现价正好 3500 → 平值 3500 + assert pick_itm_or_atm_strike(strikes, 3500, option_side="put") == 3500 + assert is_itm_or_atm(option_side="call", strike=3500, mark_px=3510) + assert not is_itm_or_atm(option_side="call", strike=3600, mark_px=3510) + assert is_itm_or_atm(option_side="put", strike=3600, mark_px=3510) + assert not is_itm_or_atm(option_side="put", strike=3400, mark_px=3510) + + +def test_select_option_pair_itm_put() -> None: + rows = [ + {"instId": "ETH-USD_UM-260725-3490-C", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3490-P", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3500-C", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3500-P", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3510-C", "state": "live"}, + {"instId": "ETH-USD_UM-260725-3510-P", "state": "live"}, + ] + # 标的 3502:Put 实/平 → 3510(≥3502 最近) + pair = select_option_pair(rows, mark_px=3502, expiry_ymd="260725", option_side="put") + assert pair is not None + assert pair.strike == 3510 + # Call 实/平 → 3500(≤3502 最近) + pair_c = select_option_pair( + rows, mark_px=3502, expiry_ymd="260725", option_side="call" + ) + assert pair_c is not None + assert pair_c.strike == 3500 + + def test_next_session_expiry_before_open() -> None: now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH) assert next_session_expiry_ymd(now) == "260724" diff --git a/backend/tests/test_p1_p2_rules.py b/backend/tests/test_p1_p2_rules.py index f1e333a..35dbbf0 100644 --- a/backend/tests/test_p1_p2_rules.py +++ b/backend/tests/test_p1_p2_rules.py @@ -28,6 +28,26 @@ def test_signal_equal() -> None: assert decide(10.0, 10.0) is None +def test_decide_fixed_long_put() -> None: + from app.strategy.signal import decide_fixed + + s = decide_fixed(20.0, 15.0, perp_side="long") + assert s is not None + assert s.option_side == "put" + assert s.perp_side == "long" + assert s.bias == "fixed_long_put" + + +def test_decide_fixed_short_call() -> None: + from app.strategy.signal import decide_fixed + + s = decide_fixed(20.0, 15.0, perp_side="short") + assert s is not None + assert s.option_side == "call" + assert s.perp_side == "short" + assert s.bias == "fixed_short_call" + + def test_signal_strike_below_spot_call_short() -> None: # 现价 1859、ATM 1850:即使 Put 卖一更高,也走 Call+空 s = decide(10.0, 20.0, strike=1850, mark_px=1859) diff --git a/docs/更新说明.md b/docs/更新说明.md index c86801b..86104ee 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,16 @@ --- +## 2026-07-24 — 策略设置:固定方向开关 + +### 变更 + +1. 选约增加 **固定方向**(默认关):开则固定永续多→买 Put,或永续空→买 Call。 +2. 固定方向开启时,期权只选 **实值或平值**(Call:K≤标的;Put:K≥标的),不买虚值;ATM 偏差限制不适用。 +3. 关闭时仍按现有 ATM / 卖一比价规则开仓。 + +--- + ## 2026-07-29 — 企微不标模拟盘;已完成组数按已平仓统计 ### 变更 diff --git a/docs/策略说明.md b/docs/策略说明.md index 7e181fd..713dcd3 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -39,7 +39,9 @@ 永续杠杆默认 **3×**(可配)。同时最多 **1 组**仓,禁止叠仓开下一组。 -### 2.1 开仓方向(ATM 相对现价优先) +### 2.1 开仓方向 + +#### 默认(`fixed_direction_enabled=false`):ATM 相对现价优先 行权价相对标的有偏离时(币安粗档常见),**先按 ATM 偏上/偏下选向**;仅当行权价与标的贴平(≈)时,才回退卖一比价。 @@ -53,6 +55,17 @@ 直觉:偏下行权价用 Call+空,偏上行权价用 Put+多;贴平时再按权利金溢价侧选向。 +#### 固定方向(`fixed_direction_enabled=true`) + +设置里可开「固定方向」(默认关),并指定永续多或空: + +| `fixed_perp_side` | 永续 | 期权 | 行权价约束 | bias | +|-------------------|------|------|------------|------| +| `long` | 做多 | 买入 Put | 实值或平值(K ≥ 标的),优先贴近标的 | `fixed_long_put` | +| `short` | 做空 | 买入 Call | 实值或平值(K ≤ 标的),优先贴近标的 | `fixed_short_call` | + +固定方向开启时:**不买虚值**;ATM 偏差限制不适用(由实值/平值规则替代)。 + --- ## 3. 开仓机制 @@ -64,9 +77,9 @@ → 无持仓且不在休息期 → 非周末跳过(若开启) → 选到期:剩余时长 ≥ min_option_hours(默认 12h) - → 该到期 ATM 行权价(最接近标的) - → 若开启 atm_open_offset_enabled:|ATM − 标的| ≤ max_atm_open_offset(默认 3)否则跳过 - → 选向:ATM 偏下→Call+空;偏上→Put+多;贴平→卖一比价 + → 若固定方向关:该到期 ATM;若开:按 Put/Call 选实值或平值档 + → 若未开固定方向且开启 atm_open_offset_enabled:|ATM − 标的| ≤ max_atm_open_offset(默认 3)否则跳过 + → 选向:固定方向按设置;否则 ATM 偏下→Call+空;偏上→Put+多;贴平→卖一比价 → 期权杠杆 = 标的价 ÷ 卖一权利金 ≥ min_option_leverage(默认 100) → 先开期权(吃卖一)→ 再市价开永续(一组) → 锁定 initial_premium = 期权成交价 × 期权名义(不含费) @@ -78,9 +91,11 @@ |------|------|------| | `min_option_hours` | 12 | 过滤过近到期,减少刚开仓就到期 | | `min_option_leverage` | 100 | 权利金相对标的不能太贵(现价/卖一) | -| `atm_open_offset_enabled` | false | 开仓 ATM 偏差限制开关(默认关) | +| `fixed_direction_enabled` | false | 固定方向开关(默认关) | +| `fixed_perp_side` | long | 开启后:long=多+Put / short=空+Call | +| `atm_open_offset_enabled` | false | 开仓 ATM 偏差限制开关(默认关;固定方向开启时忽略) | | `max_atm_open_offset` | 3 | 开关开启后:\|ATM − 标的\| 超过则不开 | -| ATM | — | 同到期、最接近指数/标记价的行权价 | +| ATM / 实值平值 | — | 默认 ATM;固定方向时同到期实值或平值、最贴近标的 | 无合格合约时:状态停留等待,记录「无合格期权…」,不硬开。 @@ -324,6 +339,8 @@ | `skip_weekends` | true | 时间 | | `min_option_hours` | 12 | 选约 | | `min_option_leverage` | 100 | 选约 | +| `fixed_direction_enabled` | false | 固定方向开关 | +| `fixed_perp_side` | long | 固定永续方向 | | `atm_open_offset_enabled` | false | 开仓 ATM 偏差开关 | | `max_atm_open_offset` | 3 | 开仓 ATM 偏差上限 | | `close_bid_mark_max_pct` | 30 | 平仓流动性 | @@ -334,6 +351,7 @@ | 日期 | 说明 | |------|------| +| 2026-07-24 | 选约增加固定方向开关:永续多→Put / 空→Call,期权仅实值或平值;关则沿用 ATM/比价 | | 2026-07-25 | 初稿:对齐当前开平仓、周末跳过、到期全平、净盈利口径与资金建议 | | 2026-07-25 | 平仓顺序改为先期权后永续(与开仓同理:薄腿优先) | | 2026-07-26 | 到期按内在价值结算(对齐实盘);紧急平仓仍用 max(买一,标记,内在价值) | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index b88abb3..c6d71f6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -234,6 +234,8 @@ export type PlanState = { min_option_leverage: number; atm_open_offset_enabled?: boolean; max_atm_open_offset?: number; + fixed_direction_enabled?: boolean; + fixed_perp_side?: "long" | "short"; can_open: boolean; open_capacity?: { leverage?: number; @@ -299,6 +301,29 @@ export type PlanState = { live_ready_reason?: string; }; +export type StrategySettings = { + fee_rate: number; + exit_mode: "fixed_usdt" | "premium_multiple"; + net_profit_target: number; + premium_exit_multiple: number; + rest_seconds: number; + live_order_interval_sec?: number; + skip_weekends?: boolean; + initial_equity?: number; + leverage?: number; + min_option_hours?: number; + min_option_leverage?: number; + atm_open_offset_enabled?: boolean; + max_atm_open_offset?: number; + fixed_direction_enabled?: boolean; + fixed_perp_side?: "long" | "short"; + close_bid_mark_max_pct?: number; + perp_qty_eth?: number; + option_qty_eth?: number; + show_manual_trade_buttons?: boolean; + exchange?: string; +}; + export type RuntimeSettings = { mode: "SIM" | "LIVE"; exchange: string; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index ac92285..b4d7d89 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -130,9 +130,13 @@ export default function PlanPage() { const bias = snap?.ask_compare?.bias; const biasTag = - bias === "strike_below_spot" || bias === "call_ask_gt_put" ? ( + bias === "strike_below_spot" || + bias === "call_ask_gt_put" || + bias === "fixed_short_call" ? ( 买 Call + 永续空 - ) : bias === "strike_above_spot" || bias === "put_ask_gt_call" ? ( + ) : bias === "strike_above_spot" || + bias === "put_ask_gt_call" || + bias === "fixed_long_put" ? ( 买 Put + 永续多 ) : ( 等待 / 相等 @@ -149,9 +153,13 @@ export default function PlanPage() { ? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}` : `固定 ${fmt(plan?.net_profit_target ?? 15)} U`; const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—"; - const atmRule = plan?.atm_open_offset_enabled - ? `ATM偏差≤${fmt(plan?.max_atm_open_offset ?? 3, 0)}` - : "ATM偏差关"; + const atmRule = plan?.fixed_direction_enabled + ? plan?.fixed_perp_side === "short" + ? "固定:永续空+买Call(实/平)" + : "固定:永续多+买Put(实/平)" + : plan?.atm_open_offset_enabled + ? `ATM偏差≤${fmt(plan?.max_atm_open_offset ?? 3, 0)}` + : "ATM偏差关"; const exitDetail = exitMode === "premium_multiple" ? `净盈利≥初始权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}` @@ -337,9 +345,13 @@ export default function PlanPage() { 剩余≥{fmt(plan?.min_option_hours, 0)}h · 期权杠杆≥ {fmt(plan?.min_option_leverage, 0)}x - {plan?.atm_open_offset_enabled - ? ` · ATM偏差≤${fmt(plan?.max_atm_open_offset ?? 3, 0)}` - : " · ATM偏差关"} + {plan?.fixed_direction_enabled + ? plan?.fixed_perp_side === "short" + ? " · 固定空+Call(实/平)" + : " · 固定多+Put(实/平)" + : plan?.atm_open_offset_enabled + ? ` · ATM偏差≤${fmt(plan?.max_atm_open_offset ?? 3, 0)}` + : " · ATM偏差关"}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 94f5334..f44f59e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -74,6 +74,8 @@ export default function SettingsPage() { const [minOptLev, setMinOptLev] = useState(100); const [atmOffOn, setAtmOffOn] = useState(false); const [maxAtmOff, setMaxAtmOff] = useState(3); + const [fixedDirOn, setFixedDirOn] = useState(false); + const [fixedPerpSide, setFixedPerpSide] = useState<"long" | "short">("long"); const [closeDevPct, setCloseDevPct] = useState(30); const [perpQty, setPerpQty] = useState(1); const [optQty, setOptQty] = useState(2); @@ -158,6 +160,8 @@ export default function SettingsPage() { setMinOptLev(s.min_option_leverage ?? 100); setAtmOffOn(s.atm_open_offset_enabled === true); setMaxAtmOff(s.max_atm_open_offset ?? 3); + setFixedDirOn(s.fixed_direction_enabled === true); + setFixedPerpSide(s.fixed_perp_side === "short" ? "short" : "long"); setCloseDevPct(s.close_bid_mark_max_pct ?? 30); setPerpQty(s.perp_qty_eth ?? 1); setOptQty(s.option_qty_eth ?? 2); @@ -302,6 +306,8 @@ export default function SettingsPage() { min_option_leverage: minOptLev, atm_open_offset_enabled: atmOffOn, max_atm_open_offset: maxAtmOff, + fixed_direction_enabled: fixedDirOn, + fixed_perp_side: fixedPerpSide, close_bid_mark_max_pct: closeDevPct, perp_qty_eth: perpQty, option_qty_eth: optQty, @@ -610,11 +616,41 @@ export default function SettingsPage() { onChange={(e) => setMinOptLev(Number(e.target.value))} />
+
+ + +
+
+ + +