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