Files
eth_hedge_sim/backend/app/strategy/risk_sizing.py
T

410 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""以损定仓:按可承受最大亏损反推标准组倍数 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);可由设置 risk_*_unit 覆盖
BASE_PERP_ETH = 1.0
BASE_OPTION_ETH = 2.0
BASE_EXIT_USDT = 15.0
MIN_K = 0.1
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)
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
perp_unit: float | None = None
option_unit: float | None = None
exit_unit: 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,
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
return premium_unit + fee_unit
def compute_k(
*,
budget: float,
index_px: float,
option_ask: float,
fee_rate: float,
perp_unit: float = BASE_PERP_ETH,
option_unit: float = BASE_OPTION_ETH,
exit_unit: float = BASE_EXIT_USDT,
) -> 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="以损定仓缺少有效期权卖一")
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,
option_unit=option_unit,
)
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=_round2(float(budget)),
k=k,
index_px=float(index_px),
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:
prem = float(option_ask) * float(option_unit) * 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=_round2(float(budget)),
premium_est=_round2(prem),
fee_est=_round2(fee),
max_loss=_round2(mx),
perp_qty_eth=round(float(perp_unit) * k, 4),
option_qty_eth=round(float(option_unit) * k, 4),
net_profit_target=_round2(float(exit_unit) * k),
index_px=float(index_px),
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)
return RiskSizingResult(
ok=False,
detail=f"以损定仓无法在预算 {budget:.2f}U 内找到合规 k",
budget=_round2(float(budget)),
index_px=float(index_px),
option_ask=float(option_ask),
perp_unit=float(perp_unit),
option_unit=float(option_unit),
exit_unit=float(exit_unit),
)
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)
perp_u, opt_u, exit_u = read_risk_units(ledger)
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,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
if not r.ok:
return RiskSizingResult(
ok=False,
detail=r.detail,
budget=_round2(budget),
capital_base=_round2(capital) if capital is not None else None,
index_px=float(index_px),
option_ask=float(option_ask),
k=r.k,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
return RiskSizingResult(
ok=True,
detail=r.detail,
k=r.k,
budget=_round2(budget),
capital_base=_round2(capital) if capital is not None else None,
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,
perp_unit=perp_u,
option_unit=opt_u,
exit_unit=exit_u,
)
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")
# 有活跃仓:本组成场参数已锁定,禁止重算覆盖
pos = database.fetchone("SELECT status, group_id FROM positions WHERE id=1")
if pos is not None:
st = str(pos["status"] or "flat")
if st in ("open", "half_open", "option_closed_perp_pending", "opening"):
return RiskSizingResult(
ok=False,
detail="持仓中已锁定本组成交目标与名义,平仓后再自动计算",
)
r = compute_risk_sizing(index_px=index_px, option_ask=option_ask, db=database)
if not r.ok:
return r
# 以损定仓强制 fixed_usdt;出场 = exit_unit × 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:.2f}" 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)
perp_u, opt_u, exit_u = read_risk_units(ledger)
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,
"perp_unit": perp_u,
"option_unit": opt_u,
"exit_unit": exit_u,
}
)
return out