diff --git a/account_risk_lib.py b/account_risk_lib.py index ef6eb3a..36a7ea0 100644 --- a/account_risk_lib.py +++ b/account_risk_lib.py @@ -26,7 +26,18 @@ MOOD_ISSUE_OPTIONS = ( "重仓违规", ) -EXTERNAL_CLOSE_RESULTS = frozenset({"外部平仓"}) +# 仅以下来源计入「手动平仓」风控(用户主动点平仓/结束计划) +CLOSE_SOURCE_USER_INSTANCE = "user_instance" +CLOSE_SOURCE_USER_HUB = "user_hub" +CLOSE_SOURCE_USER_TREND_STOP = "user_trend_stop" + +USER_INITIATED_CLOSE_SOURCES = frozenset( + { + CLOSE_SOURCE_USER_INSTANCE, + CLOSE_SOURCE_USER_HUB, + CLOSE_SOURCE_USER_TREND_STOP, + } +) def _env_bool(key: str, default: bool = True) -> bool: @@ -52,10 +63,6 @@ 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) @@ -185,26 +192,26 @@ def parse_mood_issues(raw: Any) -> list[str]: return [p for p in parts if p in MOOD_ISSUE_OPTIONS] -def on_manual_close( +def _record_one_user_initiated_close( conn, *, - trade_record_id: int, + source: str, + trade_record_id: Optional[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) + pending = int(trade_record_id) if trade_record_id else None 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")), + (count, pending, (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) @@ -218,26 +225,54 @@ def on_manual_close( ) -def on_external_close( +def on_user_initiated_close( conn, *, + source: str, + trade_record_id: Optional[int] = None, + closed_at_ms: Optional[int] = None, + trading_day: str, + now: Optional[datetime] = None, + count: int = 1, +) -> None: + """用户主动平仓/结束趋势计划:计入手动平仓次数与冷静期。""" + if not risk_control_enabled(): + return + src = (source or "").strip() + if src not in USER_INITIATED_CLOSE_SOURCES: + return + n = max(1, int(count or 1)) + for i in range(n): + _record_one_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id if i == 0 else None, + closed_at_ms=closed_at_ms, + trading_day=trading_day, + now=now, + ) + row = _load_state(conn) + if int(_row_get(row, "daily_frozen") or 0) == 1: + break + + +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 - close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now) - _set_cooloff( + """兼容旧调用:等同实例页用户平仓。""" + on_user_initiated_close( conn, + source=CLOSE_SOURCE_USER_INSTANCE, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, 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"),), + count=1, ) @@ -357,10 +392,6 @@ def account_risk_blocks_trading( 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) diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index a3cc1b6..137c87b 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -133,7 +133,6 @@ DAILY_OPEN_HARD_LIMIT=0 # ============================================================================= # 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 diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 909c2fb..90da23b 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -1549,6 +1549,28 @@ def hub_account_risk_status(conn): ) +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + def app_now(): """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -4430,16 +4452,6 @@ 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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): @@ -8642,10 +8654,11 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) - from account_risk_lib import insert_trade_record_id, on_manual_close + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - on_manual_close( + on_user_initiated_close( conn, + source=CLOSE_SOURCE_USER_INSTANCE, trade_record_id=insert_trade_record_id(conn), closed_at_ms=_to_ms_with_fallback(closed_at_ms, closed_at), trading_day=session_date, @@ -8708,6 +8721,16 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + 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' WHERE id=?", (id,)) conn.commit() conn.close() @@ -9366,6 +9389,7 @@ try: ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, ) except Exception as _hub_err: print(f"[hub_bridge] binance: {_hub_err}") diff --git a/crypto_monitor_gate/.env.example b/crypto_monitor_gate/.env.example index e6a71e7..bae49c5 100644 --- a/crypto_monitor_gate/.env.example +++ b/crypto_monitor_gate/.env.example @@ -135,7 +135,6 @@ DAILY_OPEN_HARD_LIMIT=0 # ============================================================================= # 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 diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index a5bf5b3..25e2196 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -1539,6 +1539,28 @@ def hub_account_risk_status(conn): ) +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + def app_now(): """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -4162,16 +4184,6 @@ 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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): @@ -8563,10 +8575,11 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) - from account_risk_lib import insert_trade_record_id, on_manual_close + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - on_manual_close( + on_user_initiated_close( conn, + source=CLOSE_SOURCE_USER_INSTANCE, trade_record_id=insert_trade_record_id(conn), closed_at_ms=_to_ms_with_fallback(None, closed_at), trading_day=session_date, @@ -8630,6 +8643,16 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + 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' WHERE id=?", (id,)) conn.commit() conn.close() @@ -9311,6 +9334,7 @@ try: volume_rank_fn=_hub_fetch_volume_rank, reconcile_hub_flat_fn=reconcile_hub_external_close, risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, ) except Exception as _hub_err: print(f"[hub_bridge] gate: {_hub_err}") diff --git a/crypto_monitor_gate_bot/.env.example b/crypto_monitor_gate_bot/.env.example index 180b55b..df7897d 100644 --- a/crypto_monitor_gate_bot/.env.example +++ b/crypto_monitor_gate_bot/.env.example @@ -135,7 +135,6 @@ DAILY_OPEN_HARD_LIMIT=0 # ============================================================================= # 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 diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index e65b65b..b21ecb2 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -1539,6 +1539,28 @@ def hub_account_risk_status(conn): ) +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + def app_now(): """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -4162,16 +4184,6 @@ 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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): @@ -8563,10 +8575,11 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) - from account_risk_lib import insert_trade_record_id, on_manual_close + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - on_manual_close( + on_user_initiated_close( conn, + source=CLOSE_SOURCE_USER_INSTANCE, trade_record_id=insert_trade_record_id(conn), closed_at_ms=_to_ms_with_fallback(None, closed_at), trading_day=session_date, @@ -8630,6 +8643,16 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + 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' WHERE id=?", (id,)) conn.commit() conn.close() @@ -9311,6 +9334,7 @@ try: volume_rank_fn=_hub_fetch_volume_rank, reconcile_hub_flat_fn=reconcile_hub_external_close, risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, ) except Exception as _hub_err: print(f"[hub_bridge] gate_bot: {_hub_err}") diff --git a/crypto_monitor_okx/.env.example b/crypto_monitor_okx/.env.example index eaface4..cd1ec76 100644 --- a/crypto_monitor_okx/.env.example +++ b/crypto_monitor_okx/.env.example @@ -173,7 +173,6 @@ DAILY_OPEN_HARD_LIMIT=0 # ============================================================================= # 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 diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index a0249f7..6082d2e 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -1528,6 +1528,28 @@ def hub_account_risk_status(conn): ) +def hub_user_initiated_close( + conn, + *, + source, + count=1, + trade_record_id=None, + closed_at_ms=None, +): + from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close + + src = (source or "").strip() or CLOSE_SOURCE_USER_HUB + on_user_initiated_close( + conn, + source=src, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=get_trading_day(), + now=app_now(), + count=count, + ) + + def app_now(): """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -3527,16 +3549,6 @@ 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"],)) if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): send_wechat_msg( @@ -8126,10 +8138,11 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) - from account_risk_lib import insert_trade_record_id, on_manual_close + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close - on_manual_close( + on_user_initiated_close( conn, + source=CLOSE_SOURCE_USER_INSTANCE, trade_record_id=insert_trade_record_id(conn), closed_at_ms=_to_ms_with_fallback(None, closed_at), trading_day=session_date, @@ -8190,6 +8203,16 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) + from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_INSTANCE, + 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' WHERE id=?", (id,)) conn.commit() conn.close() @@ -8849,6 +8872,7 @@ try: ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, risk_status_fn=hub_account_risk_status, + user_close_fn=hub_user_initiated_close, ) except Exception as _hub_err: print(f"[hub_bridge] okx: {_hub_err}") diff --git a/docs/account-risk-cooldown.md b/docs/account-risk-cooldown.md index 9e07b58..960beaa 100644 --- a/docs/account-risk-cooldown.md +++ b/docs/account-risk-cooldown.md @@ -1,10 +1,11 @@ # 账户冷静期 / 日冻结风控 -四所实例(币安 / OKX / Gate / Gate 趋势)共用 `account_risk_lib.py`,在手动平仓、外部平仓与交易复盘保存时更新 `account_risk_state` 表,并在开仓前 `precheck_risk` 拦截。 +四所实例(币安 / OKX / Gate / Gate 趋势)共用 `account_risk_lib.py`。 +**仅用户主动平仓**计入风控;交易所止盈/止损、空仓同步、改保本/改委托等**不触发**冷静期。 ## 状态展示 -实例页顶「交易所」标签旁、中控监控卡片账户名后显示: +实例页顶、中控监控卡片账户名旁: | 状态 | 含义 | |------|------| @@ -13,42 +14,59 @@ | 4h冻结 | 冷静期中(默认 4 小时) | | 日冻结 | 当日禁止一切新开仓 | +## 什么算「手动平仓」(计入风控) + +以下操作通过 `close_source` 登记为 **用户主动平仓**: + +| 来源标识 | 操作 | +|----------|------| +| `user_instance` | 实例页删单/手动平仓(`del_order`) | +| `user_hub` | 中控「平仓」「全平」「紧急全平」 | +| `user_trend_stop` | 趋势计划 **「结束计划」**(手动结束) | + +**不算**手动平仓(不触发风控): + +- 趋势 **「保本移交下单监控」** +- 中控/实例修改委托、挂止盈止损、移动保本 +- 交易所止盈/止损/条件单成交 +- 后台 `reconcile_external_closes` 空仓同步(即使记账为「外部平仓」) +- 监控轮询自动止盈/止损/保本 + ## 触发规则 | 事件 | 行为 | |------|------| -| 页面手动平仓 | 默认 **4h** 冷静期;累计手动平仓次数 +1 | -| 当日第 2 次手动平仓 | **日冻结**(默认上限 2 次,可配置) | -| 复盘:离场触发=手动平仓 且补充说明非空 | 将当前冷静期降为 **1h**(自上次平仓时刻起算) | -| 复盘:情绪标签任一项勾选 | **日冻结** | -| 外部平仓(`result=外部平仓`) | **4h** 冷静期(正常止盈/止损不触发) | +| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 | +| 第 2 次用户主动平仓(同一交易日) | **日冻结** | +| 复盘勾选任意情绪标签 | **日冻结** | +| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须存在 pending 关联交易记录) | -情绪标签(`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` 自然到期。 +`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控)。 ## API -- 实例:`GET /api/account_snapshot` 返回 `risk_status`;`GET /api/account_risk_status`(hub_bridge)供中控拉取。 -- 中控:`hub_monitor` 载荷含 `risk_status`,卡片标题旁展示 `status_label`。 +| 接口 | 说明 | +|------|------| +| `GET /api/account_snapshot` | 返回 `risk_status` | +| `GET /api/account_risk_status` | hub_bridge,供中控拉取 | +| `POST /api/hub/account-risk/user-close` | 中控登记用户平仓,`body: { source, count }` | ## 相关代码 -- `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` — 单元测试 +- `account_risk_lib.py` — 状态机与 `on_user_initiated_close` +- `hub_bridge.py` — `/api/hub/account-risk/user-close` +- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close +- `strategy_trend_register.py` — `stop_trend_pullback` 结束计划时登记风控 +- `tests/test_account_risk_lib.py` diff --git a/hub_bridge.py b/hub_bridge.py index 4f01192..9085402 100644 --- a/hub_bridge.py +++ b/hub_bridge.py @@ -214,6 +214,7 @@ def install_on_app( volume_rank_fn=None, reconcile_hub_flat_fn=None, risk_status_fn=None, + user_close_fn=None, ): app.config["HUB_CTX"] = { "exchange": exchange, @@ -228,6 +229,7 @@ def install_on_app( "volume_rank_fn": volume_rank_fn, "reconcile_hub_flat_fn": reconcile_hub_flat_fn, "risk_status_fn": risk_status_fn, + "user_close_fn": user_close_fn, } install_hub_embed_headers(app) configure_hub_embed_session(app) @@ -383,6 +385,41 @@ def register_hub_routes(app): finally: conn.close() + @app.route("/api/hub/account-risk/user-close", methods=["POST"]) + @_hub_auth_required + def api_hub_account_risk_user_close(): + """中控/实例:登记用户主动平仓(计入冷静期与日冻结)。""" + c = _ctx() + get_db = c.get("get_db") + user_close_fn = c.get("user_close_fn") + if not callable(get_db) or not callable(user_close_fn): + return jsonify({"ok": False, "msg": "未配置 user_close_fn"}), 501 + body = request.get_json(silent=True) or {} + source = (body.get("source") or request.form.get("source") or "").strip() + try: + count = max(0, int(body.get("count") if body.get("count") is not None else 1)) + except (TypeError, ValueError): + count = 1 + trade_record_id = body.get("trade_record_id") + closed_at_ms = body.get("closed_at_ms") + if count <= 0: + return jsonify({"ok": True, "skipped": True, "count": 0}) + conn = get_db() + try: + user_close_fn( + conn, + source=source, + count=count, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + ) + conn.commit() + return jsonify({"ok": True, "count": count, "source": source}) + except Exception as e: + return jsonify({"ok": False, "msg": str(e)}), 500 + finally: + conn.close() + @app.route("/api/hub/monitor") @_hub_auth_required def api_hub_monitor(): diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index ec53975..ac4e293 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -1177,6 +1177,21 @@ async def _fetch_flask_json( return {"ok": False, "error": str(e)} +async def _notify_instance_user_close( + client: httpx.AsyncClient, ex: dict, *, count: int = 1 +) -> dict | None: + """登记实例侧用户主动平仓风控(中控点平仓/全平)。""" + if count <= 0 or not (ex.get("flask_url") or "").strip(): + return None + return await _fetch_flask_json( + client, + ex, + "/api/hub/account-risk/user-close", + method="POST", + json_body={"source": "user_hub", "count": int(count)}, + ) + + def _flask_error_from_hub_mon(hub_mon: dict | None) -> str | None: if not isinstance(hub_mon, dict) or hub_mon.get("ok") is not False: return None @@ -1936,6 +1951,9 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody): ) if isinstance(sync_parsed, dict): out["trend_sync"] = sync_parsed + risk_sync = await _notify_instance_user_close(flask_client, ex, count=1) + if isinstance(risk_sync, dict): + out["risk_sync"] = risk_sync _schedule_board_refresh() return out @@ -1985,7 +2003,15 @@ async def api_close_exchange(exchange_id: str): body = r.json() except Exception: body = {"raw": (r.text or "")[:2000]} - out = {"exchange": ex, "status_code": r.status_code, "payload": body} + ok = bool(isinstance(body, dict) and body.get("ok")) + out = {"exchange": ex, "status_code": r.status_code, "payload": body, "ok": ok} + if ok and isinstance(body, dict): + closed = body.get("closed") or [] + n = len(closed) if isinstance(closed, list) else 0 + if n > 0: + risk_sync = await _notify_instance_user_close(client, ex, count=n) + if isinstance(risk_sync, dict): + out["risk_sync"] = risk_sync _schedule_board_refresh() return out @@ -2005,7 +2031,15 @@ async def api_close_all(body: CloseAllBody | None = Body(default=None)): payload = r.json() except Exception: payload = {"raw": (r.text or "")[:2000]} - return {"id": ex["id"], "name": ex["name"], "status_code": r.status_code, "payload": payload} + row = {"id": ex["id"], "name": ex["name"], "status_code": r.status_code, "payload": payload} + if isinstance(payload, dict) and payload.get("ok"): + closed = payload.get("closed") or [] + n = len(closed) if isinstance(closed, list) else 0 + if n > 0: + risk_sync = await _notify_instance_user_close(client, ex, count=n) + if isinstance(risk_sync, dict): + row["risk_sync"] = risk_sync + return row except Exception as e: return {"id": ex["id"], "name": ex["name"], "status_code": None, "error": str(e)} diff --git a/strategy_trend_register.py b/strategy_trend_register.py index be4654a..d81fb59 100644 --- a/strategy_trend_register.py +++ b/strategy_trend_register.py @@ -838,7 +838,34 @@ def _bump_session_capital_no_commit( return None -def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) -> None: +def _apply_trend_user_risk_close(cfg: dict, conn, *, trade_record_id=None, closed_at_ms=None) -> None: + m = _m(cfg) + fn = getattr(m, "hub_user_initiated_close", None) + from account_risk_lib import CLOSE_SOURCE_USER_TREND_STOP + + if callable(fn): + fn( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + count=1, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + ) + return + from account_risk_lib import on_user_initiated_close + + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + trade_record_id=trade_record_id, + closed_at_ms=closed_at_ms, + trading_day=m.get_trading_day(), + now=m.app_now(), + count=1, + ) + + +def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float, *, user_initiated_risk: bool = False) -> None: m = _m(cfg) plan_id = int(row["id"]) active = conn.execute( @@ -897,6 +924,7 @@ def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) - except Exception: pass session_capital = None + trade_record_id = None if not _trend_plan_trade_exists(conn, plan_id): session_date = row["session_date"] or m.get_trading_day() session_capital = _bump_session_capital_no_commit( @@ -928,6 +956,26 @@ def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) - entry_reason=ENTRY_REASON_TREND_PULLBACK, ), ) + try: + from account_risk_lib import insert_trade_record_id + + trade_record_id = insert_trade_record_id(conn) + except Exception: + trade_record_id = None + if user_initiated_risk: + closed_ms = None + to_ms = getattr(m, "_to_ms_with_fallback", None) + if callable(to_ms): + try: + closed_ms = to_ms(None, closed_at) + except Exception: + closed_ms = None + _apply_trend_user_risk_close( + cfg, + conn, + trade_record_id=trade_record_id, + closed_at_ms=closed_ms, + ) conn.commit() try: from strategy_wechat_notify import notify_trend_plan_ended @@ -1845,7 +1893,7 @@ def register_trend_routes(app: Flask, cfg: dict) -> None: except Exception: pass try: - _finalize_plan(cfg, conn, row, "手动平仓", exit_p) + _finalize_plan(cfg, conn, row, "手动平仓", exit_p, user_initiated_risk=True) except Exception as e: conn.execute( "UPDATE trend_pullback_plans SET status='stopped_manual', message=? " diff --git a/tests/test_account_risk_lib.py b/tests/test_account_risk_lib.py index b433563..c5d69f2 100644 --- a/tests/test_account_risk_lib.py +++ b/tests/test_account_risk_lib.py @@ -5,6 +5,9 @@ from datetime import datetime, timezone from unittest import mock from account_risk_lib import ( + CLOSE_SOURCE_USER_HUB, + CLOSE_SOURCE_USER_INSTANCE, + CLOSE_SOURCE_USER_TREND_STOP, STATUS_DAILY, STATUS_FREEZE_1H, STATUS_FREEZE_4H, @@ -12,11 +15,10 @@ from account_risk_lib import ( account_risk_blocks_trading, compute_account_risk_status, ensure_account_risk_schema, - on_external_close, on_journal_saved, on_manual_close, + on_user_initiated_close, parse_mood_issues, - should_apply_external_close_risk, ) @@ -33,7 +35,6 @@ class AccountRiskLibTests(unittest.TestCase): 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" @@ -41,17 +42,13 @@ class AccountRiskLibTests(unittest.TestCase): 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): + def test_user_instance_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( + on_user_initiated_close( conn, + source=CLOSE_SOURCE_USER_INSTANCE, trade_record_id=101, closed_at_ms=close_ms, trading_day="2026-06-14", @@ -60,20 +57,60 @@ class AccountRiskLibTests(unittest.TestCase): 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): + def test_invalid_source_ignored(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + on_user_initiated_close( + conn, + source="exchange_tpsl", + 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_NORMAL) + + def test_second_user_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) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms, trading_day="2026-06-14", now=now + ) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_HUB, 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_hub_close_all_count(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_HUB, + closed_at_ms=close_ms, + trading_day="2026-06-14", + now=now, + count=2, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["manual_close_count"], 2) + self.assertEqual(st["status"], STATUS_DAILY) + + def test_trend_stop_counts_as_manual(self): + conn = _mem_conn() + now = datetime(2026, 6, 14, 12, 0, 0) + on_user_initiated_close( + conn, + source=CLOSE_SOURCE_USER_TREND_STOP, + trading_day="2026-06-14", + now=now, + ) + st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now) + self.assertEqual(st["manual_close_count"], 1) + self.assertEqual(st["status"], STATUS_FREEZE_4H) def test_journal_manual_with_note_reduces_to_1h(self): conn = _mem_conn() @@ -105,23 +142,16 @@ class AccountRiskLibTests(unittest.TestCase): 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) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_INSTANCE, 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() @@ -137,7 +167,6 @@ class AccountRiskLibTests(unittest.TestCase): 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("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"]) @@ -146,11 +175,14 @@ class AccountRiskLibTests(unittest.TestCase): 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) + on_user_initiated_close( + conn, source=CLOSE_SOURCE_USER_INSTANCE, 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"]) + ok, _ = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now) + self.assertTrue(ok) if __name__ == "__main__":