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:
@@ -284,6 +284,9 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di
|
|||||||
"risk_perp_unit": _pick("risk_perp_unit", 1.0),
|
"risk_perp_unit": _pick("risk_perp_unit", 1.0),
|
||||||
"risk_option_unit": _pick("risk_option_unit", 2.0),
|
"risk_option_unit": _pick("risk_option_unit", 2.0),
|
||||||
"risk_exit_unit": _pick("risk_exit_unit", 15.0),
|
"risk_exit_unit": _pick("risk_exit_unit", 15.0),
|
||||||
|
"martingale_enabled": st.get("martingale_enabled"),
|
||||||
|
"martingale_doubles": st.get("martingale_doubles"),
|
||||||
|
"risk_effective_loss_pct": st.get("risk_effective_loss_pct"),
|
||||||
},
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"status": pos.get("status") or ("open" if pos.get("has_position") else "flat"),
|
"status": pos.get("status") or ("open" if pos.get("has_position") else "flat"),
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ KEYS = (
|
|||||||
"risk_perp_unit",
|
"risk_perp_unit",
|
||||||
"risk_option_unit",
|
"risk_option_unit",
|
||||||
"risk_exit_unit",
|
"risk_exit_unit",
|
||||||
|
"martingale_enabled",
|
||||||
|
"martingale_start_after_loss_days",
|
||||||
|
"martingale_max_doubles",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -104,6 +107,9 @@ class StrategySettingsBody(BaseModel):
|
|||||||
risk_perp_unit: float | None = Field(default=None, ge=0.01, le=100)
|
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_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)
|
risk_exit_unit: float | None = Field(default=None, ge=0.1, le=1_000_000)
|
||||||
|
martingale_enabled: bool | None = None
|
||||||
|
martingale_start_after_loss_days: int | None = Field(default=None, ge=1, le=30)
|
||||||
|
martingale_max_doubles: int | None = Field(default=None, ge=1, le=10)
|
||||||
|
|
||||||
|
|
||||||
def _as_bool(raw: str | None, default: bool) -> bool:
|
def _as_bool(raw: str | None, default: bool) -> bool:
|
||||||
@@ -298,6 +304,29 @@ def _read_settings() -> dict:
|
|||||||
"risk_perp_unit": float(db.get_setting("risk_perp_unit", "1") or 1),
|
"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_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_exit_unit": float(db.get_setting("risk_exit_unit", "15") or 15),
|
||||||
|
"martingale_enabled": _as_bool(
|
||||||
|
db.get_setting(
|
||||||
|
"martingale_enabled", str(s.martingale_enabled)
|
||||||
|
),
|
||||||
|
s.martingale_enabled,
|
||||||
|
),
|
||||||
|
"martingale_start_after_loss_days": int(
|
||||||
|
float(
|
||||||
|
db.get_setting(
|
||||||
|
"martingale_start_after_loss_days",
|
||||||
|
str(s.martingale_start_after_loss_days),
|
||||||
|
)
|
||||||
|
or s.martingale_start_after_loss_days
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"martingale_max_doubles": int(
|
||||||
|
float(
|
||||||
|
db.get_setting(
|
||||||
|
"martingale_max_doubles", str(s.martingale_max_doubles)
|
||||||
|
)
|
||||||
|
or s.martingale_max_doubles
|
||||||
|
)
|
||||||
|
),
|
||||||
"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,
|
||||||
@@ -378,6 +407,9 @@ async def put_strategy_settings(
|
|||||||
"risk_loss_usdt",
|
"risk_loss_usdt",
|
||||||
"risk_capital_source",
|
"risk_capital_source",
|
||||||
"risk_manual_capital_usdt",
|
"risk_manual_capital_usdt",
|
||||||
|
"martingale_enabled",
|
||||||
|
"martingale_start_after_loss_days",
|
||||||
|
"martingale_max_doubles",
|
||||||
)
|
)
|
||||||
hit = [k for k in locked_keys if k in data]
|
hit = [k for k in locked_keys if k in data]
|
||||||
if hit:
|
if hit:
|
||||||
@@ -439,6 +471,50 @@ async def put_strategy_settings(
|
|||||||
detail="以损定仓选用亏损幅度时,须填写 risk_loss_pct > 0",
|
detail="以损定仓选用亏损幅度时,须填写 risk_loss_pct > 0",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 倍投:仅以损定仓 + 亏损幅度% + 基础幅度≤3%;条件不满足则强制关闭
|
||||||
|
from ..strategy.risk_sizing import MARTINGALE_MAX_BASE_PCT
|
||||||
|
|
||||||
|
loss_mode_final = str(
|
||||||
|
data.get(
|
||||||
|
"risk_loss_mode",
|
||||||
|
db.get_setting("risk_loss_mode", "percent") or "percent",
|
||||||
|
)
|
||||||
|
).strip().lower()
|
||||||
|
pct_final = data.get("risk_loss_pct")
|
||||||
|
if pct_final is None:
|
||||||
|
pct_final = float(db.get_setting("risk_loss_pct", "1") or 1)
|
||||||
|
else:
|
||||||
|
pct_final = float(pct_final)
|
||||||
|
existing_mg = _as_bool(
|
||||||
|
db.get_setting("martingale_enabled", str(s.martingale_enabled)),
|
||||||
|
s.martingale_enabled,
|
||||||
|
)
|
||||||
|
want_mg = (
|
||||||
|
bool(data["martingale_enabled"])
|
||||||
|
if "martingale_enabled" in data
|
||||||
|
else existing_mg
|
||||||
|
)
|
||||||
|
mg_eligible = (
|
||||||
|
sizing_mode == "risk_based"
|
||||||
|
and loss_mode_final in ("percent", "pct", "%", "幅度")
|
||||||
|
and float(pct_final) <= MARTINGALE_MAX_BASE_PCT + 1e-12
|
||||||
|
)
|
||||||
|
if want_mg and not mg_eligible:
|
||||||
|
explicit_on = "martingale_enabled" in data and bool(data["martingale_enabled"])
|
||||||
|
if explicit_on:
|
||||||
|
if sizing_mode != "risk_based":
|
||||||
|
reason = "倍投模式仅可在以损定仓下开启"
|
||||||
|
elif loss_mode_final not in ("percent", "pct", "%", "幅度"):
|
||||||
|
reason = "倍投模式仅可在「亏损幅度%」下开启"
|
||||||
|
else:
|
||||||
|
reason = (
|
||||||
|
f"以损定仓亏损幅度超过 {MARTINGALE_MAX_BASE_PCT:g}% 时不可启用倍投"
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=400, detail=reason)
|
||||||
|
data["martingale_enabled"] = False
|
||||||
|
elif not mg_eligible:
|
||||||
|
data["martingale_enabled"] = False
|
||||||
|
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
if k in KEYS:
|
if k in KEYS:
|
||||||
db.set_setting(k, str(v))
|
db.set_setting(k, str(v))
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ class Settings(BaseSettings):
|
|||||||
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
|
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
|
||||||
# 以损定仓权利金口径:actual=盘口卖一;selection=指数/选约杠杆(控节奏,默认)
|
# 以损定仓权利金口径:actual=盘口卖一;selection=指数/选约杠杆(控节奏,默认)
|
||||||
risk_leverage_basis: str = "selection"
|
risk_leverage_basis: str = "selection"
|
||||||
|
# 倍投:默认关;仅以损定仓+亏损幅度%且基础幅度≤3% 可开
|
||||||
|
martingale_enabled: bool = False
|
||||||
|
martingale_start_after_loss_days: int = 2 # 连续亏损 N 天后开始翻倍
|
||||||
|
martingale_max_doubles: int = 3 # 最多翻倍次数(如 2→4→8→16 为 3 次)
|
||||||
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
|
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
|
||||||
max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点)
|
max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点)
|
||||||
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
|
# 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值
|
||||||
|
|||||||
@@ -187,6 +187,23 @@ class StrategyEngine:
|
|||||||
"perp_qty_eth": perp_qty,
|
"perp_qty_eth": perp_qty,
|
||||||
"option_qty_eth": opt_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_until = row["rest_until_ms"]
|
||||||
rest_left = 0
|
rest_left = 0
|
||||||
if rest_until:
|
if rest_until:
|
||||||
@@ -235,6 +252,17 @@ class StrategyEngine:
|
|||||||
"risk_last_k": risk_last_k if risk_last_k > 0 else None,
|
"risk_last_k": risk_last_k if risk_last_k > 0 else None,
|
||||||
"risk_sizing_preview": risk_preview,
|
"risk_sizing_preview": risk_preview,
|
||||||
"risk_sizing_locked": bool(trade_locked and sizing_mode == "risk_based"),
|
"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_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,
|
||||||
|
|||||||
@@ -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)
|
pct = ledger.get_setting_float("risk_loss_pct", 1.0)
|
||||||
if pct <= 0:
|
if pct <= 0:
|
||||||
return None, "亏损幅度须 > 0", capital
|
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]:
|
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
|
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)
|
perp_u, opt_u, exit_u = read_risk_units(ledger)
|
||||||
|
mg = resolve_martingale(database, ledger=ledger)
|
||||||
out.update(
|
out.update(
|
||||||
{
|
{
|
||||||
"ok": r.ok,
|
"ok": r.ok,
|
||||||
@@ -464,6 +579,8 @@ def preview_risk_sizing(db: Database | None = None) -> dict[str, Any]:
|
|||||||
"perp_unit": perp_u,
|
"perp_unit": perp_u,
|
||||||
"option_unit": opt_u,
|
"option_unit": opt_u,
|
||||||
"exit_unit": exit_u,
|
"exit_unit": exit_u,
|
||||||
|
"martingale": mg,
|
||||||
|
"risk_effective_loss_pct": mg.get("effective_pct"),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -149,3 +149,96 @@ def test_compute_risk_sizing_respects_basis(tmp_path, monkeypatch) -> None:
|
|||||||
assert r2.k is not None and r2.k > 1.0
|
assert r2.k is not None and r2.k > 1.0
|
||||||
assert r2.option_ask == 10.0
|
assert r2.option_ask == 10.0
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_consecutive_loss_days_and_martingale(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.models.db import Database
|
||||||
|
from app.strategy.risk_sizing import consecutive_loss_days, resolve_martingale
|
||||||
|
|
||||||
|
db = Database(tmp_path / "mg.db")
|
||||||
|
sh = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
def day_ms(ymd: str, hour: int = 16) -> int:
|
||||||
|
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(
|
||||||
|
hour=hour, tzinfo=sh
|
||||||
|
)
|
||||||
|
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
|
# 插入:盈利日打断后连亏 3 天(有成交日序列,跳过无成交日)
|
||||||
|
rows = [
|
||||||
|
("g1", day_ms("2026-07-28"), 10.0),
|
||||||
|
("g2", day_ms("2026-07-29"), -5.0),
|
||||||
|
("g3", day_ms("2026-07-30"), -3.0),
|
||||||
|
("g4", day_ms("2026-07-31"), -1.0),
|
||||||
|
]
|
||||||
|
for gid, ms, pnl in rows:
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO groups(
|
||||||
|
group_id, status, realized_pnl, close_at_ms, open_at_ms
|
||||||
|
) VALUES(?,?,?,?,?)""",
|
||||||
|
(gid, "closed", pnl, ms, ms - 3600_000),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert consecutive_loss_days(db) == 3
|
||||||
|
|
||||||
|
db.set_setting("sizing_mode", "risk_based")
|
||||||
|
db.set_setting("risk_loss_mode", "percent")
|
||||||
|
db.set_setting("risk_loss_pct", "2")
|
||||||
|
db.set_setting("martingale_enabled", "true")
|
||||||
|
db.set_setting("martingale_start_after_loss_days", "2")
|
||||||
|
db.set_setting("martingale_max_doubles", "3")
|
||||||
|
|
||||||
|
mg = resolve_martingale(db, base_pct=2.0)
|
||||||
|
assert mg["eligible"] is True
|
||||||
|
assert mg["loss_days"] == 3
|
||||||
|
# 连亏3天、start=2 → doubles = min(3-2+1, 3) = 2 → 2%*4 = 8%
|
||||||
|
assert mg["doubles"] == 2
|
||||||
|
assert abs(float(mg["effective_pct"]) - 8.0) < 1e-9
|
||||||
|
|
||||||
|
db.set_setting("risk_loss_pct", "3.1")
|
||||||
|
mg2 = resolve_martingale(db, base_pct=3.1)
|
||||||
|
assert mg2["eligible"] is False
|
||||||
|
assert mg2["doubles"] == 0
|
||||||
|
assert abs(float(mg2["effective_pct"]) - 3.1) < 1e-9
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_martingale_doubles_capped(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("MODE", "SIM")
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from app.models.db import Database
|
||||||
|
from app.strategy.risk_sizing import resolve_martingale
|
||||||
|
|
||||||
|
db = Database(tmp_path / "mg_cap.db")
|
||||||
|
sh = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
def day_ms(ymd: str) -> int:
|
||||||
|
dt = datetime.strptime(ymd, "%Y-%m-%d").replace(hour=12, tzinfo=sh)
|
||||||
|
return int(dt.astimezone(timezone.utc).timestamp() * 1000)
|
||||||
|
|
||||||
|
for i, ymd in enumerate(
|
||||||
|
["2026-07-26", "2026-07-27", "2026-07-28", "2026-07-29", "2026-07-30"]
|
||||||
|
):
|
||||||
|
db.execute(
|
||||||
|
"""INSERT INTO groups(
|
||||||
|
group_id, status, realized_pnl, close_at_ms, open_at_ms
|
||||||
|
) VALUES(?,?,?,?,?)""",
|
||||||
|
(f"c{i}", "closed", -1.0, day_ms(ymd), day_ms(ymd) - 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.set_setting("sizing_mode", "risk_based")
|
||||||
|
db.set_setting("risk_loss_mode", "percent")
|
||||||
|
db.set_setting("martingale_enabled", "true")
|
||||||
|
db.set_setting("martingale_start_after_loss_days", "2")
|
||||||
|
db.set_setting("martingale_max_doubles", "3")
|
||||||
|
mg = resolve_martingale(db, base_pct=2.0)
|
||||||
|
# 连亏5、start2 → raw=4,cap=3 → 2%*8=16%
|
||||||
|
assert mg["doubles"] == 3
|
||||||
|
assert abs(float(mg["effective_pct"]) - 16.0) < 1e-9
|
||||||
|
db.close()
|
||||||
|
|||||||
@@ -322,6 +322,27 @@ export type PlanState = {
|
|||||||
liquidity_ok?: boolean;
|
liquidity_ok?: boolean;
|
||||||
liquidity_detail?: string | null;
|
liquidity_detail?: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
risk_perp_unit?: number;
|
||||||
|
risk_option_unit?: number;
|
||||||
|
risk_exit_unit?: number;
|
||||||
|
risk_last_k?: number | null;
|
||||||
|
risk_sizing_preview?: {
|
||||||
|
ok?: boolean;
|
||||||
|
locked?: boolean;
|
||||||
|
detail?: string;
|
||||||
|
budget?: number;
|
||||||
|
k?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
risk_sizing_locked?: boolean;
|
||||||
|
martingale_enabled?: boolean;
|
||||||
|
martingale_eligible?: boolean;
|
||||||
|
martingale_doubles?: number;
|
||||||
|
martingale_loss_days?: number;
|
||||||
|
risk_effective_loss_pct?: number;
|
||||||
|
risk_loss_pct?: number;
|
||||||
|
sizing_mode?: "manual" | "risk_based";
|
||||||
|
risk_based?: boolean;
|
||||||
ledger: { equity: number; available: number; reserved: number };
|
ledger: { equity: number; available: number; reserved: number };
|
||||||
mode?: "SIM" | "LIVE";
|
mode?: "SIM" | "LIVE";
|
||||||
sim?: boolean;
|
sim?: boolean;
|
||||||
@@ -364,6 +385,9 @@ export type StrategySettings = {
|
|||||||
risk_option_unit?: number;
|
risk_option_unit?: number;
|
||||||
risk_exit_unit?: number;
|
risk_exit_unit?: number;
|
||||||
risk_sizing_preview?: Record<string, unknown>;
|
risk_sizing_preview?: Record<string, unknown>;
|
||||||
|
martingale_enabled?: boolean;
|
||||||
|
martingale_start_after_loss_days?: number;
|
||||||
|
martingale_max_doubles?: number;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,13 @@ export default function PlanPage() {
|
|||||||
: plan?.risk_sizing_preview?.budget != null
|
: plan?.risk_sizing_preview?.budget != null
|
||||||
? `预算${fmt(plan.risk_sizing_preview.budget, 2)}U`
|
? `预算${fmt(plan.risk_sizing_preview.budget, 2)}U`
|
||||||
: null,
|
: null,
|
||||||
|
plan?.martingale_enabled &&
|
||||||
|
(plan?.martingale_doubles ?? 0) > 0 &&
|
||||||
|
!riskLocked
|
||||||
|
? `倍投×${2 ** Number(plan.martingale_doubles)}(${fmt(plan.risk_effective_loss_pct ?? plan.risk_loss_pct ?? 0, 2)}%)`
|
||||||
|
: plan?.martingale_enabled && !riskLocked
|
||||||
|
? "倍投开"
|
||||||
|
: null,
|
||||||
!riskLocked && plan?.risk_sizing_preview?.ok === false
|
!riskLocked && plan?.risk_sizing_preview?.ok === false
|
||||||
? String(plan.risk_sizing_preview.detail || "预览失败")
|
? String(plan.risk_sizing_preview.detail || "预览失败")
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ export default function SettingsPage() {
|
|||||||
const [riskPerpUnit, setRiskPerpUnit] = useState(1);
|
const [riskPerpUnit, setRiskPerpUnit] = useState(1);
|
||||||
const [riskOptUnit, setRiskOptUnit] = useState(2);
|
const [riskOptUnit, setRiskOptUnit] = useState(2);
|
||||||
const [riskExitUnit, setRiskExitUnit] = useState(15);
|
const [riskExitUnit, setRiskExitUnit] = useState(15);
|
||||||
|
const [martingaleOn, setMartingaleOn] = useState(false);
|
||||||
|
const [martingaleStartAfter, setMartingaleStartAfter] = useState(2);
|
||||||
|
const [martingaleMaxDoubles, setMartingaleMaxDoubles] = useState(3);
|
||||||
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
|
const [riskPreview, setRiskPreview] = useState<Record<string, unknown> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -215,6 +218,9 @@ export default function SettingsPage() {
|
|||||||
setRiskPerpUnit(s.risk_perp_unit ?? 1);
|
setRiskPerpUnit(s.risk_perp_unit ?? 1);
|
||||||
setRiskOptUnit(s.risk_option_unit ?? 2);
|
setRiskOptUnit(s.risk_option_unit ?? 2);
|
||||||
setRiskExitUnit(s.risk_exit_unit ?? 15);
|
setRiskExitUnit(s.risk_exit_unit ?? 15);
|
||||||
|
setMartingaleOn(s.martingale_enabled === true);
|
||||||
|
setMartingaleStartAfter(s.martingale_start_after_loss_days ?? 2);
|
||||||
|
setMartingaleMaxDoubles(s.martingale_max_doubles ?? 3);
|
||||||
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
|
||||||
@@ -376,6 +382,13 @@ export default function SettingsPage() {
|
|||||||
risk_perp_unit: riskPerpUnit,
|
risk_perp_unit: riskPerpUnit,
|
||||||
risk_option_unit: riskOptUnit,
|
risk_option_unit: riskOptUnit,
|
||||||
risk_exit_unit: riskExitUnit,
|
risk_exit_unit: riskExitUnit,
|
||||||
|
martingale_enabled:
|
||||||
|
sizingMode === "risk_based" &&
|
||||||
|
riskLossMode === "percent" &&
|
||||||
|
riskLossPct <= 3 &&
|
||||||
|
martingaleOn,
|
||||||
|
martingale_start_after_loss_days: martingaleStartAfter,
|
||||||
|
martingale_max_doubles: martingaleMaxDoubles,
|
||||||
exchange,
|
exchange,
|
||||||
};
|
};
|
||||||
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
|
// 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422
|
||||||
@@ -719,13 +732,14 @@ export default function SettingsPage() {
|
|||||||
id="riskLossMode"
|
id="riskLossMode"
|
||||||
className="mono"
|
className="mono"
|
||||||
value={riskLossMode}
|
value={riskLossMode}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setRiskLossMode(
|
const mode =
|
||||||
e.target.value === "absolute"
|
e.target.value === "absolute"
|
||||||
? "absolute"
|
? "absolute"
|
||||||
: "percent",
|
: "percent";
|
||||||
)
|
setRiskLossMode(mode);
|
||||||
}
|
if (mode !== "percent") setMartingaleOn(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="percent">亏损幅度(占本金 %)</option>
|
<option value="percent">亏损幅度(占本金 %)</option>
|
||||||
<option value="absolute">亏损值(USDT)</option>
|
<option value="absolute">亏损值(USDT)</option>
|
||||||
@@ -778,11 +792,93 @@ export default function SettingsPage() {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0.01"
|
min="0.01"
|
||||||
value={riskLossPct}
|
value={riskLossPct}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setRiskLossPct(Number(e.target.value))
|
const v = Number(e.target.value);
|
||||||
}
|
setRiskLossPct(v);
|
||||||
|
if (v > 3) setMartingaleOn(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="mgOn">倍投模式</label>
|
||||||
|
<select
|
||||||
|
id="mgOn"
|
||||||
|
className="mono"
|
||||||
|
value={martingaleOn ? "on" : "off"}
|
||||||
|
disabled={riskLossPct > 3}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMartingaleOn(e.target.value === "on")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="off">关闭(默认)</option>
|
||||||
|
<option value="on">开启</option>
|
||||||
|
</select>
|
||||||
|
<p className="hint" style={{ margin: "0.35rem 0 0" }}>
|
||||||
|
{riskLossPct > 3
|
||||||
|
? "亏损幅度超过 3% 时不可启用倍投。"
|
||||||
|
: "连续亏损日达阈值后,按基础幅度翻倍(不改保存的幅度值)。最多可设翻倍次数。"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{martingaleOn && riskLossPct <= 3 ? (
|
||||||
|
<>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="mgStart">
|
||||||
|
连续亏损几天后开始翻倍
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mgStart"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
max="30"
|
||||||
|
value={martingaleStartAfter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMartingaleStartAfter(
|
||||||
|
Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="mgMax">最多翻倍次数</label>
|
||||||
|
<input
|
||||||
|
id="mgMax"
|
||||||
|
className="mono"
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={martingaleMaxDoubles}
|
||||||
|
onChange={(e) =>
|
||||||
|
setMartingaleMaxDoubles(
|
||||||
|
Number(e.target.value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className="hint"
|
||||||
|
style={{ margin: "0.35rem 0 0" }}
|
||||||
|
>
|
||||||
|
例:幅度 {riskLossPct}%、连亏{" "}
|
||||||
|
{martingaleStartAfter} 天起翻、最多{" "}
|
||||||
|
{martingaleMaxDoubles} 次 →{" "}
|
||||||
|
{[0, 1, 2, 3]
|
||||||
|
.filter((d) => d <= martingaleMaxDoubles)
|
||||||
|
.map(
|
||||||
|
(d) =>
|
||||||
|
`${Number(
|
||||||
|
(
|
||||||
|
riskLossPct *
|
||||||
|
2 ** d
|
||||||
|
).toPrecision(6),
|
||||||
|
)}%`,
|
||||||
|
)
|
||||||
|
.join(" → ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@@ -835,7 +931,25 @@ export default function SettingsPage() {
|
|||||||
? "选约杠杆"
|
? "选约杠杆"
|
||||||
: "";
|
: "";
|
||||||
const basisS = basis ? ` · ${basis}` : "";
|
const basisS = basis ? ` · ${basis}` : "";
|
||||||
return `k=${pk ?? "—"} · 预算=${budS}U · 估亏=${mxS}U · 永续=${perp ?? "—"} · 期权=${opt ?? "—"} · 出场=${exit ?? "—"}${basisS}`;
|
const mg =
|
||||||
|
riskPreview.martingale &&
|
||||||
|
typeof riskPreview.martingale === "object"
|
||||||
|
? (riskPreview.martingale as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>)
|
||||||
|
: null;
|
||||||
|
const mgD = Number(mg?.doubles);
|
||||||
|
const mgS =
|
||||||
|
mg != null &&
|
||||||
|
mg.enabled === true &&
|
||||||
|
Number.isFinite(mgD) &&
|
||||||
|
mgD > 0
|
||||||
|
? ` · 倍投×${2 ** mgD}(连亏${Number(mg.loss_days) || 0}天·有效${Number(mg.effective_pct)}%)`
|
||||||
|
: mg != null && mg.enabled === true
|
||||||
|
? ` · 倍投待命(连亏${Number(mg.loss_days) || 0}天)`
|
||||||
|
: "";
|
||||||
|
return `k=${pk ?? "—"} · 预算=${budS}U · 估亏=${mxS}U · 永续=${perp ?? "—"} · 期权=${opt ?? "—"} · 出场=${exit ?? "—"}${basisS}${mgS}`;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user