Add daily loss-count freeze for account risk cooldown.

RISK_DAILY_LOSS_LIMIT (default 2, 0 disables) freezes new opens after N losing closes in the trading day.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-21 22:13:38 +08:00
parent a4be294c06
commit 301a464f29
13 changed files with 166 additions and 3 deletions
+2
View File
@@ -158,6 +158,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+11
View File
@@ -2751,6 +2751,17 @@ def insert_trade_record(
opened_at_ms=open_ts_ms,
closed_at_ms=close_ts_ms,
)
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
+2
View File
@@ -160,6 +160,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+11
View File
@@ -2445,6 +2445,17 @@ def insert_trade_record(
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
+2
View File
@@ -219,6 +219,8 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
# 日亏损次数上限:平仓盈亏<0 计1次;达限当日冻结开仓;0=不启用
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
# 资金与仓位刷新周期(秒)
+11
View File
@@ -2364,6 +2364,17 @@ def insert_trade_record(
sync_trade_records_from_exchange(conn, force=False)
except Exception:
pass
try:
from lib.trade.account_risk_lib import on_closed_trade_pnl
close_dt = parse_dt_for_trading_day(close_ts)
on_closed_trade_pnl(
conn,
pnl_amount=pnl_amount,
trading_day=get_trading_day(close_dt),
)
except Exception:
pass
return tid
+7 -1
View File
@@ -41,6 +41,7 @@
|------|------|
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
| 平仓亏损达 `RISK_DAILY_LOSS_LIMIT` 次(同一交易日) | **日冻结**(默认 2 次;`0`=不启用) |
| 复盘勾选任意情绪标签 | **日冻结** |
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须处于 4h 档冷静期中) |
@@ -77,11 +78,15 @@ RISK_CONTROL_ENABLED=true
RISK_COOLING_HOURS_MANUAL=4
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
RISK_DAILY_LOSS_LIMIT=2
RISK_MOOD_ISSUES_DAILY_FREEZE=true
TRADING_DAY_RESET_HOUR=8
APP_TIMEZONE=Asia/Shanghai
```
- `RISK_DAILY_LOSS_LIMIT`:任意已平仓交易若盈亏 < 0 计 1 次(含止损/止盈后仍亏损等);达上限当日冻结开仓;`0` 表示不因亏损次数冻结.
- `RISK_MANUAL_CLOSE_DAILY_LIMIT`:仅计**用户主动平仓**次数(与亏损次数独立).
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控).
## API 与 `risk_status` 字段
@@ -102,6 +107,7 @@ APP_TIMEZONE=Asia/Shanghai
| `can_trade` | 是否允许新开仓(仅风控维度) |
| `reason` | 悬停提示文案 |
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env``MAX_ACTIVE_POSITIONS` |
| `daily_loss_count` / `daily_loss_limit` | 当日亏损笔数与上限(`0` 上限表示未启用) |
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) |
@@ -123,7 +129,7 @@ APP_TIMEZONE=Asia/Shanghai
## 相关代码
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`
- `account_risk_lib.py` — 状态机,`enrich_risk_status_countdown`,`apply_position_limit_risk`,`on_user_initiated_close`,`on_closed_trade_pnl`
- `hub_bridge.py``/api/hub/account-risk/user-close`
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
- `strategy_trend_register.py``stop_trend_pullback` 结束计划时登记风控
+1
View File
@@ -131,6 +131,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 手动平仓冷静(小时) | |
| 复盘情绪冷静(小时) | |
| 日手动平仓次数上限 | |
| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 |
| 情绪标签日冻结 | |
详见 [account-risk-cooldown.md](./account-risk-cooldown.md).
+1
View File
@@ -58,6 +58,7 @@ HOT_RELOAD_EXACT = frozenset({
"RISK_COOLING_HOURS_MANUAL",
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE",
"KEY_AUTO_ORDER_ENABLED",
"TRADE_DIRECTION_RESTRICT_ENABLED",
+2
View File
@@ -94,6 +94,7 @@ _SHARED_SECTIONS: list[dict[str, Any]] = [
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
@@ -197,6 +198,7 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_COOLING_HOURS_MANUAL": "4",
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
+10
View File
@@ -8,6 +8,7 @@ from lib.key_monitor.key_auto_order_lib import load_key_auto_order_enabled
from lib.trade.account_risk_lib import (
cooling_hours_manual,
cooling_hours_manual_journal,
daily_loss_limit,
manual_close_daily_limit,
max_active_positions_from_env,
mood_issues_daily_freeze_enabled,
@@ -113,6 +114,15 @@ def build_instance_settings_view(
_row("手动平仓冷静", f"{cooling_hours_manual():g} 小时"),
_row("复盘后冷静", f"{cooling_hours_manual_journal():g} 小时", "手动平仓且填写说明后可缩短"),
_row("日手动平仓上限", f"{manual_close_daily_limit()}", "超限当日冻结"),
_row(
"日亏损次数上限",
(
f"{daily_loss_limit()}"
if daily_loss_limit() > 0
else "未启用"
),
"平仓亏损达限后当日冻结开仓;0=不启用" if daily_loss_limit() > 0 else "RISK_DAILY_LOSS_LIMIT=0",
),
_row(
"复盘情绪日冻结",
_on_off(mood_issues_daily_freeze_enabled()),
+69 -2
View File
@@ -86,6 +86,14 @@ def manual_close_daily_limit() -> int:
return 2
def daily_loss_limit() -> int:
"""日亏损次数上限:达限当日冻结开仓;0=不因亏损次数冻结."""
try:
return max(0, int(os.getenv("RISK_DAILY_LOSS_LIMIT", "2")))
except (TypeError, ValueError):
return 2
def max_active_positions_from_env(default: int = 1) -> int:
try:
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default))))
@@ -116,6 +124,7 @@ def ensure_account_risk_schema(conn) -> None:
id INTEGER PRIMARY KEY CHECK (id = 1),
trading_day TEXT,
manual_close_count INTEGER DEFAULT 0,
daily_loss_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER,
cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0,
@@ -124,10 +133,18 @@ def ensure_account_risk_schema(conn) -> None:
updated_at TEXT
)"""
)
cols = {
str(r[1])
for r in conn.execute("PRAGMA table_info(account_risk_state)").fetchall()
}
if "daily_loss_count" not in cols:
conn.execute(
"ALTER TABLE account_risk_state ADD COLUMN daily_loss_count INTEGER DEFAULT 0"
)
row = conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone()
if not row:
conn.execute(
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_loss_count, daily_frozen) VALUES (1, '', 0, 0, 0)"
)
@@ -268,6 +285,7 @@ def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) ->
"""UPDATE account_risk_state SET
trading_day=?,
manual_close_count=0,
daily_loss_count=0,
daily_frozen=0,
cooloff_until_ms=?,
cooloff_hours=?,
@@ -600,6 +618,43 @@ def on_manual_close(
)
def on_closed_trade_pnl(
conn,
*,
pnl_amount: Any,
trading_day: str,
now: Optional[datetime] = None,
) -> None:
"""
已平仓交易记盈亏后调用:亏损笔数达 RISK_DAILY_LOSS_LIMIT 则当日冻结开仓.
上限为 0 时不启用本规则.
"""
if not risk_control_enabled():
return
limit = daily_loss_limit()
if limit <= 0:
return
try:
pnl = float(pnl_amount)
except (TypeError, ValueError):
return
if pnl >= 0:
return
row = _sync_trading_day(conn, trading_day, now=now)
if int(_row_get(row, "daily_frozen") or 0) == 1:
return
count = int(_row_get(row, "daily_loss_count") or 0) + 1
conn.execute(
"""UPDATE account_risk_state SET
daily_loss_count=?,
updated_at=?
WHERE id=1""",
(count, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
)
if count >= limit:
_set_daily_frozen(conn, trading_day=trading_day, now=now)
def on_journal_saved(
conn,
*,
@@ -762,6 +817,7 @@ def compute_account_risk_status(
"cooloff_until_ms": None,
"cooloff_until": None,
"manual_close_count": 0,
"daily_loss_count": 0,
"daily_frozen": False,
}
row = _sync_trading_day(conn, trading_day, now=now)
@@ -784,12 +840,21 @@ def compute_account_risk_status(
row = _load_state(conn)
cooloff_until_ms = _resolved_cooloff_until_ms(row, now_ms)
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
daily_loss_count = int(_row_get(row, "daily_loss_count") or 0)
loss_limit = daily_loss_limit()
status = STATUS_NORMAL
reason = ""
if daily_frozen:
status = STATUS_DAILY
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
parts = []
if loss_limit > 0 and daily_loss_count >= loss_limit:
parts.append(f"日亏损 {daily_loss_count}/{loss_limit}")
if manual_close_count >= manual_close_daily_limit():
parts.append(f"手动平仓 {manual_close_count}")
if not parts:
parts.append("手动平仓/日亏损达限或复盘情绪标签")
reason = "账户今日已冻结(" + "".join(parts) + ")"
elif cooloff_until_ms is not None:
remaining_ms = cooloff_until_ms - now_ms
hours = _cooloff_hours_value(row)
@@ -818,6 +883,8 @@ def compute_account_risk_status(
if fmt_local_ms and cooloff_until_ms
else None,
"manual_close_count": manual_close_count,
"daily_loss_count": daily_loss_count,
"daily_loss_limit": loss_limit,
"daily_frozen": daily_frozen,
"pending_journal_trade_id": pending,
"freeze_remaining_sec": freeze_remaining_sec if not can_trade else 0,
+37
View File
@@ -20,6 +20,7 @@ from lib.trade.account_risk_lib import (
enrich_risk_status_countdown,
ensure_account_risk_schema,
max_active_positions_from_env,
on_closed_trade_pnl,
on_journal_saved,
on_manual_close,
on_user_initiated_close,
@@ -58,6 +59,7 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["RISK_COOLING_HOURS_MANUAL"] = "4"
os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
os.environ["RISK_DAILY_LOSS_LIMIT"] = "2"
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
os.environ["APP_TIMEZONE"] = "Asia/Shanghai"
@@ -521,6 +523,41 @@ class AccountRiskLibTests(unittest.TestCase):
os.environ["MAX_ACTIVE_POSITIONS"] = "3"
self.assertEqual(max_active_positions_from_env(), 3)
def test_daily_loss_limit_freezes_on_second_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-1.5, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 1)
self.assertEqual(st["status"], STATUS_NORMAL)
on_closed_trade_pnl(conn, pnl_amount=-0.2, trading_day="2026-06-14", now=now)
st2 = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st2["daily_loss_count"], 2)
self.assertEqual(st2["status"], STATUS_DAILY)
self.assertFalse(st2["can_trade"])
self.assertIn("日亏损", st2["reason"])
def test_daily_loss_limit_zero_disables(self):
os.environ["RISK_DAILY_LOSS_LIMIT"] = "0"
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=-10, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["daily_loss_limit"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertTrue(st["can_trade"])
def test_profitable_close_does_not_count_loss(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_closed_trade_pnl(conn, pnl_amount=3.2, trading_day="2026-06-14", now=now)
on_closed_trade_pnl(conn, pnl_amount=0, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["daily_loss_count"], 0)
self.assertEqual(st["status"], STATUS_NORMAL)
if __name__ == "__main__":
unittest.main()