diff --git a/account_risk_lib.py b/account_risk_lib.py index 5807a0f..f474c3c 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -9,12 +9,14 @@ STATUS_NORMAL = "normal" STATUS_FREEZE_1H = "freeze_1h" STATUS_FREEZE_4H = "freeze_4h" STATUS_DAILY = "freeze_daily" +STATUS_FREEZE_POSITION = "freeze_position" STATUS_LABELS = { STATUS_NORMAL: "正常", STATUS_FREEZE_1H: "1h冻结", STATUS_FREEZE_4H: "4h冻结", STATUS_DAILY: "日冻结", + STATUS_FREEZE_POSITION: "仓位上限冻结", } MOOD_ISSUE_OPTIONS = ( @@ -84,6 +86,13 @@ def manual_close_daily_limit() -> int: return 2 +def max_active_positions_from_env(default: int = 1) -> int: + try: + return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", str(default)))) + except (TypeError, ValueError): + return max(1, default) + + def mood_issues_daily_freeze_enabled() -> bool: return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True) @@ -688,6 +697,39 @@ def enrich_risk_status_countdown( return st +def apply_position_limit_risk( + st: dict[str, Any], + active_count: int, + *, + max_active_positions: Optional[int] = None, +) -> dict[str, Any]: + """持仓达 env MAX_ACTIVE_POSITIONS 时叠加「仓位上限冻结」(时间冻结优先展示)。""" + out = dict(st or {}) + try: + mx = max(1, int(max_active_positions if max_active_positions is not None else max_active_positions_from_env())) + except (TypeError, ValueError): + mx = max_active_positions_from_env() + try: + ac = max(0, int(active_count)) + except (TypeError, ValueError): + ac = 0 + out["max_active_positions"] = mx + out["active_count"] = ac + if out.get("status") != STATUS_NORMAL: + return out + if ac >= mx: + out["status"] = STATUS_FREEZE_POSITION + out["status_label"] = STATUS_LABELS[STATUS_FREEZE_POSITION] + out["can_trade"] = False + out["reason"] = f"已达最大持仓数({ac}/{mx}),平仓前不可新开" + out["position_limit_frozen"] = True + out["freeze_until_ms"] = None + out["freeze_remaining_sec"] = 0 + else: + out["position_limit_frozen"] = False + return out + + def compute_account_risk_status( conn, *, diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 99c59c4..1f1c47a 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -1542,6 +1542,7 @@ def get_db(): def hub_account_risk_status(conn): from account_risk_lib import ( + apply_position_limit_risk, compute_account_risk_status, enrich_risk_status_countdown, ensure_account_risk_schema, @@ -1555,7 +1556,12 @@ def hub_account_risk_status(conn): now=now, fmt_local_ms=ms_to_app_local_str, ) - return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + return apply_position_limit_risk( + st, + get_active_position_count(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) def hub_user_initiated_close( diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index eadea96..68bb524 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -5,8 +5,8 @@ - - + + diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index cbab36a..abf3b1f 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -1533,6 +1533,7 @@ def get_db(): def hub_account_risk_status(conn): from account_risk_lib import ( + apply_position_limit_risk, compute_account_risk_status, enrich_risk_status_countdown, ensure_account_risk_schema, @@ -1546,7 +1547,12 @@ def hub_account_risk_status(conn): now=now, fmt_local_ms=ms_to_app_local_str, ) - return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + return apply_position_limit_risk( + st, + get_active_position_count(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) def hub_user_initiated_close( diff --git a/crypto_monitor_gate/templates/index.html b/crypto_monitor_gate/templates/index.html index a10f789..33067cc 100644 --- a/crypto_monitor_gate/templates/index.html +++ b/crypto_monitor_gate/templates/index.html @@ -5,8 +5,8 @@ - - + + diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index 1543cdc..706a19c 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -1533,6 +1533,7 @@ def get_db(): def hub_account_risk_status(conn): from account_risk_lib import ( + apply_position_limit_risk, compute_account_risk_status, enrich_risk_status_countdown, ensure_account_risk_schema, @@ -1546,7 +1547,12 @@ def hub_account_risk_status(conn): now=now, fmt_local_ms=ms_to_app_local_str, ) - return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + return apply_position_limit_risk( + st, + get_active_position_count(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) def hub_user_initiated_close( diff --git a/crypto_monitor_gate_bot/templates/index.html b/crypto_monitor_gate_bot/templates/index.html index a10f789..33067cc 100644 --- a/crypto_monitor_gate_bot/templates/index.html +++ b/crypto_monitor_gate_bot/templates/index.html @@ -5,8 +5,8 @@ - - + + diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 95edbcf..7d9c277 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -1520,6 +1520,7 @@ def get_db(): def hub_account_risk_status(conn): from account_risk_lib import ( + apply_position_limit_risk, compute_account_risk_status, enrich_risk_status_countdown, ensure_account_risk_schema, @@ -1533,7 +1534,12 @@ def hub_account_risk_status(conn): now=now, fmt_local_ms=ms_to_app_local_str, ) - return enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + st = enrich_risk_status_countdown(st, now=now, daily_reset_hour=TRADING_DAY_RESET_HOUR) + return apply_position_limit_risk( + st, + get_active_position_count(conn), + max_active_positions=MAX_ACTIVE_POSITIONS, + ) def hub_user_initiated_close( diff --git a/crypto_monitor_okx/templates/index.html b/crypto_monitor_okx/templates/index.html index de222d1..c57f964 100644 --- a/crypto_monitor_okx/templates/index.html +++ b/crypto_monitor_okx/templates/index.html @@ -5,8 +5,8 @@ - - + + diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index 2c47e7c..4f0e93b 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -97,17 +97,20 @@ APP_TIMEZONE=Asia/Shanghai | 字段 | 说明 | |------|------| -| `status` | `normal` / `freeze_1h` / `freeze_4h` / `freeze_daily` | +| `status` | `normal` / `freeze_1h` / `freeze_4h` / `freeze_daily` / `freeze_position` | | `status_label` | 中文标签 | | `can_trade` | 是否允许新开仓(仅风控维度) | | `reason` | 悬停提示文案 | +| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env` 中 `MAX_ACTIVE_POSITIONS` | | `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) | | `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) | | `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) | +**仓位上限冻结**:当活跃持仓数 ≥ 实例 `.env` 的 `MAX_ACTIVE_POSITIONS`(默认 1)且账户无时间类冻结时,徽章显示 **仓位上限冻结**;平仓后自动恢复 **正常**。时间冻结(1h/4h/日)优先展示。 + ## 前端倒计时 -- 共用脚本:`static/account_risk_badge.js?v=3` +- 共用脚本:`static/account_risk_badge.js?v=4` - 样式:`static/account_risk_badge.css` - 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间 - 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差 @@ -118,7 +121,7 @@ APP_TIMEZONE=Asia/Shanghai ## 相关代码 -- `account_risk_lib.py` — 状态机、`enrich_risk_status_countdown`、`on_user_initiated_close` +- `account_risk_lib.py` — 状态机、`enrich_risk_status_countdown`、`apply_position_limit_risk`、`on_user_initiated_close` - `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/embed_templates/embed_shell.html b/embed_templates/embed_shell.html index 8fe8a50..bc2c254 100644 --- a/embed_templates/embed_shell.html +++ b/embed_templates/embed_shell.html @@ -5,10 +5,10 @@ - + - + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 443c86f..d1203ef 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,8 +16,8 @@ - - + + diff --git a/static/account_risk_badge.css b/static/account_risk_badge.css index 581ff88..4ac19ba 100644 --- a/static/account_risk_badge.css +++ b/static/account_risk_badge.css @@ -22,6 +22,11 @@ html[data-theme="dark"] { --risk-daily-border: rgba(210, 75, 120, 0.5); --risk-daily-glow: rgba(210, 75, 120, 0.36); + --risk-position-fg: #8ec8ff; + --risk-position-bg: rgba(55, 120, 210, 0.18); + --risk-position-border: rgba(75, 145, 230, 0.48); + --risk-position-glow: rgba(75, 145, 230, 0.34); + --risk-badge-shadow: 0 1px 2px rgba(0, 0, 0, 0.28); } @@ -46,6 +51,11 @@ html[data-theme="light"] { --risk-daily-border: rgba(155, 28, 68, 0.34); --risk-daily-glow: rgba(180, 35, 80, 0.18); + --risk-position-fg: #0b5cab; + --risk-position-bg: rgba(20, 100, 190, 0.12); + --risk-position-border: rgba(15, 85, 165, 0.36); + --risk-position-glow: rgba(20, 100, 190, 0.2); + --risk-badge-shadow: 0 1px 2px rgba(20, 50, 80, 0.1); } @@ -113,6 +123,13 @@ html[data-hub-linked="1"] .header-row .risk-status-badge { --risk-glow: var(--risk-daily-glow); } +.risk-status-freeze_position { + --risk-fg: var(--risk-position-fg); + --risk-bg: var(--risk-position-bg); + --risk-border: var(--risk-position-border); + --risk-glow: var(--risk-position-glow); +} + /* 实例页:与交易所标签并排 */ .header-row .risk-status-badge { min-height: 28px; diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index b5749e9..430b8af 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -12,11 +12,14 @@ from account_risk_lib import ( STATUS_DAILY, STATUS_FREEZE_1H, STATUS_FREEZE_4H, + STATUS_FREEZE_POSITION, STATUS_NORMAL, account_risk_blocks_trading, + apply_position_limit_risk, compute_account_risk_status, enrich_risk_status_countdown, ensure_account_risk_schema, + max_active_positions_from_env, on_journal_saved, on_manual_close, on_user_initiated_close, @@ -489,6 +492,33 @@ class AccountRiskLibTests(unittest.TestCase): ok, _ = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now) self.assertTrue(ok) + def test_position_limit_freeze_from_env(self): + os.environ["MAX_ACTIVE_POSITIONS"] = "2" + st = apply_position_limit_risk({"status": STATUS_NORMAL, "can_trade": True}, 2) + self.assertEqual(st["status"], STATUS_FREEZE_POSITION) + self.assertEqual(st["status_label"], "仓位上限冻结") + self.assertFalse(st["can_trade"]) + self.assertIn("2/2", st["reason"]) + self.assertEqual(st["max_active_positions"], 2) + + def test_position_limit_normal_when_under_cap(self): + st = apply_position_limit_risk({"status": STATUS_NORMAL, "can_trade": True}, 0, max_active_positions=1) + self.assertEqual(st["status"], STATUS_NORMAL) + self.assertTrue(st["can_trade"]) + + def test_time_freeze_takes_priority_over_position_limit(self): + st = apply_position_limit_risk( + {"status": STATUS_FREEZE_4H, "status_label": "4h冻结", "can_trade": False}, + 5, + max_active_positions=1, + ) + self.assertEqual(st["status"], STATUS_FREEZE_4H) + self.assertEqual(st["active_count"], 5) + + def test_max_active_positions_from_env(self): + os.environ["MAX_ACTIVE_POSITIONS"] = "3" + self.assertEqual(max_active_positions_from_env(), 3) + if __name__ == "__main__": unittest.main()