feat(risk): add account cooldown and daily freeze after manual/external close
Implements shared account_risk_lib with 4h/1h cooloff and daily freeze rules, wires hooks into all four exchange apps and hub monitor UI, with tests and docs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -129,6 +129,17 @@ DAILY_OPEN_ALERT_THRESHOLD=5
|
||||
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
|
||||
DAILY_OPEN_HARD_LIMIT=0
|
||||
|
||||
# =============================================================================
|
||||
# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签)
|
||||
# 详见 docs/account-risk-cooldown.md
|
||||
# =============================================================================
|
||||
# RISK_CONTROL_ENABLED=true
|
||||
# RISK_COOLING_HOURS_MANUAL=4
|
||||
# RISK_COOLING_HOURS_EXTERNAL=4
|
||||
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||
|
||||
# 资金与仓位刷新周期(秒)
|
||||
BALANCE_REFRESH_SECONDS=60
|
||||
# 前端价格快照轮询(秒)
|
||||
|
||||
@@ -1491,6 +1491,9 @@ def init_db():
|
||||
from strategy_db import init_strategy_tables
|
||||
|
||||
init_strategy_tables(conn)
|
||||
from account_risk_lib import ensure_account_risk_schema
|
||||
|
||||
ensure_account_risk_schema(conn)
|
||||
backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1524,6 +1527,18 @@ def get_db():
|
||||
return conn
|
||||
|
||||
|
||||
def hub_account_risk_status(conn):
|
||||
from account_risk_lib import compute_account_risk_status, ensure_account_risk_schema
|
||||
|
||||
ensure_account_risk_schema(conn)
|
||||
return compute_account_risk_status(
|
||||
conn,
|
||||
trading_day=get_trading_day(),
|
||||
now=app_now(),
|
||||
fmt_local_ms=ms_to_app_local_str,
|
||||
)
|
||||
|
||||
|
||||
def app_now():
|
||||
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
||||
return datetime.now(APP_TZ).replace(tzinfo=None)
|
||||
@@ -2795,6 +2810,16 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
|
||||
|
||||
def precheck_risk(conn, symbol, direction):
|
||||
now = app_now()
|
||||
from account_risk_lib import account_risk_blocks_trading
|
||||
|
||||
ok_risk, risk_reason = account_risk_blocks_trading(
|
||||
conn,
|
||||
trading_day=get_trading_day(now),
|
||||
now=now,
|
||||
fmt_local_ms=ms_to_app_local_str,
|
||||
)
|
||||
if not ok_risk:
|
||||
return False, risk_reason
|
||||
if not trading_day_reset_allows_new_open(now):
|
||||
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
|
||||
active_count = get_active_position_count(conn)
|
||||
@@ -4137,6 +4162,16 @@ def reconcile_external_closes(conn, days=None):
|
||||
opened_at=opened_at,
|
||||
closed_at=closed_at,
|
||||
)
|
||||
from account_risk_lib import on_external_close, should_apply_external_close_risk
|
||||
|
||||
if should_apply_external_close_risk(result):
|
||||
close_ms = _to_ms_with_fallback(None, closed_at)
|
||||
on_external_close(
|
||||
conn,
|
||||
closed_at_ms=close_ms,
|
||||
trading_day=session_date,
|
||||
now=app_now(),
|
||||
)
|
||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
|
||||
clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
|
||||
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
||||
@@ -6740,12 +6775,14 @@ def render_main_page(page="trade"):
|
||||
rate = round(win/total*100,2) if total else 0
|
||||
active_count = len(order_list)
|
||||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||||
risk_status = hub_account_risk_status(conn)
|
||||
can_trade = can_trade_new_open(
|
||||
time_allows=trading_day_reset_allows_new_open(now),
|
||||
active_count=active_count,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
)
|
||||
key_rule_ctx = key_monitor_rule_template_context(
|
||||
kline_timeframe=KLINE_TIMEFRAME,
|
||||
@@ -6840,6 +6877,7 @@ def render_main_page(page="trade"):
|
||||
journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT,
|
||||
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
|
||||
exchange_display=EXCHANGE_DISPLAY_NAME,
|
||||
risk_status=risk_status,
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
|
||||
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
|
||||
@@ -6907,6 +6945,7 @@ def api_account_snapshot():
|
||||
recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
|
||||
active_count = get_active_position_count(conn)
|
||||
opens_today = count_opens_for_trading_day(conn, trading_day)
|
||||
risk_status = hub_account_risk_status(conn)
|
||||
conn.close()
|
||||
can_trade = can_trade_new_open(
|
||||
time_allows=trading_day_reset_allows_new_open(now),
|
||||
@@ -6914,6 +6953,7 @@ def api_account_snapshot():
|
||||
max_active_positions=MAX_ACTIVE_POSITIONS,
|
||||
opens_today=opens_today,
|
||||
hard_limit=DAILY_OPEN_HARD_LIMIT,
|
||||
extra_blocks=not risk_status.get("can_trade", True),
|
||||
)
|
||||
available_trading_usdt = get_available_trading_usdt()
|
||||
return jsonify({
|
||||
@@ -6928,7 +6968,8 @@ def api_account_snapshot():
|
||||
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
|
||||
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
|
||||
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
|
||||
"trading_day": trading_day
|
||||
"trading_day": trading_day,
|
||||
"risk_status": risk_status,
|
||||
})
|
||||
|
||||
|
||||
@@ -8522,6 +8563,15 @@ def del_order(id):
|
||||
opened_at=opened_at,
|
||||
closed_at=closed_at,
|
||||
)
|
||||
from account_risk_lib import insert_trade_record_id, on_manual_close
|
||||
|
||||
on_manual_close(
|
||||
conn,
|
||||
trade_record_id=insert_trade_record_id(conn),
|
||||
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||
trading_day=session_date,
|
||||
now=app_now(),
|
||||
)
|
||||
conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
|
||||
clear_key_sizing_snapshot_if_flat(conn, session_date)
|
||||
conn.commit()
|
||||
@@ -8750,6 +8800,16 @@ def add_journal():
|
||||
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename
|
||||
)
|
||||
)
|
||||
from account_risk_lib import on_journal_saved
|
||||
|
||||
on_journal_saved(
|
||||
conn,
|
||||
early_exit_trigger=early_exit_trigger,
|
||||
early_exit_note=early_exit_note,
|
||||
mood_issues_raw=mood_issues,
|
||||
trading_day=get_trading_day(),
|
||||
now=app_now(),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
if chart_msg:
|
||||
@@ -9250,6 +9310,7 @@ try:
|
||||
ohlcv_fn=_hub_fetch_ohlcv,
|
||||
volume_rank_fn=_hub_fetch_volume_rank,
|
||||
reconcile_hub_flat_fn=reconcile_hub_external_close,
|
||||
risk_status_fn=hub_account_risk_status,
|
||||
)
|
||||
except Exception as _hub_err:
|
||||
print(f"[hub_bridge] gate: {_hub_err}")
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px}
|
||||
.header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25}
|
||||
.exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em}
|
||||
.header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center}
|
||||
.risk-status-badge{font-size:.78rem;font-weight:600;padding:4px 12px;border-radius:999px;border:1px solid transparent}
|
||||
.risk-status-normal{color:#b8f5d0;background:#14241e;border-color:#2d6a4f}
|
||||
.risk-status-freeze_1h{color:#ffd89a;background:#2a2210;border-color:#8a6a20}
|
||||
.risk-status-freeze_4h{color:#ffb4a0;background:#2a1410;border-color:#8a4020}
|
||||
.risk-status-freeze_daily{color:#ffb0c8;background:#2a1020;border-color:#8a3050}
|
||||
.top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px}
|
||||
.top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none}
|
||||
.top-nav a.active{background:#2a3f6c;color:#dbe4ff}
|
||||
@@ -262,6 +268,7 @@
|
||||
<h1>加密货币|交易监控 + AI复盘一体化</h1>
|
||||
<div class="header-row">
|
||||
<div class="exchange-tag">{{ exchange_display }}</div>
|
||||
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" title="{{ risk_status.reason|default('', true) }}">{{ risk_status.status_label|default('正常') }}</span>
|
||||
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
|
||||
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
|
||||
<svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
@@ -1967,9 +1974,21 @@ function refreshAccountSnapshot(){
|
||||
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
|
||||
latestAvailableUsdt = Number(data.available_trading_usdt);
|
||||
}
|
||||
if (data.risk_status) {
|
||||
const badge = document.getElementById("account-risk-badge");
|
||||
if (badge) {
|
||||
const st = data.risk_status.status || "normal";
|
||||
badge.className = "risk-status-badge risk-status-" + st;
|
||||
badge.innerText = data.risk_status.status_label || "正常";
|
||||
badge.title = data.risk_status.reason || "";
|
||||
}
|
||||
}
|
||||
let canTradeText = "可开仓";
|
||||
if (!data.can_trade) {
|
||||
const parts = [];
|
||||
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
|
||||
parts.push(data.risk_status.reason);
|
||||
}
|
||||
const ac = Number(data.active_count || 0);
|
||||
const max = Number(data.max_active_positions || {{ max_active_positions }});
|
||||
if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
|
||||
|
||||
Reference in New Issue
Block a user