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",
|
||||
"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))
|
||||
|
||||
@@ -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}-%",))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user