Polish risk sizing: 2dp money preview, editable ratio/exit, plan badge.
Exit unit and perp/option units scale with k; plan page shows 以损定仓. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -54,6 +54,9 @@ KEYS = (
|
|||||||
"risk_loss_usdt",
|
"risk_loss_usdt",
|
||||||
"risk_capital_source",
|
"risk_capital_source",
|
||||||
"risk_manual_capital_usdt",
|
"risk_manual_capital_usdt",
|
||||||
|
"risk_perp_unit",
|
||||||
|
"risk_option_unit",
|
||||||
|
"risk_exit_unit",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +91,9 @@ class StrategySettingsBody(BaseModel):
|
|||||||
default=None, pattern="^(trading_account|manual)$"
|
default=None, pattern="^(trading_account|manual)$"
|
||||||
)
|
)
|
||||||
risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000)
|
risk_manual_capital_usdt: float | None = Field(default=None, ge=1, le=100_000_000)
|
||||||
|
risk_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||||
|
risk_option_unit: float | None = Field(default=None, ge=0.01, le=100)
|
||||||
|
risk_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(raw: str | None, default: bool) -> bool:
|
def _as_bool(raw: str | None, default: bool) -> bool:
|
||||||
@@ -244,6 +250,9 @@ def _read_settings() -> dict:
|
|||||||
"risk_manual_capital_usdt": float(
|
"risk_manual_capital_usdt": float(
|
||||||
db.get_setting("risk_manual_capital_usdt", "10000") or 10000
|
db.get_setting("risk_manual_capital_usdt", "10000") or 10000
|
||||||
),
|
),
|
||||||
|
"risk_perp_unit": float(db.get_setting("risk_perp_unit", "1") or 1),
|
||||||
|
"risk_option_unit": float(db.get_setting("risk_option_unit", "2") or 2),
|
||||||
|
"risk_exit_unit": float(db.get_setting("risk_exit_unit", "15") or 15),
|
||||||
"risk_sizing_preview": _risk_preview_safe(),
|
"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,
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ class StrategyEngine:
|
|||||||
fixed_perp = "long"
|
fixed_perp = "long"
|
||||||
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
|
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
|
||||||
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
|
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
|
||||||
|
sizing_mode = (
|
||||||
|
sm
|
||||||
|
if (
|
||||||
|
sm := str(
|
||||||
|
self.ledger.get_setting_str("sizing_mode", "manual") or "manual"
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.lower()
|
||||||
|
)
|
||||||
|
in ("manual", "risk_based")
|
||||||
|
else "manual"
|
||||||
|
)
|
||||||
|
risk_perp_unit = self.ledger.get_setting_float("risk_perp_unit", 1.0)
|
||||||
|
risk_option_unit = self.ledger.get_setting_float("risk_option_unit", 2.0)
|
||||||
|
risk_exit_unit = self.ledger.get_setting_float("risk_exit_unit", 15.0)
|
||||||
|
risk_last_k = self.ledger.get_setting_float("risk_last_k", 0.0)
|
||||||
rest_until = row["rest_until_ms"]
|
rest_until = row["rest_until_ms"]
|
||||||
rest_left = 0
|
rest_left = 0
|
||||||
if rest_until:
|
if rest_until:
|
||||||
@@ -150,6 +166,12 @@ class StrategyEngine:
|
|||||||
"perp_margin_mode": perp_mm,
|
"perp_margin_mode": perp_mm,
|
||||||
"perp_qty_eth": perp_qty,
|
"perp_qty_eth": perp_qty,
|
||||||
"option_qty_eth": opt_qty,
|
"option_qty_eth": opt_qty,
|
||||||
|
"sizing_mode": sizing_mode,
|
||||||
|
"risk_based": sizing_mode == "risk_based",
|
||||||
|
"risk_perp_unit": risk_perp_unit,
|
||||||
|
"risk_option_unit": risk_option_unit,
|
||||||
|
"risk_exit_unit": risk_exit_unit,
|
||||||
|
"risk_last_k": risk_last_k if risk_last_k > 0 else None,
|
||||||
"min_option_hours": min_hours,
|
"min_option_hours": min_hours,
|
||||||
"min_option_leverage": min_opt_lev,
|
"min_option_leverage": min_opt_lev,
|
||||||
"atm_open_offset_enabled": atm_off_on,
|
"atm_open_offset_enabled": atm_off_on,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from ..sim.ledger import Ledger
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 标准组基准(k=1)
|
# 标准组基准默认(k=1);可由设置 risk_*_unit 覆盖
|
||||||
BASE_PERP_ETH = 1.0
|
BASE_PERP_ETH = 1.0
|
||||||
BASE_OPTION_ETH = 2.0
|
BASE_OPTION_ETH = 2.0
|
||||||
BASE_EXIT_USDT = 15.0
|
BASE_EXIT_USDT = 15.0
|
||||||
@@ -21,6 +21,26 @@ MIN_K = 0.1
|
|||||||
FEE_LEG_COUNT = 3 # 永续开/平 + 期权一次
|
FEE_LEG_COUNT = 3 # 永续开/平 + 期权一次
|
||||||
|
|
||||||
|
|
||||||
|
def _round2(x: float | None) -> float | None:
|
||||||
|
if x is None or not math.isfinite(float(x)):
|
||||||
|
return None
|
||||||
|
return round(float(x) + 0.0, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def read_risk_units(ledger: Ledger) -> tuple[float, float, float]:
|
||||||
|
"""永续名义单位 / 期权名义单位 / 出场基数(k=1)。"""
|
||||||
|
perp_u = ledger.get_setting_float("risk_perp_unit", BASE_PERP_ETH)
|
||||||
|
opt_u = ledger.get_setting_float("risk_option_unit", BASE_OPTION_ETH)
|
||||||
|
exit_u = ledger.get_setting_float("risk_exit_unit", BASE_EXIT_USDT)
|
||||||
|
if perp_u <= 0:
|
||||||
|
perp_u = BASE_PERP_ETH
|
||||||
|
if opt_u <= 0:
|
||||||
|
opt_u = BASE_OPTION_ETH
|
||||||
|
if exit_u <= 0:
|
||||||
|
exit_u = BASE_EXIT_USDT
|
||||||
|
return float(perp_u), float(opt_u), float(exit_u)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class RiskSizingResult:
|
class RiskSizingResult:
|
||||||
ok: bool
|
ok: bool
|
||||||
@@ -36,6 +56,9 @@ class RiskSizingResult:
|
|||||||
net_profit_target: float | None = None
|
net_profit_target: float | None = None
|
||||||
index_px: float | None = None
|
index_px: float | None = None
|
||||||
option_ask: float | None = None
|
option_ask: float | None = None
|
||||||
|
perp_unit: float | None = None
|
||||||
|
option_unit: float | None = None
|
||||||
|
exit_unit: float | None = None
|
||||||
|
|
||||||
|
|
||||||
def is_risk_based(ledger: Ledger | None = None) -> bool:
|
def is_risk_based(ledger: Ledger | None = None) -> bool:
|
||||||
@@ -51,9 +74,15 @@ def floor_k_1dp(k_raw: float) -> float:
|
|||||||
return math.floor(k_raw * 10.0 + 1e-12) / 10.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:
|
def unit_cost(
|
||||||
"""k=1 时估算最大亏损 = 权利金(2ETH) + 手续费粗估。"""
|
*,
|
||||||
premium_unit = float(option_ask) * BASE_OPTION_ETH
|
index_px: float,
|
||||||
|
option_ask: float,
|
||||||
|
fee_rate: float,
|
||||||
|
option_unit: float = BASE_OPTION_ETH,
|
||||||
|
) -> float:
|
||||||
|
"""k=1 时估算最大亏损 = 权利金(option_unit ETH) + 手续费粗估。"""
|
||||||
|
premium_unit = float(option_ask) * float(option_unit)
|
||||||
fee_unit = float(index_px) * float(fee_rate) * FEE_LEG_COUNT
|
fee_unit = float(index_px) * float(fee_rate) * FEE_LEG_COUNT
|
||||||
return premium_unit + fee_unit
|
return premium_unit + fee_unit
|
||||||
|
|
||||||
@@ -64,6 +93,9 @@ def compute_k(
|
|||||||
index_px: float,
|
index_px: float,
|
||||||
option_ask: float,
|
option_ask: float,
|
||||||
fee_rate: float,
|
fee_rate: float,
|
||||||
|
perp_unit: float = BASE_PERP_ETH,
|
||||||
|
option_unit: float = BASE_OPTION_ETH,
|
||||||
|
exit_unit: float = BASE_EXIT_USDT,
|
||||||
) -> RiskSizingResult:
|
) -> RiskSizingResult:
|
||||||
if budget is None or budget <= 0 or not math.isfinite(budget):
|
if budget is None or budget <= 0 or not math.isfinite(budget):
|
||||||
return RiskSizingResult(ok=False, detail="以损定仓预算无效(须 > 0)")
|
return RiskSizingResult(ok=False, detail="以损定仓预算无效(须 > 0)")
|
||||||
@@ -71,8 +103,15 @@ def compute_k(
|
|||||||
return RiskSizingResult(ok=False, detail="以损定仓缺少有效指数价")
|
return RiskSizingResult(ok=False, detail="以损定仓缺少有效指数价")
|
||||||
if option_ask is None or option_ask <= 0 or not math.isfinite(option_ask):
|
if option_ask is None or option_ask <= 0 or not math.isfinite(option_ask):
|
||||||
return RiskSizingResult(ok=False, detail="以损定仓缺少有效期权卖一")
|
return RiskSizingResult(ok=False, detail="以损定仓缺少有效期权卖一")
|
||||||
|
if option_unit <= 0 or perp_unit <= 0 or exit_unit <= 0:
|
||||||
|
return RiskSizingResult(ok=False, detail="以损定仓比例/出场基数须 > 0")
|
||||||
|
|
||||||
cost1 = unit_cost(index_px=index_px, option_ask=option_ask, fee_rate=fee_rate)
|
cost1 = unit_cost(
|
||||||
|
index_px=index_px,
|
||||||
|
option_ask=option_ask,
|
||||||
|
fee_rate=fee_rate,
|
||||||
|
option_unit=option_unit,
|
||||||
|
)
|
||||||
if cost1 <= 1e-12:
|
if cost1 <= 1e-12:
|
||||||
return RiskSizingResult(ok=False, detail="以损定仓单位成本无效")
|
return RiskSizingResult(ok=False, detail="以损定仓单位成本无效")
|
||||||
|
|
||||||
@@ -85,15 +124,18 @@ def compute_k(
|
|||||||
f"以损定仓算出 k={k_raw:.4f},向下取整后 < {MIN_K},"
|
f"以损定仓算出 k={k_raw:.4f},向下取整后 < {MIN_K},"
|
||||||
f"预算 {budget:.2f}U 不足以开最小仓(单位成本≈{cost1:.2f}U)"
|
f"预算 {budget:.2f}U 不足以开最小仓(单位成本≈{cost1:.2f}U)"
|
||||||
),
|
),
|
||||||
budget=float(budget),
|
budget=_round2(float(budget)),
|
||||||
k=k,
|
k=k,
|
||||||
index_px=float(index_px),
|
index_px=float(index_px),
|
||||||
option_ask=float(option_ask),
|
option_ask=float(option_ask),
|
||||||
|
perp_unit=float(perp_unit),
|
||||||
|
option_unit=float(option_unit),
|
||||||
|
exit_unit=float(exit_unit),
|
||||||
)
|
)
|
||||||
|
|
||||||
# 若浮点导致仍略超,再降一档
|
# 若浮点导致仍略超,再降一档
|
||||||
while k >= MIN_K - 1e-12:
|
while k >= MIN_K - 1e-12:
|
||||||
prem = float(option_ask) * BASE_OPTION_ETH * k
|
prem = float(option_ask) * float(option_unit) * k
|
||||||
fee = float(index_px) * float(fee_rate) * FEE_LEG_COUNT * k
|
fee = float(index_px) * float(fee_rate) * FEE_LEG_COUNT * k
|
||||||
mx = prem + fee
|
mx = prem + fee
|
||||||
if mx <= float(budget) + 1e-6:
|
if mx <= float(budget) + 1e-6:
|
||||||
@@ -101,24 +143,30 @@ def compute_k(
|
|||||||
ok=True,
|
ok=True,
|
||||||
detail="ok",
|
detail="ok",
|
||||||
k=k,
|
k=k,
|
||||||
budget=float(budget),
|
budget=_round2(float(budget)),
|
||||||
premium_est=prem,
|
premium_est=_round2(prem),
|
||||||
fee_est=fee,
|
fee_est=_round2(fee),
|
||||||
max_loss=mx,
|
max_loss=_round2(mx),
|
||||||
perp_qty_eth=round(BASE_PERP_ETH * k, 4),
|
perp_qty_eth=round(float(perp_unit) * k, 4),
|
||||||
option_qty_eth=round(BASE_OPTION_ETH * k, 4),
|
option_qty_eth=round(float(option_unit) * k, 4),
|
||||||
net_profit_target=round(BASE_EXIT_USDT * k, 4),
|
net_profit_target=_round2(float(exit_unit) * k),
|
||||||
index_px=float(index_px),
|
index_px=float(index_px),
|
||||||
option_ask=float(option_ask),
|
option_ask=float(option_ask),
|
||||||
|
perp_unit=float(perp_unit),
|
||||||
|
option_unit=float(option_unit),
|
||||||
|
exit_unit=float(exit_unit),
|
||||||
)
|
)
|
||||||
k = round(k - 0.1, 1)
|
k = round(k - 0.1, 1)
|
||||||
|
|
||||||
return RiskSizingResult(
|
return RiskSizingResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
detail=f"以损定仓无法在预算 {budget:.2f}U 内找到合规 k",
|
detail=f"以损定仓无法在预算 {budget:.2f}U 内找到合规 k",
|
||||||
budget=float(budget),
|
budget=_round2(float(budget)),
|
||||||
index_px=float(index_px),
|
index_px=float(index_px),
|
||||||
option_ask=float(option_ask),
|
option_ask=float(option_ask),
|
||||||
|
perp_unit=float(perp_unit),
|
||||||
|
option_unit=float(option_unit),
|
||||||
|
exit_unit=float(exit_unit),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -220,6 +268,7 @@ def compute_risk_sizing(
|
|||||||
ledger = Ledger(database)
|
ledger = Ledger(database)
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
|
fee_rate = ledger.get_setting_float("fee_rate", s.fee_rate)
|
||||||
|
perp_u, opt_u, exit_u = read_risk_units(ledger)
|
||||||
budget, bud_detail, capital = resolve_budget(database)
|
budget, bud_detail, capital = resolve_budget(database)
|
||||||
if budget is None:
|
if budget is None:
|
||||||
return RiskSizingResult(ok=False, detail=f"以损定仓预算失败: {bud_detail}")
|
return RiskSizingResult(ok=False, detail=f"以损定仓预算失败: {bud_detail}")
|
||||||
@@ -228,23 +277,29 @@ def compute_risk_sizing(
|
|||||||
index_px=index_px,
|
index_px=index_px,
|
||||||
option_ask=option_ask,
|
option_ask=option_ask,
|
||||||
fee_rate=fee_rate,
|
fee_rate=fee_rate,
|
||||||
|
perp_unit=perp_u,
|
||||||
|
option_unit=opt_u,
|
||||||
|
exit_unit=exit_u,
|
||||||
)
|
)
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
return RiskSizingResult(
|
return RiskSizingResult(
|
||||||
ok=False,
|
ok=False,
|
||||||
detail=r.detail,
|
detail=r.detail,
|
||||||
budget=budget,
|
budget=_round2(budget),
|
||||||
capital_base=capital,
|
capital_base=_round2(capital) if capital is not None else None,
|
||||||
index_px=float(index_px),
|
index_px=float(index_px),
|
||||||
option_ask=float(option_ask),
|
option_ask=float(option_ask),
|
||||||
k=r.k,
|
k=r.k,
|
||||||
|
perp_unit=perp_u,
|
||||||
|
option_unit=opt_u,
|
||||||
|
exit_unit=exit_u,
|
||||||
)
|
)
|
||||||
return RiskSizingResult(
|
return RiskSizingResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
detail=r.detail,
|
detail=r.detail,
|
||||||
k=r.k,
|
k=r.k,
|
||||||
budget=budget,
|
budget=_round2(budget),
|
||||||
capital_base=capital,
|
capital_base=_round2(capital) if capital is not None else None,
|
||||||
premium_est=r.premium_est,
|
premium_est=r.premium_est,
|
||||||
fee_est=r.fee_est,
|
fee_est=r.fee_est,
|
||||||
max_loss=r.max_loss,
|
max_loss=r.max_loss,
|
||||||
@@ -253,6 +308,9 @@ def compute_risk_sizing(
|
|||||||
net_profit_target=r.net_profit_target,
|
net_profit_target=r.net_profit_target,
|
||||||
index_px=r.index_px,
|
index_px=r.index_px,
|
||||||
option_ask=r.option_ask,
|
option_ask=r.option_ask,
|
||||||
|
perp_unit=perp_u,
|
||||||
|
option_unit=opt_u,
|
||||||
|
exit_unit=exit_u,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -272,7 +330,7 @@ def apply_risk_sizing_to_ledger(
|
|||||||
if not r.ok:
|
if not r.ok:
|
||||||
return r
|
return r
|
||||||
|
|
||||||
# 以损定仓强制 fixed_usdt,保证出场 15×k
|
# 以损定仓强制 fixed_usdt;出场 = exit_unit × k
|
||||||
database.set_setting("exit_mode", "fixed_usdt")
|
database.set_setting("exit_mode", "fixed_usdt")
|
||||||
database.set_setting("perp_qty_eth", str(r.perp_qty_eth))
|
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("option_qty_eth", str(r.option_qty_eth))
|
||||||
@@ -280,7 +338,7 @@ def apply_risk_sizing_to_ledger(
|
|||||||
database.set_setting("risk_last_k", str(r.k))
|
database.set_setting("risk_last_k", str(r.k))
|
||||||
database.set_setting(
|
database.set_setting(
|
||||||
"risk_last_max_loss",
|
"risk_last_max_loss",
|
||||||
f"{r.max_loss:.6f}" if r.max_loss is not None else "",
|
f"{r.max_loss:.2f}" if r.max_loss is not None else "",
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"risk_sizing applied k=%.1f perp=%.4f opt=%.4f exit=%.4f max_loss=%.4f budget=%.4f",
|
"risk_sizing applied k=%.1f perp=%.4f opt=%.4f exit=%.4f max_loss=%.4f budget=%.4f",
|
||||||
@@ -317,6 +375,7 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
|
|||||||
out["detail"] = "暂无指数或期权卖一,无法预览"
|
out["detail"] = "暂无指数或期权卖一,无法预览"
|
||||||
return out
|
return out
|
||||||
r = compute_risk_sizing(index_px=float(idx), option_ask=float(ask), db=database)
|
r = compute_risk_sizing(index_px=float(idx), option_ask=float(ask), db=database)
|
||||||
|
perp_u, opt_u, exit_u = read_risk_units(ledger)
|
||||||
out.update(
|
out.update(
|
||||||
{
|
{
|
||||||
"ok": r.ok,
|
"ok": r.ok,
|
||||||
@@ -332,6 +391,9 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
|
|||||||
"net_profit_target": r.net_profit_target,
|
"net_profit_target": r.net_profit_target,
|
||||||
"index_px": r.index_px,
|
"index_px": r.index_px,
|
||||||
"option_ask": r.option_ask,
|
"option_ask": r.option_ask,
|
||||||
|
"perp_unit": perp_u,
|
||||||
|
"option_unit": opt_u,
|
||||||
|
"exit_unit": exit_u,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -29,6 +29,33 @@ def test_compute_k_scales_1_2_15() -> None:
|
|||||||
assert r.option_qty_eth == BASE_OPTION_ETH
|
assert r.option_qty_eth == BASE_OPTION_ETH
|
||||||
assert r.net_profit_target == BASE_EXIT_USDT
|
assert r.net_profit_target == BASE_EXIT_USDT
|
||||||
assert r.max_loss is not None and r.max_loss <= 43.0 + 1e-6
|
assert r.max_loss is not None and r.max_loss <= 43.0 + 1e-6
|
||||||
|
assert r.budget == 43.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_k_custom_units() -> None:
|
||||||
|
# option_unit=4 → premium unit = 20*4=80; fee=3; cost=83; budget=83 → k=1
|
||||||
|
r = compute_k(
|
||||||
|
budget=83.0,
|
||||||
|
index_px=2000.0,
|
||||||
|
option_ask=20.0,
|
||||||
|
fee_rate=0.0005,
|
||||||
|
perp_unit=0.5,
|
||||||
|
option_unit=4.0,
|
||||||
|
exit_unit=30.0,
|
||||||
|
)
|
||||||
|
assert r.ok
|
||||||
|
assert r.k == 1.0
|
||||||
|
assert r.perp_qty_eth == 0.5
|
||||||
|
assert r.option_qty_eth == 4.0
|
||||||
|
assert r.net_profit_target == 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_money_rounds_2dp() -> None:
|
||||||
|
r = compute_k(budget=50.123456, index_px=1900.0, option_ask=18.5, fee_rate=0.0005)
|
||||||
|
assert r.ok
|
||||||
|
assert r.budget == round(50.123456, 2)
|
||||||
|
assert r.max_loss is not None
|
||||||
|
assert abs(r.max_loss * 100 - round(r.max_loss * 100)) < 1e-9
|
||||||
|
|
||||||
|
|
||||||
def test_compute_k_never_exceeds_budget() -> None:
|
def test_compute_k_never_exceeds_budget() -> None:
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-29 — 以损定仓:预览两位小数 / 比例与出场可配 / 计划页标识
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
1. 预览「预算」「估亏」统一保留两位小数。
|
||||||
|
2. 以损定仓可设永续/期权比例与出场基数(k=1);改出场后按比例联动名义。
|
||||||
|
3. 对冲计划选约条件显示「以损定仓」及比例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-29 — 以损定仓(智能调仓)
|
## 2026-07-29 — 以损定仓(智能调仓)
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -329,6 +329,9 @@ export type StrategySettings = {
|
|||||||
risk_loss_usdt?: number;
|
risk_loss_usdt?: number;
|
||||||
risk_capital_source?: "trading_account" | "manual";
|
risk_capital_source?: "trading_account" | "manual";
|
||||||
risk_manual_capital_usdt?: number;
|
risk_manual_capital_usdt?: number;
|
||||||
|
risk_perp_unit?: number;
|
||||||
|
risk_option_unit?: number;
|
||||||
|
risk_exit_unit?: number;
|
||||||
risk_sizing_preview?: Record<string, unknown>;
|
risk_sizing_preview?: Record<string, unknown>;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -151,7 +151,9 @@ export default function PlanPage() {
|
|||||||
const exitRuleLabel =
|
const exitRuleLabel =
|
||||||
exitMode === "premium_multiple"
|
exitMode === "premium_multiple"
|
||||||
? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`
|
? `权利金×${fmt(plan?.premium_exit_multiple ?? 1, 2)}`
|
||||||
: `固定 ${fmt(plan?.net_profit_target ?? 15)} U`;
|
: plan?.risk_based || plan?.sizing_mode === "risk_based"
|
||||||
|
? `以损定仓 · 固定 ${fmt(exitTarget)} U(基数${fmt(plan?.risk_exit_unit ?? 15)})`
|
||||||
|
: `固定 ${fmt(plan?.net_profit_target ?? 15)} U`;
|
||||||
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
|
const phaseLabel = PHASE_ZH[plan?.phase || ""] || plan?.phase || "—";
|
||||||
const atmRule = plan?.fixed_direction_enabled
|
const atmRule = plan?.fixed_direction_enabled
|
||||||
? plan?.fixed_perp_side === "short"
|
? plan?.fixed_perp_side === "short"
|
||||||
@@ -343,8 +345,20 @@ export default function PlanPage() {
|
|||||||
<div className="kv">
|
<div className="kv">
|
||||||
<span>选约条件</span>
|
<span>选约条件</span>
|
||||||
<span className="mono">
|
<span className="mono">
|
||||||
永续{fmt(plan?.perp_qty_eth ?? 1, 0)}ETH/期权
|
{plan?.risk_based || plan?.sizing_mode === "risk_based" ? (
|
||||||
{fmt(plan?.option_qty_eth ?? 2, 0)}ETH · 剩余≥
|
<>
|
||||||
|
<span className="status-running">以损定仓</span>
|
||||||
|
{" · "}
|
||||||
|
比例{fmt(plan?.risk_perp_unit ?? 1, 2)}:
|
||||||
|
{fmt(plan?.risk_option_unit ?? 2, 2)}
|
||||||
|
{plan?.risk_last_k
|
||||||
|
? ` · k=${fmt(plan.risk_last_k, 1)}`
|
||||||
|
: ""}
|
||||||
|
{" · "}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
永续{fmt(plan?.perp_qty_eth ?? 1, 2)}ETH/期权
|
||||||
|
{fmt(plan?.option_qty_eth ?? 2, 2)}ETH · 剩余≥
|
||||||
{fmt(plan?.min_option_hours, 0)}h · 期权杠杆≥
|
{fmt(plan?.min_option_hours, 0)}h · 期权杠杆≥
|
||||||
{fmt(plan?.min_option_leverage, 0)}x
|
{fmt(plan?.min_option_leverage, 0)}x
|
||||||
{plan?.fixed_direction_enabled
|
{plan?.fixed_direction_enabled
|
||||||
|
|||||||
+178
-14
@@ -94,6 +94,9 @@ export default function SettingsPage() {
|
|||||||
"trading_account" | "manual"
|
"trading_account" | "manual"
|
||||||
>("trading_account");
|
>("trading_account");
|
||||||
const [riskManualCapital, setRiskManualCapital] = useState(10000);
|
const [riskManualCapital, setRiskManualCapital] = useState(10000);
|
||||||
|
const [riskPerpUnit, setRiskPerpUnit] = useState(1);
|
||||||
|
const [riskOptUnit, setRiskOptUnit] = useState(2);
|
||||||
|
const [riskExitUnit, setRiskExitUnit] = useState(15);
|
||||||
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
|
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -194,6 +197,9 @@ export default function SettingsPage() {
|
|||||||
s.risk_capital_source === "manual" ? "manual" : "trading_account",
|
s.risk_capital_source === "manual" ? "manual" : "trading_account",
|
||||||
);
|
);
|
||||||
setRiskManualCapital(s.risk_manual_capital_usdt ?? 10000);
|
setRiskManualCapital(s.risk_manual_capital_usdt ?? 10000);
|
||||||
|
setRiskPerpUnit(s.risk_perp_unit ?? 1);
|
||||||
|
setRiskOptUnit(s.risk_option_unit ?? 2);
|
||||||
|
setRiskExitUnit(s.risk_exit_unit ?? 15);
|
||||||
setRiskPreview(
|
setRiskPreview(
|
||||||
s.risk_sizing_preview && typeof s.risk_sizing_preview === "object"
|
s.risk_sizing_preview && typeof s.risk_sizing_preview === "object"
|
||||||
? s.risk_sizing_preview
|
? s.risk_sizing_preview
|
||||||
@@ -349,6 +355,9 @@ export default function SettingsPage() {
|
|||||||
risk_loss_usdt: riskLossUsdt,
|
risk_loss_usdt: riskLossUsdt,
|
||||||
risk_capital_source: riskCapitalSource,
|
risk_capital_source: riskCapitalSource,
|
||||||
risk_manual_capital_usdt: riskManualCapital,
|
risk_manual_capital_usdt: riskManualCapital,
|
||||||
|
risk_perp_unit: riskPerpUnit,
|
||||||
|
risk_option_unit: riskOptUnit,
|
||||||
|
risk_exit_unit: riskExitUnit,
|
||||||
exchange,
|
exchange,
|
||||||
};
|
};
|
||||||
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
|
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
|
||||||
@@ -739,17 +748,138 @@ export default function SettingsPage() {
|
|||||||
<div className="field" style={{ gridColumn: "1 / -1" }}>
|
<div className="field" style={{ gridColumn: "1 / -1" }}>
|
||||||
<label>以损定仓预览(保存后刷新;开仓前再算一次)</label>
|
<label>以损定仓预览(保存后刷新;开仓前再算一次)</label>
|
||||||
<div className="mono" style={{ fontSize: 12, opacity: 0.9 }}>
|
<div className="mono" style={{ fontSize: 12, opacity: 0.9 }}>
|
||||||
{riskPreview == null
|
{(() => {
|
||||||
? "—"
|
if (riskPreview == null) return "—";
|
||||||
: riskPreview.ok === false
|
if (riskPreview.ok === false) {
|
||||||
? String(riskPreview.detail || "预览不可用")
|
return 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 ?? "—")}`}
|
}
|
||||||
|
const k = Number(riskPreview.k);
|
||||||
|
const bud = Number(riskPreview.budget);
|
||||||
|
const mx = Number(riskPreview.max_loss);
|
||||||
|
const pk = Number.isFinite(k) ? k : null;
|
||||||
|
const perp =
|
||||||
|
pk != null
|
||||||
|
? Number((riskPerpUnit * pk).toFixed(4))
|
||||||
|
: riskPreview.perp_qty_eth;
|
||||||
|
const opt =
|
||||||
|
pk != null
|
||||||
|
? Number((riskOptUnit * pk).toFixed(4))
|
||||||
|
: riskPreview.option_qty_eth;
|
||||||
|
const exit =
|
||||||
|
pk != null
|
||||||
|
? Number((riskExitUnit * pk).toFixed(2))
|
||||||
|
: riskPreview.net_profit_target;
|
||||||
|
const budS = Number.isFinite(bud)
|
||||||
|
? bud.toFixed(2)
|
||||||
|
: "—";
|
||||||
|
const mxS = Number.isFinite(mx) ? mx.toFixed(2) : "—";
|
||||||
|
return `k=${pk ?? "—"} · 预算=${budS}U · 估亏=${mxS}U · 永续=${perp ?? "—"} · 期权=${opt ?? "—"} · 出场=${exit ?? "—"}`;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskPerpU">永续比例(k=1)</label>
|
||||||
|
<input
|
||||||
|
id="riskPerpU"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
value={riskPerpUnit}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setRiskPerpUnit(v);
|
||||||
|
const k = Number(riskPreview?.k);
|
||||||
|
if (Number.isFinite(k) && k > 0) {
|
||||||
|
setPerpQty(Number((v * k).toFixed(4)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskOptU">期权比例(k=1)</label>
|
||||||
|
<input
|
||||||
|
id="riskOptU"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.01"
|
||||||
|
value={riskOptUnit}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setRiskOptUnit(v);
|
||||||
|
const k = Number(riskPreview?.k);
|
||||||
|
if (Number.isFinite(k) && k > 0) {
|
||||||
|
setOptQty(Number((v * k).toFixed(4)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskExitU">
|
||||||
|
出场资金(k=1;改后按比例联动)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="riskExitU"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0.1"
|
||||||
|
value={riskExitUnit}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
setRiskExitUnit(v);
|
||||||
|
const k = Number(riskPreview?.k);
|
||||||
|
if (Number.isFinite(k) && k > 0) {
|
||||||
|
setNetTarget(Number((v * k).toFixed(2)));
|
||||||
|
setPerpQty(Number((riskPerpUnit * k).toFixed(4)));
|
||||||
|
setOptQty(Number((riskOptUnit * k).toFixed(4)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="riskExitAbs">
|
||||||
|
当前出场(= 基数×k,可改后反推基数)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="riskExitAbs"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0.1"
|
||||||
|
value={
|
||||||
|
(() => {
|
||||||
|
const k = Number(riskPreview?.k);
|
||||||
|
if (Number.isFinite(k) && k > 0) {
|
||||||
|
return Number((riskExitUnit * k).toFixed(2));
|
||||||
|
}
|
||||||
|
return Number(riskExitUnit.toFixed(2));
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const abs = Number(e.target.value);
|
||||||
|
const k = Number(riskPreview?.k);
|
||||||
|
if (Number.isFinite(k) && k > 0) {
|
||||||
|
const unit = Number((abs / k).toFixed(4));
|
||||||
|
setRiskExitUnit(unit);
|
||||||
|
setNetTarget(Number(abs.toFixed(2)));
|
||||||
|
setPerpQty(Number((riskPerpUnit * k).toFixed(4)));
|
||||||
|
setOptQty(Number((riskOptUnit * k).toFixed(4)));
|
||||||
|
} else {
|
||||||
|
setRiskExitUnit(abs);
|
||||||
|
setNetTarget(abs);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="perp">永续 ETH 数量</label>
|
<label htmlFor="perp">
|
||||||
|
永续 ETH 数量
|
||||||
|
{sizingMode === "risk_based" ? "(随比例×k)" : ""}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="perp"
|
id="perp"
|
||||||
className="mono"
|
className="mono"
|
||||||
@@ -757,12 +887,22 @@ export default function SettingsPage() {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
disabled={sizingMode === "risk_based"}
|
disabled={sizingMode === "risk_based"}
|
||||||
value={perpQty}
|
value={
|
||||||
|
sizingMode === "risk_based" &&
|
||||||
|
Number(riskPreview?.k) > 0
|
||||||
|
? Number(
|
||||||
|
(riskPerpUnit * Number(riskPreview?.k)).toFixed(4),
|
||||||
|
)
|
||||||
|
: perpQty
|
||||||
|
}
|
||||||
onChange={(e) => setPerpQty(Number(e.target.value))}
|
onChange={(e) => setPerpQty(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="opt">期权 ETH 数量</label>
|
<label htmlFor="opt">
|
||||||
|
期权 ETH 数量
|
||||||
|
{sizingMode === "risk_based" ? "(随比例×k)" : ""}
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="opt"
|
id="opt"
|
||||||
className="mono"
|
className="mono"
|
||||||
@@ -770,7 +910,14 @@ export default function SettingsPage() {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
disabled={sizingMode === "risk_based"}
|
disabled={sizingMode === "risk_based"}
|
||||||
value={optQty}
|
value={
|
||||||
|
sizingMode === "risk_based" &&
|
||||||
|
Number(riskPreview?.k) > 0
|
||||||
|
? Number(
|
||||||
|
(riskOptUnit * Number(riskPreview?.k)).toFixed(4),
|
||||||
|
)
|
||||||
|
: optQty
|
||||||
|
}
|
||||||
onChange={(e) => setOptQty(Number(e.target.value))}
|
onChange={(e) => setOptQty(Number(e.target.value))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -895,8 +1042,9 @@ export default function SettingsPage() {
|
|||||||
{sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
|
{sizingMode === "risk_based" || exitMode === "fixed_usdt" ? (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="netTarget">
|
<label htmlFor="netTarget">
|
||||||
净盈利出场目标(USDT)
|
{sizingMode === "risk_based"
|
||||||
{sizingMode === "risk_based" ? " · 以损定仓=15×k" : ""}
|
? "出场资金(k=1 基数)"
|
||||||
|
: "净盈利出场目标(USDT)"}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="netTarget"
|
id="netTarget"
|
||||||
@@ -904,9 +1052,25 @@ export default function SettingsPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
min="0.1"
|
min="0.1"
|
||||||
disabled={sizingMode === "risk_based"}
|
value={
|
||||||
value={netTarget}
|
sizingMode === "risk_based" ? riskExitUnit : netTarget
|
||||||
onChange={(e) => setNetTarget(Number(e.target.value))}
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
if (sizingMode === "risk_based") {
|
||||||
|
setRiskExitUnit(v);
|
||||||
|
const k = Number(riskPreview?.k);
|
||||||
|
if (Number.isFinite(k) && k > 0) {
|
||||||
|
setNetTarget(Number((v * k).toFixed(2)));
|
||||||
|
setPerpQty(
|
||||||
|
Number((riskPerpUnit * k).toFixed(4)),
|
||||||
|
);
|
||||||
|
setOptQty(Number((riskOptUnit * k).toFixed(4)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNetTarget(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user