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>
This commit is contained in:
dekun
2026-08-07 09:55:29 +08:00
parent 3d1f9f3d50
commit 0f552eb50e
9 changed files with 476 additions and 10 deletions
+28
View File
@@ -187,6 +187,23 @@ class StrategyEngine:
"perp_qty_eth": perp_qty,
"option_qty_eth": opt_qty,
}
try:
from .risk_sizing import resolve_martingale
martingale = resolve_martingale(
self.db, ledger=self.ledger, base_pct=risk_loss_pct
)
except Exception:
logger.exception("resolve_martingale for state() failed")
martingale = {
"enabled": False,
"eligible": False,
"doubles": 0,
"loss_days": 0,
"start_after_loss_days": 2,
"max_doubles": 3,
"effective_pct": risk_loss_pct,
}
rest_until = row["rest_until_ms"]
rest_left = 0
if rest_until:
@@ -235,6 +252,17 @@ class StrategyEngine:
"risk_last_k": risk_last_k if risk_last_k > 0 else None,
"risk_sizing_preview": risk_preview,
"risk_sizing_locked": bool(trade_locked and sizing_mode == "risk_based"),
"martingale_enabled": bool(martingale.get("enabled")),
"martingale_eligible": bool(martingale.get("eligible")),
"martingale_doubles": int(martingale.get("doubles") or 0),
"martingale_loss_days": int(martingale.get("loss_days") or 0),
"martingale_start_after_loss_days": int(
martingale.get("start_after_loss_days") or 2
),
"martingale_max_doubles": int(martingale.get("max_doubles") or 3),
"risk_effective_loss_pct": float(
martingale.get("effective_pct") or risk_loss_pct
),
"min_option_hours": min_hours,
"min_option_leverage": min_opt_lev,
"atm_open_offset_enabled": atm_off_on,
+118 -1
View File
@@ -255,7 +255,121 @@ def resolve_budget(db: Database | None = None) -> tuple[float | None, str, float
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
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]:
@@ -444,6 +558,7 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
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,
@@ -464,6 +579,8 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
"perp_unit": perp_u,
"option_unit": opt_u,
"exit_unit": exit_u,
"martingale": mg,
"risk_effective_loss_pct": mg.get("effective_pct"),
}
)
return out