Add risk-based position sizing (以损定仓) with per-open k resize.
Manual vs risk modes are exclusive; each open floors k to 1 decimal so estimated premium+fees stay within the loss budget. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -48,6 +48,12 @@ KEYS = (
|
|||||||
"perp_qty_eth",
|
"perp_qty_eth",
|
||||||
"option_qty_eth",
|
"option_qty_eth",
|
||||||
"show_manual_trade_buttons",
|
"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)
|
option_qty_eth: float | None = Field(default=None, ge=0.01, le=100)
|
||||||
show_manual_trade_buttons: bool | None = None
|
show_manual_trade_buttons: bool | None = None
|
||||||
exchange: str | None = Field(default=None, pattern="^(okx|binance|bn)$")
|
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:
|
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")
|
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:
|
def _read_settings() -> dict:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
@@ -183,6 +206,45 @@ def _read_settings() -> dict:
|
|||||||
"show_manual_trade_buttons": _as_bool(
|
"show_manual_trade_buttons": _as_bool(
|
||||||
db.get_setting("show_manual_trade_buttons", "0"), False
|
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,
|
"exchange": rt.exchange,
|
||||||
"perp_inst_id": rt.perp_inst_id,
|
"perp_inst_id": rt.perp_inst_id,
|
||||||
"option_inst_family": rt.option_inst_family,
|
"option_inst_family": rt.option_inst_family,
|
||||||
@@ -244,6 +306,59 @@ async def put_strategy_settings(
|
|||||||
detail="有未平仓,无法切换永续保证金模式;请先平仓后再改",
|
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():
|
for k, v in data.items():
|
||||||
if k in KEYS:
|
if k in KEYS:
|
||||||
db.set_setting(k, str(v))
|
db.set_setting(k, str(v))
|
||||||
|
|||||||
@@ -96,9 +96,44 @@ async def sim_open_group(
|
|||||||
option_inst = (
|
option_inst = (
|
||||||
pick.pair.call_inst_id if option_side == "call" else pick.pair.put_inst_id
|
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()
|
wkey = window_key()
|
||||||
db = get_db()
|
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(
|
count = len(
|
||||||
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -716,6 +716,41 @@ class StrategyEngine:
|
|||||||
self._set_state(phase="open", last_error="有未平仓,禁止开下一组")
|
self._set_state(phase="open", last_error="有未平仓,禁止开下一组")
|
||||||
return
|
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:
|
try:
|
||||||
cap = assess_open_capacity(self.db)
|
cap = assess_open_capacity(self.db)
|
||||||
if cap.get("perp_can_open") is False or cap.get("option_can_open") is False:
|
if cap.get("perp_can_open") is False or cap.get("option_can_open") is False:
|
||||||
@@ -732,14 +767,6 @@ class StrategyEngine:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("open capacity gate failed")
|
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)
|
self._set_state(phase="opening", last_error=None)
|
||||||
wkey = window_key()
|
wkey = window_key()
|
||||||
count = self._count_groups_for_day(wkey)
|
count = self._count_groups_for_day(wkey)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
## 2026-07-29 — 部署:半截 .venv 必须重建(缺 activate)
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -39,6 +39,31 @@
|
|||||||
|
|
||||||
永续杠杆默认 **3×**(可配)。永续保证金模式默认 **全仓**(可改逐仓);期权保证金模式固定买方现金/逐仓意图。同时最多 **1 组**仓,禁止叠仓开下一组。
|
永续杠杆默认 **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 开仓方向
|
### 2.1 开仓方向
|
||||||
|
|
||||||
#### 默认(`fixed_direction_enabled=false`):ATM 相对现价优先
|
#### 默认(`fixed_direction_enabled=false`):ATM 相对现价优先
|
||||||
|
|||||||
@@ -323,6 +323,13 @@ export type StrategySettings = {
|
|||||||
perp_qty_eth?: number;
|
perp_qty_eth?: number;
|
||||||
option_qty_eth?: number;
|
option_qty_eth?: number;
|
||||||
show_manual_trade_buttons?: boolean;
|
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<string, unknown>;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,19 @@ export default function SettingsPage() {
|
|||||||
const [perpQty, setPerpQty] = useState(1);
|
const [perpQty, setPerpQty] = useState(1);
|
||||||
const [optQty, setOptQty] = useState(2);
|
const [optQty, setOptQty] = useState(2);
|
||||||
const [showManualTrade, setShowManualTrade] = useState(false);
|
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<Record<string, unknown> | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [initialEquity, setInitialEquity] = useState(10000);
|
const [initialEquity, setInitialEquity] = useState(10000);
|
||||||
const [exchange, setExchange] = useState<"okx" | "binance">("okx");
|
const [exchange, setExchange] = useState<"okx" | "binance">("okx");
|
||||||
const [stratOk, setStratOk] = useState("");
|
const [stratOk, setStratOk] = useState("");
|
||||||
@@ -173,6 +186,19 @@ export default function SettingsPage() {
|
|||||||
setPerpQty(s.perp_qty_eth ?? 1);
|
setPerpQty(s.perp_qty_eth ?? 1);
|
||||||
setOptQty(s.option_qty_eth ?? 2);
|
setOptQty(s.option_qty_eth ?? 2);
|
||||||
setShowManualTrade(s.show_manual_trade_buttons === true);
|
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);
|
setInitialEquity(s.initial_equity ?? 10000);
|
||||||
setExchange(s.exchange === "binance" ? "binance" : "okx");
|
setExchange(s.exchange === "binance" ? "binance" : "okx");
|
||||||
})
|
})
|
||||||
@@ -302,8 +328,7 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
fee_rate: fee,
|
fee_rate: fee,
|
||||||
exit_mode: exitMode,
|
exit_mode: sizingMode === "risk_based" ? "fixed_usdt" : exitMode,
|
||||||
net_profit_target: netTarget,
|
|
||||||
premium_exit_multiple: premMult,
|
premium_exit_multiple: premMult,
|
||||||
rest_seconds: rest,
|
rest_seconds: rest,
|
||||||
live_order_interval_sec: orderInterval,
|
live_order_interval_sec: orderInterval,
|
||||||
@@ -317,19 +342,40 @@ export default function SettingsPage() {
|
|||||||
fixed_direction_enabled: fixedDirOn,
|
fixed_direction_enabled: fixedDirOn,
|
||||||
fixed_perp_side: fixedPerpSide,
|
fixed_perp_side: fixedPerpSide,
|
||||||
close_bid_mark_max_pct: closeDevPct,
|
close_bid_mark_max_pct: closeDevPct,
|
||||||
perp_qty_eth: perpQty,
|
|
||||||
option_qty_eth: optQty,
|
|
||||||
show_manual_trade_buttons: showManualTrade,
|
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,
|
exchange,
|
||||||
};
|
};
|
||||||
|
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
|
||||||
|
if (sizingMode === "manual") {
|
||||||
|
body.net_profit_target = netTarget;
|
||||||
|
body.perp_qty_eth = perpQty;
|
||||||
|
body.option_qty_eth = optQty;
|
||||||
|
}
|
||||||
// LIVE 不改模拟资金,避免误重置本地账本
|
// LIVE 不改模拟资金,避免误重置本地账本
|
||||||
if (!isLive) {
|
if (!isLive) {
|
||||||
body.initial_equity = initialEquity;
|
body.initial_equity = initialEquity;
|
||||||
}
|
}
|
||||||
await apiFetch("/api/settings/strategy", {
|
const saved = await apiFetch<StrategySettings>("/api/settings/strategy", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
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(
|
setStratOk(
|
||||||
isLive
|
isLive
|
||||||
? "策略参数已保存(实盘模式不改模拟资金)"
|
? "策略参数已保存(实盘模式不改模拟资金)"
|
||||||
@@ -583,6 +629,125 @@ export default function SettingsPage() {
|
|||||||
<option value="isolated">逐仓</option>
|
<option value="isolated">逐仓</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="sizingMode">仓位模式</label>
|
||||||
|
<select
|
||||||
|
id="sizingMode"
|
||||||
|
className="mono"
|
||||||
|
value={sizingMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSizingMode(
|
||||||
|
e.target.value === "risk_based"
|
||||||
|
? "risk_based"
|
||||||
|
: "manual",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="manual">手动仓位(填 ETH 名义)</option>
|
||||||
|
<option value="risk_based">以损定仓(按亏损预算反推)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{sizingMode === "risk_based" ? (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskLossMode">亏损约束</label>
|
||||||
|
<select
|
||||||
|
id="riskLossMode"
|
||||||
|
className="mono"
|
||||||
|
value={riskLossMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRiskLossMode(
|
||||||
|
e.target.value === "absolute"
|
||||||
|
? "absolute"
|
||||||
|
: "percent",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="percent">亏损幅度(占本金 %)</option>
|
||||||
|
<option value="absolute">亏损值(USDT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{riskLossMode === "percent" ? (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskCapSrc">本金来源</label>
|
||||||
|
<select
|
||||||
|
id="riskCapSrc"
|
||||||
|
className="mono"
|
||||||
|
value={riskCapitalSource}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRiskCapitalSource(
|
||||||
|
e.target.value === "manual"
|
||||||
|
? "manual"
|
||||||
|
: "trading_account",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="trading_account">
|
||||||
|
交易账户总资金
|
||||||
|
</option>
|
||||||
|
<option value="manual">单独设置本金</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{riskCapitalSource === "manual" ? (
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskManCap">单独本金(USDT)</label>
|
||||||
|
<input
|
||||||
|
id="riskManCap"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="100"
|
||||||
|
min="1"
|
||||||
|
value={riskManualCapital}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRiskManualCapital(Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskPct">亏损幅度(%)</label>
|
||||||
|
<input
|
||||||
|
id="riskPct"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
value={riskLossPct}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRiskLossPct(Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskUsdt">亏损值(USDT)</label>
|
||||||
|
<input
|
||||||
|
id="riskUsdt"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0.1"
|
||||||
|
value={riskLossUsdt}
|
||||||
|
onChange={(e) =>
|
||||||
|
setRiskLossUsdt(Number(e.target.value))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field" style={{ gridColumn: "1 / -1" }}>
|
||||||
|
<label>以损定仓预览(保存后刷新;开仓前再算一次)</label>
|
||||||
|
<div className="mono" style={{ fontSize: 12, opacity: 0.9 }}>
|
||||||
|
{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 ?? "—")}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="perp">永续 ETH 数量</label>
|
<label htmlFor="perp">永续 ETH 数量</label>
|
||||||
<input
|
<input
|
||||||
@@ -591,6 +756,7 @@ export default function SettingsPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
|
disabled={sizingMode === "risk_based"}
|
||||||
value={perpQty}
|
value={perpQty}
|
||||||
onChange={(e) => setPerpQty(Number(e.target.value))}
|
onChange={(e) => setPerpQty(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
@@ -603,6 +769,7 @@ export default function SettingsPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
|
disabled={sizingMode === "risk_based"}
|
||||||
value={optQty}
|
value={optQty}
|
||||||
onChange={(e) => setOptQty(Number(e.target.value))}
|
onChange={(e) => setOptQty(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
@@ -709,7 +876,10 @@ export default function SettingsPage() {
|
|||||||
<select
|
<select
|
||||||
id="exitMode"
|
id="exitMode"
|
||||||
className="mono"
|
className="mono"
|
||||||
value={exitMode}
|
disabled={sizingMode === "risk_based"}
|
||||||
|
value={
|
||||||
|
sizingMode === "risk_based" ? "fixed_usdt" : exitMode
|
||||||
|
}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setExitMode(
|
setExitMode(
|
||||||
e.target.value === "premium_multiple"
|
e.target.value === "premium_multiple"
|
||||||
@@ -722,15 +892,19 @@ export default function SettingsPage() {
|
|||||||
<option value="premium_multiple">权利金倍数</option>
|
<option value="premium_multiple">权利金倍数</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{exitMode === "fixed_usdt" ? (
|
{sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="netTarget">净盈利出场目标(USDT)</label>
|
<label htmlFor="netTarget">
|
||||||
|
净盈利出场目标(USDT)
|
||||||
|
{sizingMode === "risk_based" ? " · 以损定仓=15×k" : ""}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="netTarget"
|
id="netTarget"
|
||||||
className="mono"
|
className="mono"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
|
disabled={sizingMode === "risk_based"}
|
||||||
value={netTarget}
|
value={netTarget}
|
||||||
onChange={(e) => setNetTarget(Number(e.target.value))}
|
onChange={(e) => setNetTarget(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user