Show position-limit freeze on hub and instance risk badges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-24 01:58:24 +08:00
parent 3e8ecbf712
commit 322060de31
14 changed files with 135 additions and 19 deletions
+42
View File
@@ -9,12 +9,14 @@ STATUS_NORMAL = "normal"
STATUS_FREEZE_1H = "freeze_1h" STATUS_FREEZE_1H = "freeze_1h"
STATUS_FREEZE_4H = "freeze_4h" STATUS_FREEZE_4H = "freeze_4h"
STATUS_DAILY = "freeze_daily" STATUS_DAILY = "freeze_daily"
STATUS_FREEZE_POSITION = "freeze_position"
STATUS_LABELS = { STATUS_LABELS = {
STATUS_NORMAL: "正常", STATUS_NORMAL: "正常",
STATUS_FREEZE_1H: "1h冻结", STATUS_FREEZE_1H: "1h冻结",
STATUS_FREEZE_4H: "4h冻结", STATUS_FREEZE_4H: "4h冻结",
STATUS_DAILY: "日冻结", STATUS_DAILY: "日冻结",
STATUS_FREEZE_POSITION: "仓位上限冻结",
} }
MOOD_ISSUE_OPTIONS = ( MOOD_ISSUE_OPTIONS = (
@@ -84,6 +86,13 @@ def manual_close_daily_limit() -> int:
return 2 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: def mood_issues_daily_freeze_enabled() -> bool:
return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True) return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True)
@@ -688,6 +697,39 @@ def enrich_risk_status_countdown(
return st 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( def compute_account_risk_status(
conn, conn,
*, *,
+7 -1
View File
@@ -1542,6 +1542,7 @@ def get_db():
def hub_account_risk_status(conn): def hub_account_risk_status(conn):
from account_risk_lib import ( from account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status, compute_account_risk_status,
enrich_risk_status_countdown, enrich_risk_status_countdown,
ensure_account_risk_schema, ensure_account_risk_schema,
@@ -1555,7 +1556,12 @@ def hub_account_risk_status(conn):
now=now, now=now,
fmt_local_ms=ms_to_app_local_str, 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( def hub_user_initiated_close(
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=16"></script> <script src="/static/instance_theme.js?v=16"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=3"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控"> <meta name="apple-mobile-web-app-title" content="监控">
+7 -1
View File
@@ -1533,6 +1533,7 @@ def get_db():
def hub_account_risk_status(conn): def hub_account_risk_status(conn):
from account_risk_lib import ( from account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status, compute_account_risk_status,
enrich_risk_status_countdown, enrich_risk_status_countdown,
ensure_account_risk_schema, ensure_account_risk_schema,
@@ -1546,7 +1547,12 @@ def hub_account_risk_status(conn):
now=now, now=now,
fmt_local_ms=ms_to_app_local_str, 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( def hub_user_initiated_close(
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=16"></script> <script src="/static/instance_theme.js?v=16"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=3"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控"> <meta name="apple-mobile-web-app-title" content="监控">
+7 -1
View File
@@ -1533,6 +1533,7 @@ def get_db():
def hub_account_risk_status(conn): def hub_account_risk_status(conn):
from account_risk_lib import ( from account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status, compute_account_risk_status,
enrich_risk_status_countdown, enrich_risk_status_countdown,
ensure_account_risk_schema, ensure_account_risk_schema,
@@ -1546,7 +1547,12 @@ def hub_account_risk_status(conn):
now=now, now=now,
fmt_local_ms=ms_to_app_local_str, 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( def hub_user_initiated_close(
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=16"></script> <script src="/static/instance_theme.js?v=16"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=3"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控"> <meta name="apple-mobile-web-app-title" content="监控">
+7 -1
View File
@@ -1520,6 +1520,7 @@ def get_db():
def hub_account_risk_status(conn): def hub_account_risk_status(conn):
from account_risk_lib import ( from account_risk_lib import (
apply_position_limit_risk,
compute_account_risk_status, compute_account_risk_status,
enrich_risk_status_countdown, enrich_risk_status_countdown,
ensure_account_risk_schema, ensure_account_risk_schema,
@@ -1533,7 +1534,12 @@ def hub_account_risk_status(conn):
now=now, now=now,
fmt_local_ms=ms_to_app_local_str, 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( def hub_user_initiated_close(
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=16"></script> <script src="/static/instance_theme.js?v=16"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<script src="/static/account_risk_badge.js?v=3"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<meta name="apple-mobile-web-app-title" content="监控"> <meta name="apple-mobile-web-app-title" content="监控">
+6 -3
View File
@@ -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` | 中文标签 | | `status_label` | 中文标签 |
| `can_trade` | 是否允许新开仓(仅风控维度) | | `can_trade` | 是否允许新开仓(仅风控维度) |
| `reason` | 悬停提示文案 | | `reason` | 悬停提示文案 |
| `active_count` / `max_active_positions` | 当前活跃持仓与 `.env``MAX_ACTIVE_POSITIONS` |
| `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) | | `cooloff_until_ms` | 1h/4h 冷静期结束时间戳(毫秒) |
| `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) | | `freeze_until_ms` | 倒计时结束时间戳(日冻结为下一交易日切点) |
| `freeze_remaining_sec` | 服务端计算的剩余秒数(供调试) | | `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` - 样式:`static/account_risk_badge.css`
- 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间 - 展示格式:`4h冻结 · 3h 12m`;日冻结为距下一交易日切点剩余时间
- 倒计时优先用服务端 `freeze_remaining_sec` 推算结束时刻,避免绝对时间戳与时区/脏数据偏差 - 倒计时优先用服务端 `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` - `hub_bridge.py``/api/hub/account-risk/user-close`
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close - `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
- `strategy_trend_register.py``stop_trend_pullback` 结束计划时登记风控 - `strategy_trend_register.py``stop_trend_pullback` 结束计划时登记风控
+2 -2
View File
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<script src="/static/instance_theme.js?v=17"></script> <script src="/static/instance_theme.js?v=17"></script>
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4"> <link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=3"> <link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=2"> <link rel="stylesheet" href="/static/instance_page.css?v=2">
<link rel="stylesheet" href="/static/instance_theme.css?v=18"> <link rel="stylesheet" href="/static/instance_theme.css?v=18">
<script src="/static/account_risk_badge.js?v=3"></script> <script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14"> <meta name="theme-color" content="#0b0d14">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title> <title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
</head> </head>
+2 -2
View File
@@ -16,8 +16,8 @@
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" /> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript> <noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260614-calculator" /> <link rel="stylesheet" href="/assets/app.css?v=20260614-calculator" />
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=3" /> <link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
<script src="/assets/account_risk_badge.js?v=3"></script> <script src="/assets/account_risk_badge.js?v=4"></script>
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" /> <link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
</head> </head>
<body> <body>
+17
View File
@@ -22,6 +22,11 @@ html[data-theme="dark"] {
--risk-daily-border: rgba(210, 75, 120, 0.5); --risk-daily-border: rgba(210, 75, 120, 0.5);
--risk-daily-glow: rgba(210, 75, 120, 0.36); --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); --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-border: rgba(155, 28, 68, 0.34);
--risk-daily-glow: rgba(180, 35, 80, 0.18); --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); --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-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 { .header-row .risk-status-badge {
min-height: 28px; min-height: 28px;
+30
View File
@@ -12,11 +12,14 @@ from account_risk_lib import (
STATUS_DAILY, STATUS_DAILY,
STATUS_FREEZE_1H, STATUS_FREEZE_1H,
STATUS_FREEZE_4H, STATUS_FREEZE_4H,
STATUS_FREEZE_POSITION,
STATUS_NORMAL, STATUS_NORMAL,
account_risk_blocks_trading, account_risk_blocks_trading,
apply_position_limit_risk,
compute_account_risk_status, compute_account_risk_status,
enrich_risk_status_countdown, enrich_risk_status_countdown,
ensure_account_risk_schema, ensure_account_risk_schema,
max_active_positions_from_env,
on_journal_saved, on_journal_saved,
on_manual_close, on_manual_close,
on_user_initiated_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) ok, _ = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now)
self.assertTrue(ok) 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__": if __name__ == "__main__":
unittest.main() unittest.main()