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:
@@ -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)
|
||||||
@@ -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
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
|
||||||
# 前端价格快照轮询(秒)
|
# 前端价格快照轮询(秒)
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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` — 单元测试
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -508,6 +508,14 @@
|
|||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user