Files
eth_hedge_sim/backend/app/strategy/risk_sizing.py
T
dekun 0f552eb50e Add martingale mode for risk-based percent sizing.
Enable in settings (default off): after N consecutive loss days, double the effective risk_loss_pct up to a configurable max; blocked when base pct > 3%.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 09:55:29 +08:00

587 lines
20 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 # 定仓用权利金(可能为选约杠杆隐含)
actual_option_ask: float | None = None # 盘口真实卖一
leverage_basis: str | None = None # actual | selection
perp_unit: float | None = None
option_unit: float | None = None
exit_unit: float | None = None
def normalize_risk_leverage_basis(raw: str | None, default: str = "selection") -> str:
v = (raw or default or "selection").strip().lower()
if v in ("selection", "min_option_leverage", "select", "选约", "选约杠杆"):
return "selection"
if v in ("actual", "market", "ask", "实际", "实际杠杆"):
return "actual"
return "selection" if default == "selection" else "actual"
def resolve_sizing_option_ask(
*,
index_px: float,
option_ask: float,
leverage_basis: str,
min_option_leverage: float,
) -> tuple[float, str]:
"""
返回 (定仓用卖一, 口径 actual|selection)。
selection:隐含卖一 = 指数 / 选约杠杆;actual:用盘口卖一。
"""
basis = normalize_risk_leverage_basis(leverage_basis, "selection")
if basis == "selection":
lev = float(min_option_leverage)
if lev > 1e-12 and math.isfinite(lev) and index_px > 0:
return float(index_px) / lev, "selection"
# 选约杠杆无效时退回实际卖一,避免拒单
return float(option_ask), "actual"
return float(option_ask), "actual"
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
mg = resolve_martingale(database, ledger=ledger, base_pct=float(pct))
effective = float(mg["effective_pct"])
detail = f"percent@{src}"
if int(mg.get("doubles") or 0) > 0:
detail += (
f"|mg×{int(2 ** int(mg['doubles']))}"
f"(连亏{int(mg.get('loss_days') or 0)}天)"
)
return float(capital) * (effective / 100.0), detail, capital
MARTINGALE_MAX_BASE_PCT = 3.0
def consecutive_loss_days(db: Database | None = None) -> int:
"""
按上海日历「平仓日」汇总净盈亏,从最近有平仓的一天往前数连续亏损天数。
某日净盈亏 < 0 计为亏损日;无平仓的日历日不计入、不打断(按有成交日序列)。
"""
from collections import defaultdict
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
database = db or get_db()
rows = database.fetchall(
"""SELECT realized_pnl, close_at_ms FROM groups
WHERE status='closed' AND close_at_ms IS NOT NULL
ORDER BY close_at_ms ASC"""
)
if not rows:
return 0
sh = ZoneInfo("Asia/Shanghai")
day_pnl: dict[str, float] = defaultdict(float)
for r in rows:
try:
ms = int(r["close_at_ms"] or 0)
except (TypeError, ValueError):
continue
if ms <= 0:
continue
day = (
datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
.astimezone(sh)
.strftime("%Y-%m-%d")
)
day_pnl[day] += float(r["realized_pnl"] or 0)
if not day_pnl:
return 0
streak = 0
for d in reversed(sorted(day_pnl.keys())):
if float(day_pnl[d]) < 0:
streak += 1
else:
break
return streak
def resolve_martingale(
db: Database | None = None,
*,
ledger: Ledger | None = None,
base_pct: float | None = None,
) -> dict[str, Any]:
"""
倍投状态:仅以损定仓 + 亏损幅度% + 开关开启 + 基础幅度≤3% 时生效。
doubles: 已翻倍次数(0=用基础幅度);effective_pct = base * 2^doubles。
"""
database = db or get_db()
led = ledger or Ledger(database)
enabled = led.get_setting_bool("martingale_enabled", False)
pct = (
float(base_pct)
if base_pct is not None
else float(led.get_setting_float("risk_loss_pct", 1.0))
)
start_after = int(
round(led.get_setting_float("martingale_start_after_loss_days", 2.0))
)
max_doubles = int(round(led.get_setting_float("martingale_max_doubles", 3.0)))
start_after = max(1, min(30, start_after))
max_doubles = max(1, min(10, max_doubles))
loss_days = consecutive_loss_days(database)
out: dict[str, Any] = {
"enabled": bool(enabled),
"eligible": False,
"blocked": "",
"base_pct": round(pct, 4),
"effective_pct": round(pct, 4),
"doubles": 0,
"loss_days": int(loss_days),
"start_after_loss_days": start_after,
"max_doubles": max_doubles,
}
if not enabled:
out["blocked"] = "off"
return out
if not is_risk_based(led):
out["blocked"] = "not_risk_based"
return out
loss_mode = (
led.get_setting_str("risk_loss_mode", "percent") or "percent"
).strip().lower()
if loss_mode not in ("percent", "pct", "%", "幅度"):
out["blocked"] = "not_percent_mode"
return out
if pct > MARTINGALE_MAX_BASE_PCT + 1e-12:
out["blocked"] = f"base_pct>{MARTINGALE_MAX_BASE_PCT:g}"
return out
out["eligible"] = True
doubles = 0
if loss_days >= start_after:
doubles = min(int(loss_days - start_after + 1), max_doubles)
out["doubles"] = doubles
out["effective_pct"] = round(float(pct) * (2**doubles), 6)
return out
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)
basis_raw = ledger.get_setting_str(
"risk_leverage_basis", s.risk_leverage_basis
) or s.risk_leverage_basis
min_opt_lev = ledger.get_setting_float(
"min_option_leverage", s.min_option_leverage
)
sizing_ask, basis = resolve_sizing_option_ask(
index_px=float(index_px),
option_ask=float(option_ask),
leverage_basis=str(basis_raw),
min_option_leverage=float(min_opt_lev),
)
budget, bud_detail, capital = resolve_budget(database)
if budget is None:
return RiskSizingResult(
ok=False,
detail=f"以损定仓预算失败: {bud_detail}",
leverage_basis=basis,
actual_option_ask=_round2(float(option_ask)),
option_ask=_round2(float(sizing_ask)),
)
r = compute_k(
budget=budget,
index_px=index_px,
option_ask=sizing_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=_round2(float(sizing_ask)),
actual_option_ask=_round2(float(option_ask)),
leverage_basis=basis,
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=_round2(float(sizing_ask)),
actual_option_ask=_round2(float(option_ask)),
leverage_basis=basis,
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 basis=%s sizing_ask=%.4f actual_ask=%.4f "
"perp=%.4f opt=%.4f exit=%.4f max_loss=%.4f budget=%.4f",
r.k or 0,
r.leverage_basis or "?",
r.option_ask or 0,
r.actual_option_ask 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)
mg = resolve_martingale(database, ledger=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,
"actual_option_ask": r.actual_option_ask,
"leverage_basis": r.leverage_basis,
"perp_unit": perp_u,
"option_unit": opt_u,
"exit_unit": exit_u,
"martingale": mg,
"risk_effective_loss_pct": mg.get("effective_pct"),
}
)
return out