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:
dekun
2026-06-17 17:05:19 +08:00
parent b77741ee21
commit e307eef690
18 changed files with 1015 additions and 5 deletions
+366
View File
@@ -0,0 +1,366 @@
"""账户冷静期 / 日冻结风控(四所实例共用)。"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Any, Callable, Optional
STATUS_NORMAL = "normal"
STATUS_FREEZE_1H = "freeze_1h"
STATUS_FREEZE_4H = "freeze_4h"
STATUS_DAILY = "freeze_daily"
STATUS_LABELS = {
STATUS_NORMAL: "正常",
STATUS_FREEZE_1H: "1h冻结",
STATUS_FREEZE_4H: "4h冻结",
STATUS_DAILY: "日冻结",
}
MOOD_ISSUE_OPTIONS = (
"怕踏空",
"报复开仓",
"盈利飘了",
"拿不住单",
"扛单",
"重仓违规",
)
EXTERNAL_CLOSE_RESULTS = frozenset({"外部平仓"})
def _env_bool(key: str, default: bool = True) -> bool:
raw = (os.getenv(key) or "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
def _env_hours(key: str, default: float) -> float:
try:
v = float(os.getenv(key, str(default)))
except (TypeError, ValueError):
v = default
return max(0.0, v)
def risk_control_enabled() -> bool:
return _env_bool("RISK_CONTROL_ENABLED", True)
def cooling_hours_manual() -> float:
return _env_hours("RISK_COOLING_HOURS_MANUAL", 4.0)
def cooling_hours_external() -> float:
return _env_hours("RISK_COOLING_HOURS_EXTERNAL", 4.0)
def cooling_hours_manual_journal() -> float:
return _env_hours("RISK_COOLING_HOURS_MANUAL_JOURNAL", 1.0)
def manual_close_daily_limit() -> int:
try:
return max(1, int(os.getenv("RISK_MANUAL_CLOSE_DAILY_LIMIT", "2")))
except (TypeError, ValueError):
return 2
def mood_issues_daily_freeze_enabled() -> bool:
return _env_bool("RISK_MOOD_ISSUES_DAILY_FREEZE", True)
def ensure_account_risk_schema(conn) -> None:
conn.execute(
"""CREATE TABLE IF NOT EXISTS account_risk_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
trading_day TEXT,
manual_close_count INTEGER DEFAULT 0,
cooloff_until_ms INTEGER,
cooloff_hours INTEGER,
daily_frozen INTEGER DEFAULT 0,
pending_journal_trade_id INTEGER,
last_close_at_ms INTEGER,
updated_at TEXT
)"""
)
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)"
)
def _row_get(row, key, default=None):
if row is None:
return default
try:
return row[key]
except (KeyError, IndexError, TypeError):
return default
def _now_ms(now: Optional[datetime] = None) -> int:
dt = now or datetime.now()
if dt.tzinfo is None:
return int(dt.replace(tzinfo=timezone.utc).timestamp() * 1000)
return int(dt.timestamp() * 1000)
def _ms_to_local_str(ms: Optional[int], fmt_local: Callable[[int], str]) -> Optional[str]:
if ms is None:
return None
try:
return fmt_local(int(ms))
except Exception:
return None
def _load_state(conn):
ensure_account_risk_schema(conn)
return conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
def _sync_trading_day(conn, trading_day: str, now: Optional[datetime] = None) -> Any:
row = _load_state(conn)
td = (trading_day or "").strip()
stored = str(_row_get(row, "trading_day") or "").strip()
if stored != td:
conn.execute(
"""UPDATE account_risk_state SET
trading_day=?,
manual_close_count=0,
daily_frozen=0,
updated_at=?
WHERE id=1""",
(td, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
)
row = _load_state(conn)
return row
def _set_cooloff(
conn,
*,
trading_day: str,
close_at_ms: int,
hours: float,
now: Optional[datetime] = None,
) -> None:
_sync_trading_day(conn, trading_day, now=now)
h = max(0.0, float(hours))
until_ms = int(close_at_ms + h * 3600 * 1000)
conn.execute(
"""UPDATE account_risk_state SET
cooloff_until_ms=?,
cooloff_hours=?,
last_close_at_ms=?,
updated_at=?
WHERE id=1""",
(
until_ms,
int(h) if h == int(h) else int(round(h)),
int(close_at_ms),
(now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),
),
)
def _set_daily_frozen(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
_sync_trading_day(conn, trading_day, now=now)
conn.execute(
"""UPDATE account_risk_state SET daily_frozen=1, updated_at=? WHERE id=1""",
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
)
def parse_mood_issues(raw: Any) -> list[str]:
if raw is None:
return []
if isinstance(raw, (list, tuple)):
parts = [str(x).strip() for x in raw if str(x).strip()]
else:
parts = [x.strip() for x in str(raw).split(",") if x.strip()]
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
def on_manual_close(
conn,
*,
trade_record_id: int,
closed_at_ms: Optional[int],
trading_day: str,
now: Optional[datetime] = None,
) -> None:
if not risk_control_enabled():
return
row = _sync_trading_day(conn, trading_day, now=now)
count = int(_row_get(row, "manual_close_count") or 0) + 1
close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now)
conn.execute(
"""UPDATE account_risk_state SET
manual_close_count=?,
pending_journal_trade_id=?,
updated_at=?
WHERE id=1""",
(count, int(trade_record_id), (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
)
if count >= manual_close_daily_limit():
_set_daily_frozen(conn, trading_day=trading_day, now=now)
return
_set_cooloff(
conn,
trading_day=trading_day,
close_at_ms=close_ms,
hours=cooling_hours_manual(),
now=now,
)
def on_external_close(
conn,
*,
closed_at_ms: Optional[int],
trading_day: str,
now: Optional[datetime] = None,
) -> None:
if not risk_control_enabled():
return
close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now)
_set_cooloff(
conn,
trading_day=trading_day,
close_at_ms=close_ms,
hours=cooling_hours_external(),
now=now,
)
conn.execute(
"UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1",
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
)
def on_journal_saved(
conn,
*,
early_exit_trigger: str,
early_exit_note: str,
mood_issues_raw: Any,
trading_day: str,
now: Optional[datetime] = None,
) -> None:
if not risk_control_enabled():
return
row = _sync_trading_day(conn, trading_day, now=now)
mood_list = parse_mood_issues(mood_issues_raw)
if mood_issues_daily_freeze_enabled() and mood_list:
_set_daily_frozen(conn, trading_day=trading_day, now=now)
conn.execute(
"UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1",
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
)
return
pending = _row_get(row, "pending_journal_trade_id")
trigger = (early_exit_trigger or "").strip()
note = (early_exit_note or "").strip()
if pending and trigger == "手动平仓" and note:
last_close_ms = _row_get(row, "last_close_at_ms")
base_ms = int(last_close_ms) if last_close_ms else _now_ms(now)
_set_cooloff(
conn,
trading_day=trading_day,
close_at_ms=base_ms,
hours=cooling_hours_manual_journal(),
now=now,
)
conn.execute(
"UPDATE account_risk_state SET pending_journal_trade_id=NULL, updated_at=? WHERE id=1",
((now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S"),),
)
def compute_account_risk_status(
conn,
*,
trading_day: str,
now: Optional[datetime] = None,
fmt_local_ms: Optional[Callable[[int], str]] = None,
) -> dict[str, Any]:
if not risk_control_enabled():
return {
"enabled": False,
"status": STATUS_NORMAL,
"status_label": STATUS_LABELS[STATUS_NORMAL],
"can_trade": True,
"reason": "",
"cooloff_until_ms": None,
"cooloff_until": None,
"manual_close_count": 0,
"daily_frozen": False,
}
row = _sync_trading_day(conn, trading_day, now=now)
now_ms = _now_ms(now)
daily_frozen = int(_row_get(row, "daily_frozen") or 0) == 1
cooloff_until_ms = _row_get(row, "cooloff_until_ms")
try:
cooloff_until_ms = int(cooloff_until_ms) if cooloff_until_ms is not None else None
except (TypeError, ValueError):
cooloff_until_ms = None
cooloff_hours = _row_get(row, "cooloff_hours")
manual_close_count = int(_row_get(row, "manual_close_count") or 0)
status = STATUS_NORMAL
reason = ""
if daily_frozen:
status = STATUS_DAILY
reason = f"账户今日已冻结(手动平仓 {manual_close_count} 次或复盘情绪标签)"
elif cooloff_until_ms is not None and cooloff_until_ms > now_ms:
h = int(cooloff_hours or cooling_hours_manual())
status = STATUS_FREEZE_1H if h <= 1 else STATUS_FREEZE_4H
until_str = _ms_to_local_str(cooloff_until_ms, fmt_local_ms) if fmt_local_ms else None
label = STATUS_LABELS[status]
reason = f"账户{label}"
if until_str:
reason += f",至 {until_str}"
can_trade = status == STATUS_NORMAL
return {
"enabled": True,
"status": status,
"status_label": STATUS_LABELS[status],
"can_trade": can_trade,
"reason": reason,
"cooloff_until_ms": cooloff_until_ms if cooloff_until_ms and cooloff_until_ms > now_ms else None,
"cooloff_until": _ms_to_local_str(cooloff_until_ms, fmt_local_ms)
if fmt_local_ms and cooloff_until_ms and cooloff_until_ms > now_ms
else None,
"manual_close_count": manual_close_count,
"daily_frozen": daily_frozen,
"pending_journal_trade_id": _row_get(row, "pending_journal_trade_id"),
}
def account_risk_blocks_trading(
conn,
*,
trading_day: str,
now: Optional[datetime] = None,
fmt_local_ms: Optional[Callable[[int], str]] = None,
) -> tuple[bool, str]:
"""返回 (允许交易, 拒绝原因)。"""
st = compute_account_risk_status(
conn, trading_day=trading_day, now=now, fmt_local_ms=fmt_local_ms
)
if st.get("can_trade"):
return True, ""
return False, str(st.get("reason") or STATUS_LABELS.get(st.get("status"), "账户冻结"))
def should_apply_external_close_risk(result: str) -> bool:
return (result or "").strip() in EXTERNAL_CLOSE_RESULTS
def insert_trade_record_id(conn) -> int:
row = conn.execute("SELECT last_insert_rowid()").fetchone()
return int(row[0] if row else 0)
+11
View File
@@ -127,6 +127,17 @@ DAILY_OPEN_ALERT_THRESHOLD=5
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 # 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
DAILY_OPEN_HARD_LIMIT=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 BALANCE_REFRESH_SECONDS=60
# 前端价格快照轮询(秒) # 前端价格快照轮询(秒)
+62 -1
View File
@@ -1501,6 +1501,9 @@ def init_db():
from strategy_db import init_strategy_tables from strategy_db import init_strategy_tables
init_strategy_tables(conn) 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) backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1534,6 +1537,18 @@ def get_db():
return conn 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(): def app_now():
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
return datetime.now(APP_TZ).replace(tzinfo=None) return datetime.now(APP_TZ).replace(tzinfo=None)
@@ -3107,6 +3122,16 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital):
def precheck_risk(conn, symbol, direction): def precheck_risk(conn, symbol, direction):
now = app_now() 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): if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
@@ -4405,6 +4430,16 @@ def reconcile_external_closes(conn, days=None):
opened_at=opened_at, opened_at=opened_at,
closed_at=closed_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"],)) 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()) clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
@@ -6861,12 +6896,14 @@ def render_main_page(page="trade"):
rate = round(win/total*100,2) if total else 0 rate = round(win/total*100,2) if total else 0
active_count = len(order_list) active_count = len(order_list)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=active_count, active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
key_rule_ctx = key_monitor_rule_template_context( key_rule_ctx = key_monitor_rule_template_context(
kline_timeframe=KLINE_TIMEFRAME, kline_timeframe=KLINE_TIMEFRAME,
@@ -6962,6 +6999,7 @@ def render_main_page(page="trade"):
journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT,
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
exchange_display=EXCHANGE_DISPLAY_NAME, exchange_display=EXCHANGE_DISPLAY_NAME,
risk_status=risk_status,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
@@ -7015,6 +7053,7 @@ def api_account_snapshot():
recommended_capital = get_recommended_capital(current_capital) recommended_capital = get_recommended_capital(current_capital)
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
conn.close() conn.close()
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
@@ -7022,6 +7061,7 @@ def api_account_snapshot():
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
available_trading_usdt = get_available_trading_usdt() available_trading_usdt = get_available_trading_usdt()
return jsonify({ return jsonify({
@@ -7036,7 +7076,8 @@ def api_account_snapshot():
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day "trading_day": trading_day,
"risk_status": risk_status,
}) })
@@ -8601,6 +8642,15 @@ def del_order(id):
opened_at=opened_at, opened_at=opened_at,
closed_at=closed_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(closed_at_ms, 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)) 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) clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit() conn.commit()
@@ -8815,6 +8865,16 @@ def add_journal():
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename 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.commit()
conn.close() conn.close()
if chart_msg: if chart_msg:
@@ -9305,6 +9365,7 @@ try:
views={"add_order": add_order, "add_key": add_key}, views={"add_order": add_order, "add_key": add_key},
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
risk_status_fn=hub_account_risk_status,
) )
except Exception as _hub_err: except Exception as _hub_err:
print(f"[hub_bridge] binance: {_hub_err}") print(f"[hub_bridge] binance: {_hub_err}")
@@ -20,6 +20,12 @@
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} .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} .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} .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{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{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} .top-nav a.active{background:#2a3f6c;color:#dbe4ff}
@@ -262,6 +268,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1> <h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row"> <div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div> <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="界面主题"> <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="暗色主题"> <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"> <svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
@@ -2041,9 +2048,21 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt); 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 = "可开仓"; let canTradeText = "可开仓";
if (!data.can_trade) { if (!data.can_trade) {
const parts = []; 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 ac = Number(data.active_count || 0);
const max = Number(data.max_active_positions || {{ max_active_positions }}); const max = Number(data.max_active_positions || {{ max_active_positions }});
if (ac >= max) parts.push(`持仓 ${ac}/${max}`); if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+11
View File
@@ -129,6 +129,17 @@ DAILY_OPEN_ALERT_THRESHOLD=5
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 # 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
DAILY_OPEN_HARD_LIMIT=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 BALANCE_REFRESH_SECONDS=60
# 前端价格快照轮询(秒) # 前端价格快照轮询(秒)
+62 -1
View File
@@ -1491,6 +1491,9 @@ def init_db():
from strategy_db import init_strategy_tables from strategy_db import init_strategy_tables
init_strategy_tables(conn) 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) backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1524,6 +1527,18 @@ def get_db():
return conn 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(): def app_now():
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
return datetime.now(APP_TZ).replace(tzinfo=None) 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): def precheck_risk(conn, symbol, direction):
now = app_now() 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): if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
@@ -4137,6 +4162,16 @@ def reconcile_external_closes(conn, days=None):
opened_at=opened_at, opened_at=opened_at,
closed_at=closed_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"],)) 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()) clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
@@ -6740,12 +6775,14 @@ def render_main_page(page="trade"):
rate = round(win/total*100,2) if total else 0 rate = round(win/total*100,2) if total else 0
active_count = len(order_list) active_count = len(order_list)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=active_count, active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
key_rule_ctx = key_monitor_rule_template_context( key_rule_ctx = key_monitor_rule_template_context(
kline_timeframe=KLINE_TIMEFRAME, 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_limit=JOURNAL_CHART_DEFAULT_LIMIT,
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
exchange_display=EXCHANGE_DISPLAY_NAME, exchange_display=EXCHANGE_DISPLAY_NAME,
risk_status=risk_status,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_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) recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
conn.close() conn.close()
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
@@ -6914,6 +6953,7 @@ def api_account_snapshot():
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
available_trading_usdt = get_available_trading_usdt() available_trading_usdt = get_available_trading_usdt()
return jsonify({ return jsonify({
@@ -6928,7 +6968,8 @@ def api_account_snapshot():
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "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, opened_at=opened_at,
closed_at=closed_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)) 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) clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit() 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 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.commit()
conn.close() conn.close()
if chart_msg: if chart_msg:
@@ -9250,6 +9310,7 @@ try:
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
reconcile_hub_flat_fn=reconcile_hub_external_close, reconcile_hub_flat_fn=reconcile_hub_external_close,
risk_status_fn=hub_account_risk_status,
) )
except Exception as _hub_err: except Exception as _hub_err:
print(f"[hub_bridge] gate: {_hub_err}") print(f"[hub_bridge] gate: {_hub_err}")
+19
View File
@@ -20,6 +20,12 @@
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} .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} .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} .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{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{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} .top-nav a.active{background:#2a3f6c;color:#dbe4ff}
@@ -262,6 +268,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1> <h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row"> <div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div> <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="界面主题"> <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="暗色主题"> <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"> <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) { if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt); 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 = "可开仓"; let canTradeText = "可开仓";
if (!data.can_trade) { if (!data.can_trade) {
const parts = []; 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 ac = Number(data.active_count || 0);
const max = Number(data.max_active_positions || {{ max_active_positions }}); const max = Number(data.max_active_positions || {{ max_active_positions }});
if (ac >= max) parts.push(`持仓 ${ac}/${max}`); if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+11
View File
@@ -129,6 +129,17 @@ DAILY_OPEN_ALERT_THRESHOLD=5
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 # 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
DAILY_OPEN_HARD_LIMIT=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 BALANCE_REFRESH_SECONDS=60
# 前端价格快照轮询(秒) # 前端价格快照轮询(秒)
+62 -1
View File
@@ -1491,6 +1491,9 @@ def init_db():
from strategy_db import init_strategy_tables from strategy_db import init_strategy_tables
init_strategy_tables(conn) 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) backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1524,6 +1527,18 @@ def get_db():
return conn 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(): def app_now():
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
return datetime.now(APP_TZ).replace(tzinfo=None) 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): def precheck_risk(conn, symbol, direction):
now = app_now() 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): if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
@@ -4137,6 +4162,16 @@ def reconcile_external_closes(conn, days=None):
opened_at=opened_at, opened_at=opened_at,
closed_at=closed_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"],)) 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()) clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day())
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
@@ -6740,12 +6775,14 @@ def render_main_page(page="trade"):
rate = round(win/total*100,2) if total else 0 rate = round(win/total*100,2) if total else 0
active_count = len(order_list) active_count = len(order_list)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
active_count=active_count, active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
key_rule_ctx = key_monitor_rule_template_context( key_rule_ctx = key_monitor_rule_template_context(
kline_timeframe=KLINE_TIMEFRAME, 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_limit=JOURNAL_CHART_DEFAULT_LIMIT,
journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR,
exchange_display=EXCHANGE_DISPLAY_NAME, exchange_display=EXCHANGE_DISPLAY_NAME,
risk_status=risk_status,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_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) recommended_capital = round(float(get_recommended_capital(current_capital)), 2)
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
conn.close() conn.close()
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now), time_allows=trading_day_reset_allows_new_open(now),
@@ -6914,6 +6953,7 @@ def api_account_snapshot():
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
available_trading_usdt = get_available_trading_usdt() available_trading_usdt = get_available_trading_usdt()
return jsonify({ return jsonify({
@@ -6928,7 +6968,8 @@ def api_account_snapshot():
"daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT,
"daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "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, opened_at=opened_at,
closed_at=closed_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)) 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) clear_key_sizing_snapshot_if_flat(conn, session_date)
conn.commit() 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 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.commit()
conn.close() conn.close()
if chart_msg: if chart_msg:
@@ -9250,6 +9310,7 @@ try:
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
reconcile_hub_flat_fn=reconcile_hub_external_close, reconcile_hub_flat_fn=reconcile_hub_external_close,
risk_status_fn=hub_account_risk_status,
) )
except Exception as _hub_err: except Exception as _hub_err:
print(f"[hub_bridge] gate_bot: {_hub_err}") print(f"[hub_bridge] gate_bot: {_hub_err}")
@@ -20,6 +20,12 @@
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} .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} .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} .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{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{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} .top-nav a.active{background:#2a3f6c;color:#dbe4ff}
@@ -262,6 +268,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1> <h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row"> <div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div> <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="界面主题"> <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="暗色主题"> <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"> <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) { if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt); 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 = "可开仓"; let canTradeText = "可开仓";
if (!data.can_trade) { if (!data.can_trade) {
const parts = []; 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 ac = Number(data.active_count || 0);
const max = Number(data.max_active_positions || {{ max_active_positions }}); const max = Number(data.max_active_positions || {{ max_active_positions }});
if (ac >= max) parts.push(`持仓 ${ac}/${max}`); if (ac >= max) parts.push(`持仓 ${ac}/${max}`);
+11
View File
@@ -167,6 +167,17 @@ DAILY_OPEN_ALERT_THRESHOLD=5
# 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 # 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用
DAILY_OPEN_HARD_LIMIT=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
KEY_CONFIRM_BREAKOUT_BAR=-2 KEY_CONFIRM_BREAKOUT_BAR=-2
KEY_CONFIRM_BAR=-1 KEY_CONFIRM_BAR=-1
KEY_VOLUME_MA_BARS=20 KEY_VOLUME_MA_BARS=20
+61
View File
@@ -1480,6 +1480,9 @@ def init_db():
from strategy_db import init_strategy_tables from strategy_db import init_strategy_tables
init_strategy_tables(conn) 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) backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO)
conn.commit() conn.commit()
conn.close() conn.close()
@@ -1513,6 +1516,18 @@ def get_db():
return conn 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(): def app_now():
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
return datetime.now(APP_TZ).replace(tzinfo=None) return datetime.now(APP_TZ).replace(tzinfo=None)
@@ -2509,6 +2524,16 @@ def trading_day_reset_allows_new_open(now, conn=None):
def precheck_risk(conn, symbol, direction): def precheck_risk(conn, symbol, direction):
now = app_now() 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): if not trading_day_reset_allows_new_open(now):
return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓"
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
@@ -3502,6 +3527,16 @@ def reconcile_external_closes(conn, days=None):
opened_at=opened_at, opened_at=opened_at,
closed_at=closed_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"],)) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],))
if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
send_wechat_msg( send_wechat_msg(
@@ -6317,12 +6352,14 @@ def render_main_page(page="trade"):
open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn)
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
time_allows=trading_day_reset_allows_new_open(now, conn), time_allows=trading_day_reset_allows_new_open(now, conn),
active_count=active_count, active_count=active_count,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
key_rule_ctx = key_monitor_rule_template_context( key_rule_ctx = key_monitor_rule_template_context(
kline_timeframe=KLINE_TIMEFRAME, kline_timeframe=KLINE_TIMEFRAME,
@@ -6416,6 +6453,7 @@ def render_main_page(page="trade"):
key_rule_ctx=key_rule_ctx, key_rule_ctx=key_rule_ctx,
funds_fmt=format_funds_u, funds_fmt=format_funds_u,
exchange_display=EXCHANGE_DISPLAY_NAME, exchange_display=EXCHANGE_DISPLAY_NAME,
risk_status=risk_status,
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR,
key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR,
@@ -6484,6 +6522,7 @@ def api_account_snapshot():
active_count = get_active_position_count(conn) active_count = get_active_position_count(conn)
open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn) open_guard_enabled = get_trading_day_reset_open_guard_enabled(conn)
opens_today = count_opens_for_trading_day(conn, trading_day) opens_today = count_opens_for_trading_day(conn, trading_day)
risk_status = hub_account_risk_status(conn)
conn.close() conn.close()
open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR open_guard_blocks_now = open_guard_enabled and now.hour < TRADING_DAY_RESET_HOUR
can_trade = can_trade_new_open( can_trade = can_trade_new_open(
@@ -6492,6 +6531,7 @@ def api_account_snapshot():
max_active_positions=MAX_ACTIVE_POSITIONS, max_active_positions=MAX_ACTIVE_POSITIONS,
opens_today=opens_today, opens_today=opens_today,
hard_limit=DAILY_OPEN_HARD_LIMIT, hard_limit=DAILY_OPEN_HARD_LIMIT,
extra_blocks=not risk_status.get("can_trade", True),
) )
available_trading_usdt = get_available_trading_usdt() available_trading_usdt = get_available_trading_usdt()
return jsonify({ return jsonify({
@@ -6510,6 +6550,7 @@ def api_account_snapshot():
"reset_hour": TRADING_DAY_RESET_HOUR, "reset_hour": TRADING_DAY_RESET_HOUR,
"manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR,
"trading_day": trading_day, "trading_day": trading_day,
"risk_status": risk_status,
}) })
@@ -8085,6 +8126,15 @@ def del_order(id):
opened_at=opened_at, opened_at=opened_at,
closed_at=closed_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)) conn.execute("UPDATE order_monitors SET status='stopped', exchange_close_order_id=? WHERE id=?", (close_order_id, id))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -8297,6 +8347,16 @@ def add_journal():
d.get("post_breakeven_stare"), d.get("new_trade_while_occupied"), d.get("note"), image_filename 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.commit()
conn.close() conn.close()
if chart_msg: if chart_msg:
@@ -8788,6 +8848,7 @@ try:
views={"add_order": add_order, "add_key": add_key}, views={"add_order": add_order, "add_key": add_key},
ohlcv_fn=_hub_fetch_ohlcv, ohlcv_fn=_hub_fetch_ohlcv,
volume_rank_fn=_hub_fetch_volume_rank, volume_rank_fn=_hub_fetch_volume_rank,
risk_status_fn=hub_account_risk_status,
) )
except Exception as _hub_err: except Exception as _hub_err:
print(f"[hub_bridge] okx: {_hub_err}") print(f"[hub_bridge] okx: {_hub_err}")
+19
View File
@@ -20,6 +20,12 @@
.header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} .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} .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} .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{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{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} .top-nav a.active{background:#2a3f6c;color:#dbe4ff}
@@ -262,6 +268,7 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1> <h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row"> <div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div> <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="界面主题"> <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="暗色主题"> <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"> <svg class="theme-icon" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
@@ -1997,9 +2004,21 @@ function refreshAccountSnapshot(){
if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) { if (typeof data.available_trading_usdt !== "undefined" && data.available_trading_usdt !== null) {
latestAvailableUsdt = Number(data.available_trading_usdt); 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 = "可开仓"; let canTradeText = "可开仓";
if(!data.can_trade){ if(!data.can_trade){
const parts = []; const parts = [];
if (data.risk_status && data.risk_status.can_trade === false && data.risk_status.reason) {
parts.push(data.risk_status.reason);
}
if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`); if((data.active_count||0) >= (data.max_active_positions||{{ max_active_positions }})) parts.push(`持仓 ${data.active_count}/${data.max_active_positions}`);
const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }}); const hard = Number(data.daily_open_hard_limit != null ? data.daily_open_hard_limit : {{ daily_open_hard_limit }});
const opens = Number(data.opens_today); const opens = Number(data.opens_today);
+54
View File
@@ -0,0 +1,54 @@
# 账户冷静期 / 日冻结风控
四所实例(币安 / OKX / Gate / Gate 趋势)共用 `account_risk_lib.py`,在手动平仓、外部平仓与交易复盘保存时更新 `account_risk_state` 表,并在开仓前 `precheck_risk` 拦截。
## 状态展示
实例页顶「交易所」标签旁、中控监控卡片账户名后显示:
| 状态 | 含义 |
|------|------|
| 正常 | 可新开仓 |
| 1h冻结 | 冷静期中(通常为复盘后缩短的 1 小时) |
| 4h冻结 | 冷静期中(默认 4 小时) |
| 日冻结 | 当日禁止一切新开仓 |
## 触发规则
| 事件 | 行为 |
|------|------|
| 页面手动平仓 | 默认 **4h** 冷静期;累计手动平仓次数 +1 |
| 当日第 2 次手动平仓 | **日冻结**(默认上限 2 次,可配置) |
| 复盘:离场触发=手动平仓 且补充说明非空 | 将当前冷静期降为 **1h**(自上次平仓时刻起算) |
| 复盘:情绪标签任一项勾选 | **日冻结** |
| 外部平仓(`result=外部平仓` | **4h** 冷静期(正常止盈/止损不触发) |
情绪标签(`mood_issues`):怕踏空、报复开仓、盈利飘了、拿不住单、扛单、重仓违规。
## 环境变量
在各实例目录 `.env` 中配置(模板见各所 `.env.example`):
```env
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
```
- `RISK_CONTROL_ENABLED=false` 时关闭整套逻辑,状态始终为「正常」。
- 交易日切换(`TRADING_DAY_RESET_HOUR`)会清零当日手动平仓计数与日冻结标记;未过期的冷静期按 `cooloff_until_ms` 自然到期。
## API
- 实例:`GET /api/account_snapshot` 返回 `risk_status``GET /api/account_risk_status`hub_bridge)供中控拉取。
- 中控:`hub_monitor` 载荷含 `risk_status`,卡片标题旁展示 `status_label`
## 相关代码
- `account_risk_lib.py` — 核心状态机
- 各所 `app.py``on_manual_close` / `on_external_close` / `on_journal_saved` 钩子
- `hub_bridge.py` — 中控聚合 `risk_status`
- `tests/test_account_risk_lib.py` — 单元测试
+31
View File
@@ -106,6 +106,7 @@ def build_hub_monitor_payload(
trends, trends,
rolls, rolls,
enrich=None, enrich=None,
risk_status=None,
) -> dict: ) -> dict:
"""合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders。""" """合并 enrich 增量字段;enrich 只返回 trends 等局部时不得丢掉 keys/orders。"""
payload = { payload = {
@@ -116,6 +117,8 @@ def build_hub_monitor_payload(
"rolls": rolls, "rolls": rolls,
"key_prices": [], "key_prices": [],
} }
if isinstance(risk_status, dict):
payload["risk_status"] = risk_status
if callable(enrich): if callable(enrich):
extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls) extra = enrich(keys=keys, orders=orders, trends=trends, rolls=rolls)
if isinstance(extra, dict): if isinstance(extra, dict):
@@ -209,6 +212,7 @@ def install_on_app(
account_fn=None, account_fn=None,
volume_rank_fn=None, volume_rank_fn=None,
reconcile_hub_flat_fn=None, reconcile_hub_flat_fn=None,
risk_status_fn=None,
): ):
app.config["HUB_CTX"] = { app.config["HUB_CTX"] = {
"exchange": exchange, "exchange": exchange,
@@ -222,6 +226,7 @@ def install_on_app(
"ohlcv_fn": ohlcv_fn, "ohlcv_fn": ohlcv_fn,
"volume_rank_fn": volume_rank_fn, "volume_rank_fn": volume_rank_fn,
"reconcile_hub_flat_fn": reconcile_hub_flat_fn, "reconcile_hub_flat_fn": reconcile_hub_flat_fn,
"risk_status_fn": risk_status_fn,
} }
install_hub_embed_headers(app) install_hub_embed_headers(app)
configure_hub_embed_session(app) configure_hub_embed_session(app)
@@ -360,6 +365,23 @@ def register_hub_routes(app):
except Exception as e: except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500 return jsonify({"ok": False, "msg": str(e)}), 500
@app.route("/api/account_risk_status")
@_hub_auth_required
def api_account_risk_status():
c = _ctx()
get_db = c.get("get_db")
risk_fn = c.get("risk_status_fn")
if not callable(get_db) or not callable(risk_fn):
return jsonify({"ok": False, "msg": "未配置风控"}), 501
conn = get_db()
try:
payload = risk_fn(conn)
return jsonify({"ok": True, **(payload if isinstance(payload, dict) else {})})
except Exception as e:
return jsonify({"ok": False, "msg": str(e)}), 500
finally:
conn.close()
@app.route("/api/hub/monitor") @app.route("/api/hub/monitor")
@_hub_auth_required @_hub_auth_required
def api_hub_monitor(): def api_hub_monitor():
@@ -399,6 +421,13 @@ def register_hub_routes(app):
rolls.append(_row_to_dict(row)) rolls.append(_row_to_dict(row))
except Exception: except Exception:
pass pass
risk_status = None
risk_fn = c.get("risk_status_fn")
if callable(risk_fn):
try:
risk_status = risk_fn(conn)
except Exception:
risk_status = None
conn.close() conn.close()
enrich = c.get("enrich_monitor") enrich = c.get("enrich_monitor")
if callable(enrich): if callable(enrich):
@@ -410,6 +439,7 @@ def register_hub_routes(app):
trends=trends, trends=trends,
rolls=rolls, rolls=rolls,
enrich=enrich, enrich=enrich,
risk_status=risk_status,
) )
) )
except Exception as e: except Exception as e:
@@ -420,6 +450,7 @@ def register_hub_routes(app):
orders=orders, orders=orders,
trends=trends, trends=trends,
rolls=rolls, rolls=rolls,
risk_status=risk_status,
) )
) )
+30
View File
@@ -593,6 +593,36 @@ button:disabled {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-wrap: wrap;
}
.risk-status-badge {
font-size: 0.72rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid transparent;
white-space: nowrap;
}
.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;
} }
.status-dot { .status-dot {
+10 -2
View File
@@ -508,6 +508,14 @@
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function formatRiskStatusBadge(riskStatus) {
if (!riskStatus || typeof riskStatus !== "object") return "";
const st = riskStatus.status || "normal";
const label = esc(riskStatus.status_label || "正常");
const title = esc(riskStatus.reason || "");
return `<span class="risk-status-badge risk-status-${esc(st)}" title="${title}">${label}</span>`;
}
function fmt(n, d) { function fmt(n, d) {
if (n === null || n === undefined || Number.isNaN(Number(n))) return "—"; if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d }); return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
@@ -3240,7 +3248,7 @@
<div class="hub-tile-body card-expand-zone" title="点击进入全屏详情"> <div class="hub-tile-body card-expand-zone" title="点击进入全屏详情">
<div class="hub-tile-top"> <div class="hub-tile-top">
<span class="status-dot ${dotCls}" aria-hidden="true"></span> <span class="status-dot ${dotCls}" aria-hidden="true"></span>
<span class="hub-tile-name">${esc(row.name)}</span> <span class="hub-tile-name">${esc(row.name)}${formatRiskStatusBadge(hm.risk_status)}</span>
</div> </div>
${ ${
showAccountPnlPref() showAccountPnlPref()
@@ -3293,7 +3301,7 @@
<div> <div>
<div class="card-title-row"> <div class="card-title-row">
<span class="status-dot ${dotCls}" title="${online ? "在线" : "离线"}"></span> <span class="status-dot ${dotCls}" title="${online ? "在线" : "离线"}"></span>
<div class="card-title">${esc(row.name)}</div> <div class="card-title">${esc(row.name)}${formatRiskStatusBadge(hm.risk_status)}</div>
</div> </div>
<div class="card-sub">${esc(flaskOpen || "")}</div> <div class="card-sub">${esc(flaskOpen || "")}</div>
</div> </div>
+157
View File
@@ -0,0 +1,157 @@
import os
import sqlite3
import unittest
from datetime import datetime, timezone
from unittest import mock
from account_risk_lib import (
STATUS_DAILY,
STATUS_FREEZE_1H,
STATUS_FREEZE_4H,
STATUS_NORMAL,
account_risk_blocks_trading,
compute_account_risk_status,
ensure_account_risk_schema,
on_external_close,
on_journal_saved,
on_manual_close,
parse_mood_issues,
should_apply_external_close_risk,
)
def _mem_conn():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
ensure_account_risk_schema(conn)
return conn
class AccountRiskLibTests(unittest.TestCase):
def setUp(self):
self.env_patch = mock.patch.dict(os.environ, {}, clear=False)
self.env_patch.start()
os.environ["RISK_CONTROL_ENABLED"] = "1"
os.environ["RISK_COOLING_HOURS_MANUAL"] = "4"
os.environ["RISK_COOLING_HOURS_EXTERNAL"] = "4"
os.environ["RISK_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
def tearDown(self):
self.env_patch.stop()
def test_should_apply_external_close_risk_only_external(self):
self.assertTrue(should_apply_external_close_risk("外部平仓"))
self.assertFalse(should_apply_external_close_risk("止盈"))
self.assertFalse(should_apply_external_close_risk("手动平仓"))
def test_manual_close_sets_4h_cooloff(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
on_manual_close(
conn,
trade_record_id=101,
closed_at_ms=close_ms,
trading_day="2026-06-14",
now=now,
)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["status"], STATUS_FREEZE_4H)
self.assertFalse(st["can_trade"])
self.assertEqual(st["manual_close_count"], 1)
ok, reason = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now)
self.assertFalse(ok)
self.assertIn("冻结", reason)
def test_second_manual_close_daily_freeze(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
on_manual_close(conn, trade_record_id=1, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
on_manual_close(conn, trade_record_id=2, closed_at_ms=close_ms + 1000, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["status"], STATUS_DAILY)
self.assertTrue(st["daily_frozen"])
def test_journal_manual_with_note_reduces_to_1h(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
on_manual_close(conn, trade_record_id=9, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
on_journal_saved(
conn,
early_exit_trigger="手动平仓",
early_exit_note="违反计划提前离场",
mood_issues_raw="",
trading_day="2026-06-14",
now=now,
)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["status"], STATUS_FREEZE_1H)
def test_journal_mood_issues_daily_freeze(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_journal_saved(
conn,
early_exit_trigger="止损",
early_exit_note="",
mood_issues_raw=["报复开仓"],
trading_day="2026-06-14",
now=now,
)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["status"], STATUS_DAILY)
def test_external_close_4h_cooloff(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
on_external_close(conn, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertEqual(st["status"], STATUS_FREEZE_4H)
def test_cooloff_expired_returns_normal(self):
conn = _mem_conn()
start = datetime(2026, 6, 14, 8, 0, 0)
close_ms = int(start.replace(tzinfo=timezone.utc).timestamp() * 1000)
on_external_close(conn, closed_at_ms=close_ms, trading_day="2026-06-14", now=start)
later = datetime(2026, 6, 14, 13, 0, 0)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertTrue(st["can_trade"])
def test_trading_day_reset_clears_daily_frozen(self):
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
on_journal_saved(
conn,
early_exit_trigger="止损",
early_exit_note="",
mood_issues_raw="扛单",
trading_day="2026-06-14",
now=now,
)
next_day = datetime(2026, 6, 15, 8, 0, 0)
st = compute_account_risk_status(conn, trading_day="2026-06-15", now=next_day)
self.assertEqual(st["status"], STATUS_NORMAL)
self.assertFalse(st["daily_frozen"])
def test_parse_mood_issues_filters_unknown(self):
self.assertEqual(parse_mood_issues("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"])
def test_disabled_risk_control(self):
os.environ["RISK_CONTROL_ENABLED"] = "0"
conn = _mem_conn()
now = datetime(2026, 6, 14, 12, 0, 0)
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
on_manual_close(conn, trade_record_id=1, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
self.assertFalse(st["enabled"])
self.assertTrue(st["can_trade"])
if __name__ == "__main__":
unittest.main()