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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user