diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index c7acf59..473deec 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -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_option_unit": _pick("risk_option_unit", 2.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": { "status": pos.get("status") or ("open" if pos.get("has_position") else "flat"), diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 5bb56b4..65201ad 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -61,6 +61,9 @@ KEYS = ( "risk_perp_unit", "risk_option_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_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) + 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: @@ -298,6 +304,29 @@ def _read_settings() -> dict: "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_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(), "exchange": rt.exchange, "perp_inst_id": rt.perp_inst_id, @@ -378,6 +407,9 @@ async def put_strategy_settings( "risk_loss_usdt", "risk_capital_source", "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] if hit: @@ -439,6 +471,50 @@ async def put_strategy_settings( 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(): if k in KEYS: db.set_setting(k, str(v)) diff --git a/backend/app/config.py b/backend/app/config.py index 1e9ca3d..64ae9d6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -76,6 +76,10 @@ class Settings(BaseSettings): min_option_leverage: float = 100.0 # 现价/卖一权利金 下限 # 以损定仓权利金口径:actual=盘口卖一;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 偏差限制开关(默认关) max_atm_open_offset: float = 3.0 # 开启后:|ATM行权价−标的| 上限(点) # 固定方向:关=现有 ATM/比价规则;开=指定永续多/空,期权 Put/Call 且须实值或平值 diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index eee404c..89a2a1b 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -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, diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index 855a9b9..112b2f5 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -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 diff --git a/backend/tests/test_risk_sizing.py b/backend/tests/test_risk_sizing.py index a645856..304ddf7 100644 --- a/backend/tests/test_risk_sizing.py +++ b/backend/tests/test_risk_sizing.py @@ -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.option_ask == 10.0 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() diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 6fc7811..7802943 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -322,6 +322,27 @@ export type PlanState = { liquidity_ok?: boolean; 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 }; mode?: "SIM" | "LIVE"; sim?: boolean; @@ -364,6 +385,9 @@ export type StrategySettings = { risk_option_unit?: number; risk_exit_unit?: number; risk_sizing_preview?: Record; + martingale_enabled?: boolean; + martingale_start_after_loss_days?: number; + martingale_max_doubles?: number; exchange?: string; }; diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 23dba1b..538d64f 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -223,6 +223,13 @@ export default function PlanPage() { : plan?.risk_sizing_preview?.budget != null ? `预算${fmt(plan.risk_sizing_preview.budget, 2)}U` : 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 ? String(plan.risk_sizing_preview.detail || "预览失败") : null, diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 707ef0a..c757d79 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -105,6 +105,9 @@ export default function SettingsPage() { const [riskPerpUnit, setRiskPerpUnit] = useState(1); const [riskOptUnit, setRiskOptUnit] = useState(2); const [riskExitUnit, setRiskExitUnit] = useState(15); + const [martingaleOn, setMartingaleOn] = useState(false); + const [martingaleStartAfter, setMartingaleStartAfter] = useState(2); + const [martingaleMaxDoubles, setMartingaleMaxDoubles] = useState(3); const [riskPreview, setRiskPreview] = useState | null>( null, ); @@ -215,6 +218,9 @@ export default function SettingsPage() { setRiskPerpUnit(s.risk_perp_unit ?? 1); setRiskOptUnit(s.risk_option_unit ?? 2); 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( s.risk_sizing_preview && typeof s.risk_sizing_preview === "object" ? s.risk_sizing_preview @@ -376,6 +382,13 @@ export default function SettingsPage() { risk_perp_unit: riskPerpUnit, risk_option_unit: riskOptUnit, 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, }; // 以损定仓不提交手填名义/出场,避免禁用输入框脏值导致 422 @@ -719,13 +732,14 @@ export default function SettingsPage() { id="riskLossMode" className="mono" value={riskLossMode} - onChange={(e) => - setRiskLossMode( + onChange={(e) => { + const mode = e.target.value === "absolute" ? "absolute" - : "percent", - ) - } + : "percent"; + setRiskLossMode(mode); + if (mode !== "percent") setMartingaleOn(false); + }} > @@ -778,11 +792,93 @@ export default function SettingsPage() { step="0.01" min="0.01" value={riskLossPct} - onChange={(e) => - setRiskLossPct(Number(e.target.value)) - } + onChange={(e) => { + const v = Number(e.target.value); + setRiskLossPct(v); + if (v > 3) setMartingaleOn(false); + }} /> +
+ + +

+ {riskLossPct > 3 + ? "亏损幅度超过 3% 时不可启用倍投。" + : "连续亏损日达阈值后,按基础幅度翻倍(不改保存的幅度值)。最多可设翻倍次数。"} +

+
+ {martingaleOn && riskLossPct <= 3 ? ( + <> +
+ + + setMartingaleStartAfter( + Number(e.target.value), + ) + } + /> +
+
+ + + setMartingaleMaxDoubles( + Number(e.target.value), + ) + } + /> +

+ 例:幅度 {riskLossPct}%、连亏{" "} + {martingaleStartAfter} 天起翻、最多{" "} + {martingaleMaxDoubles} 次 →{" "} + {[0, 1, 2, 3] + .filter((d) => d <= martingaleMaxDoubles) + .map( + (d) => + `${Number( + ( + riskLossPct * + 2 ** d + ).toPrecision(6), + )}%`, + ) + .join(" → ")} +

+
+ + ) : null} ) : (
@@ -835,7 +931,25 @@ export default function SettingsPage() { ? "选约杠杆" : ""; 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}`; })()}