diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 6e2ea5e..a95c62d 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -48,6 +48,12 @@ KEYS = ( "perp_qty_eth", "option_qty_eth", "show_manual_trade_buttons", + "sizing_mode", + "risk_loss_mode", + "risk_loss_pct", + "risk_loss_usdt", + "risk_capital_source", + "risk_manual_capital_usdt", ) @@ -74,6 +80,14 @@ class StrategySettingsBody(BaseModel): option_qty_eth: float | None = Field(default=None, ge=0.01, le=100) show_manual_trade_buttons: bool | None = None exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$") + sizing_mode: str | None = Field(default=None, pattern="^(manual|risk_based)$") + risk_loss_mode: str | None = Field(default=None, pattern="^(percent|absolute)$") + risk_loss_pct: float | None = Field(default=None, ge=0.01, le=100) + risk_loss_usdt: float | None = Field(default=None, ge=0.1, le=1_000_000) + risk_capital_source: str | None = Field( + default=None, pattern="^(trading_account|manual)$" + ) + risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000) def _as_bool(raw: str | None, default: bool) -> bool: @@ -82,6 +96,15 @@ def _as_bool(raw: str | None, default: bool) -> bool: return str(raw).strip().lower() in ("1", "true", "yes", "on") +def _risk_preview_safe() -> dict: + try: + from ..strategy.risk_sizing import preview_risk_sizing + + return preview_risk_sizing() + except Exception as e: + return {"ok": False, "detail": f"预览失败: {e}", "risk_based": False} + + def _read_settings() -> dict: db = get_db() s = get_settings() @@ -183,6 +206,45 @@ def _read_settings() -> dict: "show_manual_trade_buttons": _as_bool( db.get_setting("show_manual_trade_buttons", "0"), False ), + "sizing_mode": ( + sm + if ( + sm := str(db.get_setting("sizing_mode", "manual") or "manual") + .strip() + .lower() + ) + in ("manual", "risk_based") + else "manual" + ), + "risk_loss_mode": ( + lm + if ( + lm := str(db.get_setting("risk_loss_mode", "percent") or "percent") + .strip() + .lower() + ) + in ("percent", "absolute") + else "percent" + ), + "risk_loss_pct": float(db.get_setting("risk_loss_pct", "1") or 1), + "risk_loss_usdt": float(db.get_setting("risk_loss_usdt", "15") or 15), + "risk_capital_source": ( + cs + if ( + cs := str( + db.get_setting("risk_capital_source", "trading_account") + or "trading_account" + ) + .strip() + .lower() + ) + in ("trading_account", "manual") + else "trading_account" + ), + "risk_manual_capital_usdt": float( + db.get_setting("risk_manual_capital_usdt", "10000") or 10000 + ), + "risk_sizing_preview": _risk_preview_safe(), "exchange": rt.exchange, "perp_inst_id": rt.perp_inst_id, "option_inst_family": rt.option_inst_family, @@ -244,6 +306,59 @@ async def put_strategy_settings( detail="有未平仓,无法切换永续保证金模式;请先平仓后再改", ) + # 以损定仓 ↔ 手动仓位互斥;开启以损定仓时强制 fixed_usdt,并忽略手填名义/出场 + sizing_mode = str( + data.get( + "sizing_mode", + db.get_setting("sizing_mode", "manual") or "manual", + ) + ).strip().lower() + if sizing_mode == "risk_based": + data["exit_mode"] = "fixed_usdt" + data.pop("perp_qty_eth", None) + data.pop("option_qty_eth", None) + data.pop("net_profit_target", None) + loss_mode = str( + data.get( + "risk_loss_mode", + db.get_setting("risk_loss_mode", "percent") or "percent", + ) + ).strip().lower() + if loss_mode == "absolute": + loss_u = data.get("risk_loss_usdt") + if loss_u is None: + loss_u = float(db.get_setting("risk_loss_usdt", "0") or 0) + if float(loss_u) <= 0: + raise HTTPException( + status_code=400, + detail="以损定仓选用亏损值时,须填写 risk_loss_usdt > 0", + ) + else: + src = str( + data.get( + "risk_capital_source", + db.get_setting("risk_capital_source", "trading_account") + or "trading_account", + ) + ).strip().lower() + if src == "manual": + cap = data.get("risk_manual_capital_usdt") + if cap is None: + cap = float(db.get_setting("risk_manual_capital_usdt", "0") or 0) + if float(cap) <= 0: + raise HTTPException( + status_code=400, + detail="以损定仓选用单独本金时,须填写 risk_manual_capital_usdt > 0", + ) + pct = data.get("risk_loss_pct") + if pct is None: + pct = float(db.get_setting("risk_loss_pct", "0") or 0) + if float(pct) <= 0: + raise HTTPException( + status_code=400, + detail="以损定仓选用亏损幅度时,须填写 risk_loss_pct > 0", + ) + for k, v in data.items(): if k in KEYS: db.set_setting(k, str(v)) diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index c3486b6..e4137b6 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -96,9 +96,44 @@ async def sim_open_group( option_inst = ( pick.pair.call_inst_id if option_side == "call" else pick.pair.put_inst_id ) + # 强制方向时用该腿卖一估权利金;否则用选向结果 + sizing_ask = float( + option_ask + if force in ("call", "put") + else pick.option_ask + ) wkey = window_key() db = get_db() + from ..strategy.risk_sizing import apply_risk_sizing_to_ledger + + rs = apply_risk_sizing_to_ledger( + index_px=float(pick.underlying_px), + option_ask=sizing_ask, + db=db, + ) + if not rs.ok: + raise HTTPException(status_code=409, detail=rs.detail) + + try: + from ..strategy.open_capacity import assess_open_capacity + + cap = assess_open_capacity(db) + if cap.get("perp_can_open") is False or cap.get("option_can_open") is False: + detail = ( + f"{cap.get('perp_label')} · {cap.get('option_label')};" + f"永续需≈{cap.get('perp_need_usdt')}U/有{cap.get('perp_have_usdt')}U," + f"期权需≈{cap.get('option_need_usdc')}U/有{cap.get('option_have_usdc')}U" + ) + raise HTTPException( + status_code=409, + detail=f"资金不足,暂不可开新仓:{detail}", + ) + except HTTPException: + raise + except Exception: + pass + count = len( db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",)) ) diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index 5e37aa0..fd6b169 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -716,6 +716,41 @@ class StrategyEngine: self._set_state(phase="open", last_error="有未平仓,禁止开下一组") return + self._set_state(phase="wait_signal") + pick = await get_session().pick_for_open_async() + if pick is None: + self._set_state( + last_error="无合格期权:需剩余时长、杠杆(及已开启的ATM偏差)同时满足" + ) + return + + # 以损定仓:每笔开仓前按指数/卖一重算 k,再写名义与出场(须在资金门前) + try: + from .risk_sizing import apply_risk_sizing_to_ledger + + rs = apply_risk_sizing_to_ledger( + index_px=float(pick.underlying_px), + option_ask=float(pick.option_ask), + db=self.db, + ) + if not rs.ok: + self._set_state(phase="idle", last_error=rs.detail) + try: + from ..notify import wecom + + wecom.notify_fault( + title="以损定仓失败", + detail=rs.detail, + dedupe_key=f"risk_sizing:{rs.detail[:80]}", + ) + except Exception: + pass + return + except Exception: + logger.exception("risk sizing failed") + self._set_state(phase="idle", last_error="以损定仓计算异常,暂不开仓") + return + try: cap = assess_open_capacity(self.db) if cap.get("perp_can_open") is False or cap.get("option_can_open") is False: @@ -732,14 +767,6 @@ class StrategyEngine: except Exception: logger.exception("open capacity gate failed") - self._set_state(phase="wait_signal") - pick = await get_session().pick_for_open_async() - if pick is None: - self._set_state( - last_error="无合格期权:需剩余时长、杠杆(及已开启的ATM偏差)同时满足" - ) - return - self._set_state(phase="opening", last_error=None) wkey = window_key() count = self._count_groups_for_day(wkey) diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py new file mode 100644 index 0000000..9caa6e0 --- /dev/null +++ b/backend/app/strategy/risk_sizing.py @@ -0,0 +1,337 @@ +"""以损定仓:按可承受最大亏损反推标准组倍数 k(永续1 / 期权2 / 出场15)。""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass +from typing import Any + +from ..config import get_settings +from ..models.db import Database, get_db +from ..sim.ledger import Ledger + +logger = logging.getLogger(__name__) + +# 标准组基准(k=1) +BASE_PERP_ETH = 1.0 +BASE_OPTION_ETH = 2.0 +BASE_EXIT_USDT = 15.0 +MIN_K = 0.1 +FEE_LEG_COUNT = 3 # 永续开/平 + 期权一次 + + +@dataclass(frozen=True, slots=True) +class RiskSizingResult: + ok: bool + detail: str + k: float | None = None + budget: float | None = None + capital_base: float | None = None + premium_est: float | None = None + fee_est: float | None = None + max_loss: float | None = None + perp_qty_eth: float | None = None + option_qty_eth: float | None = None + net_profit_target: float | None = None + index_px: float | None = None + option_ask: float | None = None + + +def is_risk_based(ledger: Ledger | None = None) -> bool: + led = ledger or Ledger() + mode = (led.get_setting_str("sizing_mode", "manual") or "manual").strip().lower() + return mode == "risk_based" + + +def floor_k_1dp(k_raw: float) -> float: + """一位小数向下取整,保证不超预算。""" + if k_raw <= 0 or not math.isfinite(k_raw): + return 0.0 + return math.floor(k_raw * 10.0 + 1e-12) / 10.0 + + +def unit_cost(*, index_px: float, option_ask: float, fee_rate: float) -> float: + """k=1 时估算最大亏损 = 权利金(2ETH) + 手续费粗估。""" + premium_unit = float(option_ask) * BASE_OPTION_ETH + fee_unit = float(index_px) * float(fee_rate) * FEE_LEG_COUNT + return premium_unit + fee_unit + + +def compute_k( + *, + budget: float, + index_px: float, + option_ask: float, + fee_rate: float, +) -> RiskSizingResult: + if budget is None or budget <= 0 or not math.isfinite(budget): + return RiskSizingResult(ok=False, detail="以损定仓预算无效(须 > 0)") + if index_px is None or index_px <= 0 or not math.isfinite(index_px): + return RiskSizingResult(ok=False, detail="以损定仓缺少有效指数价") + if option_ask is None or option_ask <= 0 or not math.isfinite(option_ask): + return RiskSizingResult(ok=False, detail="以损定仓缺少有效期权卖一") + + cost1 = unit_cost(index_px=index_px, option_ask=option_ask, fee_rate=fee_rate) + if cost1 <= 1e-12: + return RiskSizingResult(ok=False, detail="以损定仓单位成本无效") + + k_raw = float(budget) / cost1 + k = floor_k_1dp(k_raw) + if k < MIN_K - 1e-12: + return RiskSizingResult( + ok=False, + detail=( + f"以损定仓算出 k={k_raw:.4f},向下取整后 < {MIN_K}," + f"预算 {budget:.2f}U 不足以开最小仓(单位成本≈{cost1:.2f}U)" + ), + budget=float(budget), + k=k, + index_px=float(index_px), + option_ask=float(option_ask), + ) + + # 若浮点导致仍略超,再降一档 + while k >= MIN_K - 1e-12: + prem = float(option_ask) * BASE_OPTION_ETH * k + fee = float(index_px) * float(fee_rate) * FEE_LEG_COUNT * k + mx = prem + fee + if mx <= float(budget) + 1e-6: + return RiskSizingResult( + ok=True, + detail="ok", + k=k, + budget=float(budget), + premium_est=prem, + fee_est=fee, + max_loss=mx, + perp_qty_eth=round(BASE_PERP_ETH * k, 4), + option_qty_eth=round(BASE_OPTION_ETH * k, 4), + net_profit_target=round(BASE_EXIT_USDT * k, 4), + index_px=float(index_px), + option_ask=float(option_ask), + ) + k = round(k - 0.1, 1) + + return RiskSizingResult( + ok=False, + detail=f"以损定仓无法在预算 {budget:.2f}U 内找到合规 k", + budget=float(budget), + index_px=float(index_px), + option_ask=float(option_ask), + ) + + +def resolve_capital_base(db: Database | None = None) -> tuple[float | None, str]: + """返回 (本金USDT口径, 说明)。""" + database = db or get_db() + ledger = Ledger(database) + source = ( + ledger.get_setting_str("risk_capital_source", "trading_account") or "trading_account" + ).strip().lower() + if source in ("manual", "manual_capital", "fixed"): + cap = ledger.get_setting_float("risk_manual_capital_usdt", 0.0) + if cap <= 0: + return None, "单独本金未设置或 ≤ 0" + return float(cap), "manual" + + # trading_account:交易账户 USDT + USDC(1:1 折算,与资金条交易账户一致) + usdt, usdc = _trading_balances(database) + if usdt is None and usdc is None: + try: + from ..exchange.runtime import load_runtime_settings + + ex = str(load_runtime_settings().exchange or "").strip().lower() + if ex in ("binance", "bn") and not get_settings().is_sim: + return ( + None, + "币安实盘暂未接入交易账户余额,请改用「单独本金」或「亏损值」", + ) + except Exception: + pass + return None, "无法读取交易账户资金" + total = float(usdt or 0.0) + float(usdc or 0.0) + if total <= 1e-9: + return None, "交易账户总资金为 0" + return total, "trading_account" + + +def resolve_budget(db: Database | None = None) -> tuple[float | None, str, float | None]: + """返回 (budget, detail, capital_base)。""" + database = db or get_db() + ledger = Ledger(database) + loss_mode = ( + ledger.get_setting_str("risk_loss_mode", "percent") or "percent" + ).strip().lower() + if loss_mode in ("absolute", "usdt", "value", "亏损值"): + bud = ledger.get_setting_float("risk_loss_usdt", 0.0) + if bud <= 0: + return None, "亏损值未设置或 ≤ 0", None + return float(bud), "absolute", None + + capital, src = resolve_capital_base(database) + if capital is None: + return None, src, None + pct = ledger.get_setting_float("risk_loss_pct", 1.0) + if pct <= 0: + return None, "亏损幅度须 > 0", capital + return float(capital) * (float(pct) / 100.0), f"percent@{src}", capital + + +def _trading_balances(db: Database) -> tuple[float | None, float | None]: + s = get_settings() + if s.is_sim: + from ..sim.funds_wallets import SimFundsWallets + + w = SimFundsWallets(db) + v = w.view() + return float(v["trading_usdt"]), float(v["trading_usdc"]) + try: + from ..exchange.runtime import load_runtime_settings + + ex = str(load_runtime_settings().exchange or "").strip().lower() + if ex in ("binance", "bn"): + return None, None + from ..live.okx_funds import OkxFundsClient + + client = OkxFundsClient() + try: + bal = client.fetch_balances() + tu = bal.get("trading_usdt") + tc = bal.get("trading_usdc") + return ( + float(tu) if tu is not None else None, + float(tc) if tc is not None else None, + ) + finally: + client.close() + except Exception as e: + logger.warning("risk_sizing trading balance failed: %s", e) + return None, None + + +def compute_risk_sizing( + *, + index_px: float, + option_ask: float, + db: Database | None = None, +) -> RiskSizingResult: + database = db or get_db() + ledger = Ledger(database) + s = get_settings() + fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate) + budget, bud_detail, capital = resolve_budget(database) + if budget is None: + return RiskSizingResult(ok=False, detail=f"以损定仓预算失败: {bud_detail}") + r = compute_k( + budget=budget, + index_px=index_px, + option_ask=option_ask, + fee_rate=fee_rate, + ) + if not r.ok: + return RiskSizingResult( + ok=False, + detail=r.detail, + budget=budget, + capital_base=capital, + index_px=float(index_px), + option_ask=float(option_ask), + k=r.k, + ) + return RiskSizingResult( + ok=True, + detail=r.detail, + k=r.k, + budget=budget, + capital_base=capital, + premium_est=r.premium_est, + fee_est=r.fee_est, + max_loss=r.max_loss, + perp_qty_eth=r.perp_qty_eth, + option_qty_eth=r.option_qty_eth, + net_profit_target=r.net_profit_target, + index_px=r.index_px, + option_ask=r.option_ask, + ) + + +def apply_risk_sizing_to_ledger( + *, + index_px: float, + option_ask: float, + db: Database | None = None, +) -> RiskSizingResult: + """计算并写入 perp/option/exit;非以损定仓模式直接 ok 跳过。""" + database = db or get_db() + ledger = Ledger(database) + if not is_risk_based(ledger): + return RiskSizingResult(ok=True, detail="manual_sizing_skip") + + r = compute_risk_sizing(index_px=index_px, option_ask=option_ask, db=database) + if not r.ok: + return r + + # 以损定仓强制 fixed_usdt,保证出场 15×k + database.set_setting("exit_mode", "fixed_usdt") + database.set_setting("perp_qty_eth", str(r.perp_qty_eth)) + database.set_setting("option_qty_eth", str(r.option_qty_eth)) + database.set_setting("net_profit_target", str(r.net_profit_target)) + database.set_setting("risk_last_k", str(r.k)) + database.set_setting( + "risk_last_max_loss", + f"{r.max_loss:.6f}" if r.max_loss is not None else "", + ) + logger.info( + "risk_sizing applied k=%.1f perp=%.4f opt=%.4f exit=%.4f max_loss=%.4f budget=%.4f", + r.k or 0, + r.perp_qty_eth or 0, + r.option_qty_eth or 0, + r.net_profit_target or 0, + r.max_loss or 0, + r.budget or 0, + ) + return r + + +def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]: + """设置页预览:用当前盘口粗估。""" + database = db or get_db() + ledger = Ledger(database) + out: dict[str, Any] = { + "sizing_mode": ledger.get_setting_str("sizing_mode", "manual") or "manual", + "risk_based": is_risk_based(ledger), + } + if not is_risk_based(ledger): + out["ok"] = True + out["detail"] = "当前为手动仓位" + return out + try: + from .open_capacity import _index_and_option_ask + + idx, ask = _index_and_option_ask() + except Exception: + idx, ask = None, None + if idx is None or ask is None: + out["ok"] = False + out["detail"] = "暂无指数或期权卖一,无法预览" + return out + r = compute_risk_sizing(index_px=float(idx), option_ask=float(ask), db=database) + out.update( + { + "ok": r.ok, + "detail": r.detail, + "k": r.k, + "budget": r.budget, + "capital_base": r.capital_base, + "premium_est": r.premium_est, + "fee_est": r.fee_est, + "max_loss": r.max_loss, + "perp_qty_eth": r.perp_qty_eth, + "option_qty_eth": r.option_qty_eth, + "net_profit_target": r.net_profit_target, + "index_px": r.index_px, + "option_ask": r.option_ask, + } + ) + return out diff --git a/backend/tests/test_risk_sizing.py b/backend/tests/test_risk_sizing.py new file mode 100644 index 0000000..aef8be6 --- /dev/null +++ b/backend/tests/test_risk_sizing.py @@ -0,0 +1,53 @@ +"""以损定仓纯函数测试。""" + +from __future__ import annotations + +from app.strategy.risk_sizing import ( + BASE_EXIT_USDT, + BASE_OPTION_ETH, + BASE_PERP_ETH, + compute_k, + floor_k_1dp, + unit_cost, +) + + +def test_floor_k_1dp() -> None: + assert floor_k_1dp(1.29) == 1.2 + assert floor_k_1dp(0.19) == 0.1 + assert floor_k_1dp(0.09) == 0.0 + assert floor_k_1dp(2.0) == 2.0 + + +def test_compute_k_scales_1_2_15() -> None: + # I=2000, A=20, fee=0.0005 → unit = 2*20 + 2000*0.0005*3 = 40 + 3 = 43 + # budget=43 → k=1.0 + r = compute_k(budget=43.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005) + assert r.ok + assert r.k == 1.0 + assert r.perp_qty_eth == BASE_PERP_ETH + assert r.option_qty_eth == BASE_OPTION_ETH + assert r.net_profit_target == BASE_EXIT_USDT + assert r.max_loss is not None and r.max_loss <= 43.0 + 1e-6 + + +def test_compute_k_never_exceeds_budget() -> None: + r = compute_k(budget=50.0, index_px=1900.0, option_ask=18.5, fee_rate=0.0005) + assert r.ok + assert r.k is not None + assert abs(r.k * 10 - round(r.k * 10)) < 1e-9 # 一位小数 + assert r.max_loss is not None and r.max_loss <= 50.0 + 1e-6 + assert r.perp_qty_eth == round(1.0 * r.k, 4) + assert r.option_qty_eth == round(2.0 * r.k, 4) + assert r.net_profit_target == round(15.0 * r.k, 4) + + +def test_compute_k_too_small() -> None: + # unit≈43, budget=2 → k_raw≪0.1 + r = compute_k(budget=2.0, index_px=2000.0, option_ask=20.0, fee_rate=0.0005) + assert not r.ok + assert "最小仓" in r.detail or "k=" in r.detail + + +def test_unit_cost() -> None: + assert abs(unit_cost(index_px=2000, option_ask=20, fee_rate=0.0005) - 43.0) < 1e-9 diff --git a/docs/更新说明.md b/docs/更新说明.md index 8cae557..3325cb5 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,20 @@ --- +## 2026-07-29 — 以损定仓(智能调仓) + +### 变更 + +1. 设置页仓位模式二选一:**手动** / **以损定仓**;互斥手填名义与出场。 +2. 以损定仓:亏损幅度(交易账户总资金或单独本金)或亏损值;每次开仓前按 ATM 卖一+指数粗估手续费反推 `k`(一位小数向下),写出永续 `1k` / 期权 `2k` / 出场 `15k`。 +3. 估算最大亏损 = 权利金(`ask×2k`) + 手续费(`指数×fee_rate×3×k`),保证 ≤ 预算。 + +### 审计 + +见同日推送前两轮自审(公式边界、开仓钩子、设置互斥、回归测试)。 + +--- + ## 2026-07-29 — 部署:半截 .venv 必须重建(缺 activate) ### 变更 diff --git a/docs/策略说明.md b/docs/策略说明.md index 6945d0d..ef4cee3 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -39,6 +39,31 @@ 永续杠杆默认 **3×**(可配)。永续保证金模式默认 **全仓**(可改逐仓);期权保证金模式固定买方现金/逐仓意图。同时最多 **1 组**仓,禁止叠仓开下一组。 +### 2.0 仓位模式:手动 vs 以损定仓 + +设置页二选一: + +| 模式 | 行为 | +|------|------| +| **手动** | 直接填 `perp_qty_eth` / `option_qty_eth` / `net_profit_target` | +| **以损定仓** | 禁用手填;每次**新开仓前**按预算反推倍数 `k`(一位小数向下),写出永续 `1×k`、期权 `2×k`、出场 `15×k`(强制 `fixed_usdt`) | + +预算: + +- **亏损幅度**:`本金 × 幅度%`。本金二选一——交易账户总资金(USDT+USDC)或单独本金。 +- **亏损值**:直接填可亏 USDT。 + +估算最大亏损(须 ≤ 预算): + +```text +premium ≈ ATM卖一 × 2 × k +fee ≈ 指数 × fee_rate × 3 × k # 永续开/平 + 期权一次 +max_loss = premium + fee +k = floor(budget / (2A + I×fee_rate×3) × 10) / 10 +``` + +`k < 0.1` 或盘口缺失 → 拒绝开仓。 + ### 2.1 开仓方向 #### 默认(`fixed_direction_enabled=false`):ATM 相对现价优先 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6500597..51b8ce0 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -323,6 +323,13 @@ export type StrategySettings = { perp_qty_eth?: number; option_qty_eth?: number; show_manual_trade_buttons?: boolean; + sizing_mode?: "manual" | "risk_based"; + risk_loss_mode?: "percent" | "absolute"; + risk_loss_pct?: number; + risk_loss_usdt?: number; + risk_capital_source?: "trading_account" | "manual"; + risk_manual_capital_usdt?: number; + risk_sizing_preview?: Record; exchange?: string; }; diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 21ef82d..ab5bc9f 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -84,6 +84,19 @@ export default function SettingsPage() { const [perpQty, setPerpQty] = useState(1); const [optQty, setOptQty] = useState(2); const [showManualTrade, setShowManualTrade] = useState(false); + const [sizingMode, setSizingMode] = useState<"manual" | "risk_based">("manual"); + const [riskLossMode, setRiskLossMode] = useState<"percent" | "absolute">( + "percent", + ); + const [riskLossPct, setRiskLossPct] = useState(1); + const [riskLossUsdt, setRiskLossUsdt] = useState(15); + const [riskCapitalSource, setRiskCapitalSource] = useState< + "trading_account" | "manual" + >("trading_account"); + const [riskManualCapital, setRiskManualCapital] = useState(10000); + const [riskPreview, setRiskPreview] = useState | null>( + null, + ); const [initialEquity, setInitialEquity] = useState(10000); const [exchange, setExchange] = useState<"okx" | "binance">("okx"); const [stratOk, setStratOk] = useState(""); @@ -173,6 +186,19 @@ export default function SettingsPage() { setPerpQty(s.perp_qty_eth ?? 1); setOptQty(s.option_qty_eth ?? 2); setShowManualTrade(s.show_manual_trade_buttons === true); + setSizingMode(s.sizing_mode === "risk_based" ? "risk_based" : "manual"); + setRiskLossMode(s.risk_loss_mode === "absolute" ? "absolute" : "percent"); + setRiskLossPct(s.risk_loss_pct ?? 1); + setRiskLossUsdt(s.risk_loss_usdt ?? 15); + setRiskCapitalSource( + s.risk_capital_source === "manual" ? "manual" : "trading_account", + ); + setRiskManualCapital(s.risk_manual_capital_usdt ?? 10000); + setRiskPreview( + s.risk_sizing_preview && typeof s.risk_sizing_preview === "object" + ? s.risk_sizing_preview + : null, + ); setInitialEquity(s.initial_equity ?? 10000); setExchange(s.exchange === "binance" ? "binance" : "okx"); }) @@ -302,8 +328,7 @@ export default function SettingsPage() { try { const body: Record = { fee_rate: fee, - exit_mode: exitMode, - net_profit_target: netTarget, + exit_mode: sizingMode === "risk_based" ? "fixed_usdt" : exitMode, premium_exit_multiple: premMult, rest_seconds: rest, live_order_interval_sec: orderInterval, @@ -317,19 +342,40 @@ export default function SettingsPage() { fixed_direction_enabled: fixedDirOn, fixed_perp_side: fixedPerpSide, close_bid_mark_max_pct: closeDevPct, - perp_qty_eth: perpQty, - option_qty_eth: optQty, show_manual_trade_buttons: showManualTrade, + sizing_mode: sizingMode, + risk_loss_mode: riskLossMode, + risk_loss_pct: riskLossPct, + risk_loss_usdt: riskLossUsdt, + risk_capital_source: riskCapitalSource, + risk_manual_capital_usdt: riskManualCapital, exchange, }; + // 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422 + if (sizingMode === "manual") { + body.net_profit_target = netTarget; + body.perp_qty_eth = perpQty; + body.option_qty_eth = optQty; + } // LIVE 不改模拟资金,避免误重置本地账本 if (!isLive) { body.initial_equity = initialEquity; } - await apiFetch("/api/settings/strategy", { + const saved = await apiFetch("/api/settings/strategy", { method: "PUT", body: JSON.stringify(body), }); + if (saved?.risk_sizing_preview) { + setRiskPreview(saved.risk_sizing_preview); + } + if (saved?.perp_qty_eth != null) setPerpQty(saved.perp_qty_eth); + if (saved?.option_qty_eth != null) setOptQty(saved.option_qty_eth); + if (saved?.net_profit_target != null) setNetTarget(saved.net_profit_target); + if (saved?.exit_mode) { + setExitMode( + saved.exit_mode === "premium_multiple" ? "premium_multiple" : "fixed_usdt", + ); + } setStratOk( isLive ? "策略参数已保存(实盘模式不改模拟资金)" @@ -583,6 +629,125 @@ export default function SettingsPage() { +
+ + +
+ {sizingMode === "risk_based" ? ( + <> +
+ + +
+ {riskLossMode === "percent" ? ( + <> +
+ + +
+ {riskCapitalSource === "manual" ? ( +
+ + + setRiskManualCapital(Number(e.target.value)) + } + /> +
+ ) : null} +
+ + + setRiskLossPct(Number(e.target.value)) + } + /> +
+ + ) : ( +
+ + + setRiskLossUsdt(Number(e.target.value)) + } + /> +
+ )} +
+ +
+ {riskPreview == null + ? "—" + : riskPreview.ok === false + ? String(riskPreview.detail || "预览不可用") + : `k=${String(riskPreview.k ?? "—")} · 预算=${String(riskPreview.budget ?? "—")}U · 估亏=${String(riskPreview.max_loss ?? "—")}U · 永续=${String(riskPreview.perp_qty_eth ?? "—")} · 期权=${String(riskPreview.option_qty_eth ?? "—")} · 出场=${String(riskPreview.net_profit_target ?? "—")}`} +
+
+ + ) : null}
setPerpQty(Number(e.target.value))} /> @@ -603,6 +769,7 @@ export default function SettingsPage() { type="number" step="0.01" min="0.01" + disabled={sizingMode === "risk_based"} value={optQty} onChange={(e) => setOptQty(Number(e.target.value))} /> @@ -709,7 +876,10 @@ export default function SettingsPage() {
- {exitMode === "fixed_usdt" ? ( + {sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
- + setNetTarget(Number(e.target.value))} />