diff --git a/account_risk_lib.py b/account_risk_lib.py new file mode 100644 index 0000000..ef6eb3a --- /dev/null +++ b/account_risk_lib.py @@ -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) diff --git a/crypto_monitor_binance/.env.example b/crypto_monitor_binance/.env.example index 101cc51..a3cc1b6 100644 --- a/crypto_monitor_binance/.env.example +++ b/crypto_monitor_binance/.env.example @@ -127,6 +127,17 @@ DAILY_OPEN_ALERT_THRESHOLD=5 # 【单日开仓硬上限】本交易日开仓次数>=该值后禁止一切新开仓直至下一交易日(北京时间 TRADING_DAY_RESET_HOUR 切日);0=不启用 DAILY_OPEN_HARD_LIMIT=0 +# ============================================================================= +# 账户冷静期 / 日冻结风控(手动平仓、外部平仓、复盘情绪标签) +# 详见 docs/account-risk-cooldown.md +# ============================================================================= +# RISK_CONTROL_ENABLED=true +# RISK_COOLING_HOURS_MANUAL=4 +# RISK_COOLING_HOURS_EXTERNAL=4 +# RISK_COOLING_HOURS_MANUAL_JOURNAL=1 +# RISK_MANUAL_CLOSE_DAILY_LIMIT=2 +# RISK_MOOD_ISSUES_DAILY_FREEZE=true + # 资金与仓位刷新周期(秒) BALANCE_REFRESH_SECONDS=60 # 前端价格快照轮询(秒) diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index ce07237..909c2fb 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -1501,6 +1501,9 @@ def init_db(): from strategy_db import init_strategy_tables init_strategy_tables(conn) + from account_risk_lib import ensure_account_risk_schema + + ensure_account_risk_schema(conn) backfill_missing_key_signal_types(conn, monitor_type=ORDER_MONITOR_TYPE_KEY_AUTO) conn.commit() conn.close() @@ -1534,6 +1537,18 @@ def get_db(): return conn +def hub_account_risk_status(conn): + from account_risk_lib import compute_account_risk_status, ensure_account_risk_schema + + ensure_account_risk_schema(conn) + return compute_account_risk_status( + conn, + trading_day=get_trading_day(), + now=app_now(), + fmt_local_ms=ms_to_app_local_str, + ) + + def app_now(): """应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。""" return datetime.now(APP_TZ).replace(tzinfo=None) @@ -3107,6 +3122,16 @@ def resolve_capital_base_for_key_open(conn, trading_day, live_capital): def precheck_risk(conn, symbol, direction): now = app_now() + from account_risk_lib import account_risk_blocks_trading + + ok_risk, risk_reason = account_risk_blocks_trading( + conn, + trading_day=get_trading_day(now), + now=now, + fmt_local_ms=ms_to_app_local_str, + ) + if not ok_risk: + return False, risk_reason if not trading_day_reset_allows_new_open(now): return False, f"北京时间 {TRADING_DAY_RESET_HOUR}:00 前不允许持仓" active_count = get_active_position_count(conn) @@ -4405,6 +4430,16 @@ def reconcile_external_closes(conn, days=None): opened_at=opened_at, closed_at=closed_at, ) + from account_risk_lib import on_external_close, should_apply_external_close_risk + + if should_apply_external_close_risk(result): + close_ms = _to_ms_with_fallback(None, closed_at) + on_external_close( + conn, + closed_at_ms=close_ms, + trading_day=session_date, + now=app_now(), + ) conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (r["id"],)) clear_key_sizing_snapshot_if_flat(conn, r["session_date"] or get_trading_day()) if result in ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"): @@ -6861,12 +6896,14 @@ def render_main_page(page="trade"): rate = round(win/total*100,2) if total else 0 active_count = len(order_list) opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), active_count=active_count, max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), ) key_rule_ctx = key_monitor_rule_template_context( kline_timeframe=KLINE_TIMEFRAME, @@ -6962,6 +6999,7 @@ def render_main_page(page="trade"): journal_chart_default_limit=JOURNAL_CHART_DEFAULT_LIMIT, journal_chart_default_anchor=JOURNAL_CHART_DEFAULT_ANCHOR, exchange_display=EXCHANGE_DISPLAY_NAME, + risk_status=risk_status, max_active_positions=MAX_ACTIVE_POSITIONS, manual_min_planned_rr=MANUAL_MIN_PLANNED_RR, key_auto_min_planned_rr=KEY_AUTO_MIN_PLANNED_RR, @@ -7015,6 +7053,7 @@ def api_account_snapshot(): recommended_capital = get_recommended_capital(current_capital) active_count = get_active_position_count(conn) opens_today = count_opens_for_trading_day(conn, trading_day) + risk_status = hub_account_risk_status(conn) conn.close() can_trade = can_trade_new_open( time_allows=trading_day_reset_allows_new_open(now), @@ -7022,6 +7061,7 @@ def api_account_snapshot(): max_active_positions=MAX_ACTIVE_POSITIONS, opens_today=opens_today, hard_limit=DAILY_OPEN_HARD_LIMIT, + extra_blocks=not risk_status.get("can_trade", True), ) available_trading_usdt = get_available_trading_usdt() return jsonify({ @@ -7036,7 +7076,8 @@ def api_account_snapshot(): "daily_open_hard_limit": DAILY_OPEN_HARD_LIMIT, "daily_open_alert_threshold": DAILY_OPEN_ALERT_THRESHOLD, "manual_min_planned_rr": MANUAL_MIN_PLANNED_RR, - "trading_day": trading_day + "trading_day": trading_day, + "risk_status": risk_status, }) @@ -8601,6 +8642,15 @@ def del_order(id): opened_at=opened_at, closed_at=closed_at, ) + from account_risk_lib import insert_trade_record_id, on_manual_close + + on_manual_close( + conn, + trade_record_id=insert_trade_record_id(conn), + closed_at_ms=_to_ms_with_fallback(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)) clear_key_sizing_snapshot_if_flat(conn, session_date) 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 ) ) + from account_risk_lib import on_journal_saved + + on_journal_saved( + conn, + early_exit_trigger=early_exit_trigger, + early_exit_note=early_exit_note, + mood_issues_raw=mood_issues, + trading_day=get_trading_day(), + now=app_now(), + ) conn.commit() conn.close() if chart_msg: @@ -9305,6 +9365,7 @@ try: views={"add_order": add_order, "add_key": add_key}, ohlcv_fn=_hub_fetch_ohlcv, volume_rank_fn=_hub_fetch_volume_rank, + risk_status_fn=hub_account_risk_status, ) except Exception as _hub_err: print(f"[hub_bridge] binance: {_hub_err}") diff --git a/crypto_monitor_binance/templates/index.html b/crypto_monitor_binance/templates/index.html index 27cfe1e..05c6c2d 100644 --- a/crypto_monitor_binance/templates/index.html +++ b/crypto_monitor_binance/templates/index.html @@ -20,6 +20,12 @@ .header{display:flex;flex-direction:column;align-items:center;gap:8px;margin-bottom:12px} .header h1{font-size:1.75rem;color:#dbe4ff;text-align:center;line-height:1.25} .exchange-tag{font-size:.82rem;font-weight:600;color:#b8f5d0;background:#14241e;border:1px solid #2d6a4f;padding:5px 14px;border-radius:999px;letter-spacing:.06em} + .header-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:center} + .risk-status-badge{font-size:.78rem;font-weight:600;padding:4px 12px;border-radius:999px;border:1px solid transparent} + .risk-status-normal{color:#b8f5d0;background:#14241e;border-color:#2d6a4f} + .risk-status-freeze_1h{color:#ffd89a;background:#2a2210;border-color:#8a6a20} + .risk-status-freeze_4h{color:#ffb4a0;background:#2a1410;border-color:#8a4020} + .risk-status-freeze_daily{color:#ffb0c8;background:#2a1020;border-color:#8a3050} .top-nav{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;margin-bottom:12px} .top-nav a{padding:6px 10px;border:1px solid #304164;border-radius:8px;background:#151a2a;color:#8fc8ff;text-decoration:none} .top-nav a.active{background:#2a3f6c;color:#dbe4ff} @@ -262,6 +268,7 @@

加密货币|交易监控 + AI复盘一体化

{{ exchange_display }}
+ {{ risk_status.status_label|default('正常') }}