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}-%",))
)
+35 -8
View File
@@ -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)
+337
View File
@@ -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
+53
View File
@@ -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