diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index 46d99b9..e85fa87 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -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 # 资金与仓位刷新周期(秒) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 801cc8b..e39769a 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -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 diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index b6371fb..49b0744 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -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 # 资金与仓位刷新周期(秒) diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 66f8490..dfb48ad 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -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 diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index 52ef306..b0a96f2 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -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 # 资金与仓位刷新周期(秒) diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 6d81610..217d6be 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -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 diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index b34abb5..87bb84f 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -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` 结束计划时登记风控 diff --git a/docs/env配置说明.md b/docs/env配置说明.md index 65385a8..176dc6a 100644 --- a/docs/env配置说明.md +++ b/docs/env配置说明.md @@ -131,6 +131,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT | 手动平仓冷静(小时) | | | 复盘情绪冷静(小时) | | | 日手动平仓次数上限 | | +| 日亏损次数上限 | 默认2;达限当日冻结开仓;0=不启用 | | 情绪标签日冻结 | | 详见 [account-risk-cooldown.md](./account-risk-cooldown.md). diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 24aa0d4..bc817d5 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -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", diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index dcc9d00..dc70170 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -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", diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index 8171796..7bee01d 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -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()), diff --git a/lib/trade/account_risk_lib.py b/lib/trade/account_risk_lib.py index f5e4140..2ee1969 100644 --- a/lib/trade/account_risk_lib.py +++ b/lib/trade/account_risk_lib.py @@ -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, diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index c63d06e..df20c92 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -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()