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:
dekun
2026-07-29 23:32:42 +08:00
parent 0b51aa15ff
commit 3a4c8d639c
9 changed files with 803 additions and 16 deletions
+115
View File
@@ -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))
+35
View File
@@ -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}-%",))
)