diff --git a/backend/app/strategy/risk_sizing.py b/backend/app/strategy/risk_sizing.py index 112b2f5..d4d4437 100644 --- a/backend/app/strategy/risk_sizing.py +++ b/backend/app/strategy/risk_sizing.py @@ -269,10 +269,26 @@ def resolve_budget(db: Database | None = None) -> tuple[float | None, str, float MARTINGALE_MAX_BASE_PCT = 3.0 +def _is_expiry_close_reason(reason: str | None) -> bool: + r = str(reason or "").strip().lower() + return r in ("expiry", "到期", "到期结算", "到期结算全平") + + +def _martingale_day_pnl_contrib(realized_pnl: float, close_reason: str | None) -> float: + """ + 倍投连亏日口径:到期结算无论实际盈亏(含小盈利)一律按亏损计入; + 其它平仓按真实 realized_pnl。 + """ + if _is_expiry_close_reason(close_reason): + return -1.0 + return float(realized_pnl or 0.0) + + def consecutive_loss_days(db: Database | None = None) -> int: """ - 按上海日历「平仓日」汇总净盈亏,从最近有平仓的一天往前数连续亏损天数。 - 某日净盈亏 < 0 计为亏损日;无平仓的日历日不计入、不打断(按有成交日序列)。 + 按上海日历「平仓日」汇总倍投口径盈亏,从最近有平仓的一天往前数连续亏损天数。 + 某日合计 < 0 计为亏损日;到期结算组无论盈亏均按亏损计入。 + 无平仓的日历日不计入、不打断(按有成交日序列)。 """ from collections import defaultdict from datetime import datetime, timezone @@ -280,7 +296,7 @@ def consecutive_loss_days(db: Database | None = None) -> int: database = db or get_db() rows = database.fetchall( - """SELECT realized_pnl, close_at_ms FROM groups + """SELECT realized_pnl, close_at_ms, close_reason FROM groups WHERE status='closed' AND close_at_ms IS NOT NULL ORDER BY close_at_ms ASC""" ) @@ -300,7 +316,10 @@ def consecutive_loss_days(db: Database | None = None) -> int: .astimezone(sh) .strftime("%Y-%m-%d") ) - day_pnl[day] += float(r["realized_pnl"] or 0) + day_pnl[day] += _martingale_day_pnl_contrib( + float(r["realized_pnl"] or 0), + r["close_reason"], + ) if not day_pnl: return 0 streak = 0 diff --git a/backend/tests/test_risk_sizing.py b/backend/tests/test_risk_sizing.py index 304ddf7..3406926 100644 --- a/backend/tests/test_risk_sizing.py +++ b/backend/tests/test_risk_sizing.py @@ -242,3 +242,45 @@ def test_martingale_doubles_capped(tmp_path, monkeypatch) -> None: assert mg["doubles"] == 3 assert abs(float(mg["effective_pct"]) - 16.0) < 1e-9 db.close() + + +def test_expiry_settle_counts_as_loss_even_if_profit(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 + + db = Database(tmp_path / "mg_exp.db") + sh = ZoneInfo("Asia/Shanghai") + + def day_ms(ymd: str) -> int: + dt = datetime.strptime(ymd, "%Y-%m-%d").replace(hour=16, tzinfo=sh) + return int(dt.astimezone(timezone.utc).timestamp() * 1000) + + # 达标盈利打断;随后两天均为到期小盈利 → 仍计连亏 2 + rows = [ + ("e0", day_ms("2026-07-28"), 20.0, "fixed_usdt"), + ("e1", day_ms("2026-07-29"), 3.5, "expiry"), + ("e2", day_ms("2026-07-30"), 1.2, "expiry"), + ] + for gid, ms, pnl, reason in rows: + db.execute( + """INSERT INTO groups( + group_id, status, realized_pnl, close_at_ms, open_at_ms, close_reason + ) VALUES(?,?,?,?,?,?)""", + (gid, "closed", pnl, ms, ms - 3600_000, reason), + ) + assert consecutive_loss_days(db) == 2 + + # 再来一天达标盈利 → 连亏清零 + db.execute( + """INSERT INTO groups( + group_id, status, realized_pnl, close_at_ms, open_at_ms, close_reason + ) VALUES(?,?,?,?,?,?)""", + ("e3", "closed", 15.0, day_ms("2026-07-31"), day_ms("2026-07-31") - 1000, "fixed_usdt"), + ) + assert consecutive_loss_days(db) == 0 + db.close() diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 8d899f8..00c6580 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -816,7 +816,7 @@ export default function SettingsPage() {
{riskLossPct > 3 ? "亏损幅度超过 3% 时不可启用倍投。" - : "连续亏损日达阈值后,按基础幅度翻倍(不改保存的幅度值)。最多可设翻倍次数。"} + : "连续亏损日达阈值后翻倍。到期结算无论盈亏(含小盈利)都按亏损日计;达标平仓盈利才打断连亏。"}
{martingaleOn && riskLossPct <= 3 ? (