fix(risk): trigger cooldown only on user-initiated closes
Remove external-close risk hooks; register user_instance, user_hub, and user_trend_stop via hub API and trend stop; update docs and tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+56
-25
@@ -26,7 +26,18 @@ MOOD_ISSUE_OPTIONS = (
|
|||||||
"重仓违规",
|
"重仓违规",
|
||||||
)
|
)
|
||||||
|
|
||||||
EXTERNAL_CLOSE_RESULTS = frozenset({"外部平仓"})
|
# 仅以下来源计入「手动平仓」风控(用户主动点平仓/结束计划)
|
||||||
|
CLOSE_SOURCE_USER_INSTANCE = "user_instance"
|
||||||
|
CLOSE_SOURCE_USER_HUB = "user_hub"
|
||||||
|
CLOSE_SOURCE_USER_TREND_STOP = "user_trend_stop"
|
||||||
|
|
||||||
|
USER_INITIATED_CLOSE_SOURCES = frozenset(
|
||||||
|
{
|
||||||
|
CLOSE_SOURCE_USER_INSTANCE,
|
||||||
|
CLOSE_SOURCE_USER_HUB,
|
||||||
|
CLOSE_SOURCE_USER_TREND_STOP,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(key: str, default: bool = True) -> bool:
|
def _env_bool(key: str, default: bool = True) -> bool:
|
||||||
@@ -52,10 +63,6 @@ def cooling_hours_manual() -> float:
|
|||||||
return _env_hours("RISK_COOLING_HOURS_MANUAL", 4.0)
|
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:
|
def cooling_hours_manual_journal() -> float:
|
||||||
return _env_hours("RISK_COOLING_HOURS_MANUAL_JOURNAL", 1.0)
|
return _env_hours("RISK_COOLING_HOURS_MANUAL_JOURNAL", 1.0)
|
||||||
|
|
||||||
@@ -185,26 +192,26 @@ def parse_mood_issues(raw: Any) -> list[str]:
|
|||||||
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
|
return [p for p in parts if p in MOOD_ISSUE_OPTIONS]
|
||||||
|
|
||||||
|
|
||||||
def on_manual_close(
|
def _record_one_user_initiated_close(
|
||||||
conn,
|
conn,
|
||||||
*,
|
*,
|
||||||
trade_record_id: int,
|
source: str,
|
||||||
|
trade_record_id: Optional[int],
|
||||||
closed_at_ms: Optional[int],
|
closed_at_ms: Optional[int],
|
||||||
trading_day: str,
|
trading_day: str,
|
||||||
now: Optional[datetime] = None,
|
now: Optional[datetime] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not risk_control_enabled():
|
|
||||||
return
|
|
||||||
row = _sync_trading_day(conn, trading_day, now=now)
|
row = _sync_trading_day(conn, trading_day, now=now)
|
||||||
count = int(_row_get(row, "manual_close_count") or 0) + 1
|
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)
|
close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now)
|
||||||
|
pending = int(trade_record_id) if trade_record_id else None
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""UPDATE account_risk_state SET
|
"""UPDATE account_risk_state SET
|
||||||
manual_close_count=?,
|
manual_close_count=?,
|
||||||
pending_journal_trade_id=?,
|
pending_journal_trade_id=?,
|
||||||
updated_at=?
|
updated_at=?
|
||||||
WHERE id=1""",
|
WHERE id=1""",
|
||||||
(count, int(trade_record_id), (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
|
(count, pending, (now or datetime.now()).strftime("%Y-%m-%d %H:%M:%S")),
|
||||||
)
|
)
|
||||||
if count >= manual_close_daily_limit():
|
if count >= manual_close_daily_limit():
|
||||||
_set_daily_frozen(conn, trading_day=trading_day, now=now)
|
_set_daily_frozen(conn, trading_day=trading_day, now=now)
|
||||||
@@ -218,26 +225,54 @@ def on_manual_close(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def on_external_close(
|
def on_user_initiated_close(
|
||||||
conn,
|
conn,
|
||||||
*,
|
*,
|
||||||
|
source: str,
|
||||||
|
trade_record_id: Optional[int] = None,
|
||||||
|
closed_at_ms: Optional[int] = None,
|
||||||
|
trading_day: str,
|
||||||
|
now: Optional[datetime] = None,
|
||||||
|
count: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""用户主动平仓/结束趋势计划:计入手动平仓次数与冷静期。"""
|
||||||
|
if not risk_control_enabled():
|
||||||
|
return
|
||||||
|
src = (source or "").strip()
|
||||||
|
if src not in USER_INITIATED_CLOSE_SOURCES:
|
||||||
|
return
|
||||||
|
n = max(1, int(count or 1))
|
||||||
|
for i in range(n):
|
||||||
|
_record_one_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=src,
|
||||||
|
trade_record_id=trade_record_id if i == 0 else None,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
trading_day=trading_day,
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
row = _load_state(conn)
|
||||||
|
if int(_row_get(row, "daily_frozen") or 0) == 1:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def on_manual_close(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
trade_record_id: int,
|
||||||
closed_at_ms: Optional[int],
|
closed_at_ms: Optional[int],
|
||||||
trading_day: str,
|
trading_day: str,
|
||||||
now: Optional[datetime] = None,
|
now: Optional[datetime] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not risk_control_enabled():
|
"""兼容旧调用:等同实例页用户平仓。"""
|
||||||
return
|
on_user_initiated_close(
|
||||||
close_ms = int(closed_at_ms) if closed_at_ms else _now_ms(now)
|
|
||||||
_set_cooloff(
|
|
||||||
conn,
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
trading_day=trading_day,
|
trading_day=trading_day,
|
||||||
close_at_ms=close_ms,
|
|
||||||
hours=cooling_hours_external(),
|
|
||||||
now=now,
|
now=now,
|
||||||
)
|
count=1,
|
||||||
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"),),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -357,10 +392,6 @@ def account_risk_blocks_trading(
|
|||||||
return False, str(st.get("reason") or STATUS_LABELS.get(st.get("status"), "账户冻结"))
|
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:
|
def insert_trade_record_id(conn) -> int:
|
||||||
row = conn.execute("SELECT last_insert_rowid()").fetchone()
|
row = conn.execute("SELECT last_insert_rowid()").fetchone()
|
||||||
return int(row[0] if row else 0)
|
return int(row[0] if row else 0)
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ DAILY_OPEN_HARD_LIMIT=0
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RISK_CONTROL_ENABLED=true
|
# RISK_CONTROL_ENABLED=true
|
||||||
# RISK_COOLING_HOURS_MANUAL=4
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
# RISK_COOLING_HOURS_EXTERNAL=4
|
|
||||||
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|||||||
@@ -1549,6 +1549,28 @@ def hub_account_risk_status(conn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hub_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
source,
|
||||||
|
count=1,
|
||||||
|
trade_record_id=None,
|
||||||
|
closed_at_ms=None,
|
||||||
|
):
|
||||||
|
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
|
||||||
|
|
||||||
|
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=src,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
trading_day=get_trading_day(),
|
||||||
|
now=app_now(),
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def app_now():
|
def app_now():
|
||||||
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
||||||
return datetime.now(APP_TZ).replace(tzinfo=None)
|
return datetime.now(APP_TZ).replace(tzinfo=None)
|
||||||
@@ -4430,16 +4452,6 @@ 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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
||||||
@@ -8642,10 +8654,11 @@ 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
|
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
on_manual_close(
|
on_user_initiated_close(
|
||||||
conn,
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
trade_record_id=insert_trade_record_id(conn),
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
closed_at_ms=_to_ms_with_fallback(closed_at_ms, closed_at),
|
closed_at_ms=_to_ms_with_fallback(closed_at_ms, closed_at),
|
||||||
trading_day=session_date,
|
trading_day=session_date,
|
||||||
@@ -8708,6 +8721,16 @@ 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 CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
|
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||||
|
trading_day=session_date,
|
||||||
|
now=app_now(),
|
||||||
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -9366,6 +9389,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,
|
||||||
risk_status_fn=hub_account_risk_status,
|
risk_status_fn=hub_account_risk_status,
|
||||||
|
user_close_fn=hub_user_initiated_close,
|
||||||
)
|
)
|
||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] binance: {_hub_err}")
|
print(f"[hub_bridge] binance: {_hub_err}")
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ DAILY_OPEN_HARD_LIMIT=0
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RISK_CONTROL_ENABLED=true
|
# RISK_CONTROL_ENABLED=true
|
||||||
# RISK_COOLING_HOURS_MANUAL=4
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
# RISK_COOLING_HOURS_EXTERNAL=4
|
|
||||||
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|||||||
+36
-12
@@ -1539,6 +1539,28 @@ def hub_account_risk_status(conn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hub_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
source,
|
||||||
|
count=1,
|
||||||
|
trade_record_id=None,
|
||||||
|
closed_at_ms=None,
|
||||||
|
):
|
||||||
|
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
|
||||||
|
|
||||||
|
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=src,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
trading_day=get_trading_day(),
|
||||||
|
now=app_now(),
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def app_now():
|
def app_now():
|
||||||
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
||||||
return datetime.now(APP_TZ).replace(tzinfo=None)
|
return datetime.now(APP_TZ).replace(tzinfo=None)
|
||||||
@@ -4162,16 +4184,6 @@ 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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
||||||
@@ -8563,10 +8575,11 @@ 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
|
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
on_manual_close(
|
on_user_initiated_close(
|
||||||
conn,
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
trade_record_id=insert_trade_record_id(conn),
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||||
trading_day=session_date,
|
trading_day=session_date,
|
||||||
@@ -8630,6 +8643,16 @@ 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 CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
|
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||||
|
trading_day=session_date,
|
||||||
|
now=app_now(),
|
||||||
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -9311,6 +9334,7 @@ try:
|
|||||||
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,
|
risk_status_fn=hub_account_risk_status,
|
||||||
|
user_close_fn=hub_user_initiated_close,
|
||||||
)
|
)
|
||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] gate: {_hub_err}")
|
print(f"[hub_bridge] gate: {_hub_err}")
|
||||||
|
|||||||
@@ -135,7 +135,6 @@ DAILY_OPEN_HARD_LIMIT=0
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RISK_CONTROL_ENABLED=true
|
# RISK_CONTROL_ENABLED=true
|
||||||
# RISK_COOLING_HOURS_MANUAL=4
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
# RISK_COOLING_HOURS_EXTERNAL=4
|
|
||||||
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|||||||
@@ -1539,6 +1539,28 @@ def hub_account_risk_status(conn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hub_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
source,
|
||||||
|
count=1,
|
||||||
|
trade_record_id=None,
|
||||||
|
closed_at_ms=None,
|
||||||
|
):
|
||||||
|
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
|
||||||
|
|
||||||
|
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=src,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
trading_day=get_trading_day(),
|
||||||
|
now=app_now(),
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def app_now():
|
def app_now():
|
||||||
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
||||||
return datetime.now(APP_TZ).replace(tzinfo=None)
|
return datetime.now(APP_TZ).replace(tzinfo=None)
|
||||||
@@ -4162,16 +4184,6 @@ 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 ("止盈", "止损", "保本止盈", "移动止盈", "手动平仓", "强制清仓"):
|
||||||
@@ -8563,10 +8575,11 @@ 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
|
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
on_manual_close(
|
on_user_initiated_close(
|
||||||
conn,
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
trade_record_id=insert_trade_record_id(conn),
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||||
trading_day=session_date,
|
trading_day=session_date,
|
||||||
@@ -8630,6 +8643,16 @@ 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 CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
|
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||||
|
trading_day=session_date,
|
||||||
|
now=app_now(),
|
||||||
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -9311,6 +9334,7 @@ try:
|
|||||||
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,
|
risk_status_fn=hub_account_risk_status,
|
||||||
|
user_close_fn=hub_user_initiated_close,
|
||||||
)
|
)
|
||||||
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}")
|
||||||
|
|||||||
@@ -173,7 +173,6 @@ DAILY_OPEN_HARD_LIMIT=0
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# RISK_CONTROL_ENABLED=true
|
# RISK_CONTROL_ENABLED=true
|
||||||
# RISK_COOLING_HOURS_MANUAL=4
|
# RISK_COOLING_HOURS_MANUAL=4
|
||||||
# RISK_COOLING_HOURS_EXTERNAL=4
|
|
||||||
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
# RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
# RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
# RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
|
|||||||
+36
-12
@@ -1528,6 +1528,28 @@ def hub_account_risk_status(conn):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hub_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
*,
|
||||||
|
source,
|
||||||
|
count=1,
|
||||||
|
trade_record_id=None,
|
||||||
|
closed_at_ms=None,
|
||||||
|
):
|
||||||
|
from account_risk_lib import CLOSE_SOURCE_USER_HUB, on_user_initiated_close
|
||||||
|
|
||||||
|
src = (source or "").strip() or CLOSE_SOURCE_USER_HUB
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=src,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
trading_day=get_trading_day(),
|
||||||
|
now=app_now(),
|
||||||
|
count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def app_now():
|
def app_now():
|
||||||
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
"""应用本地时区当前墙钟时间(无时区的 datetime,便于与库中字符串直接比较)。"""
|
||||||
return datetime.now(APP_TZ).replace(tzinfo=None)
|
return datetime.now(APP_TZ).replace(tzinfo=None)
|
||||||
@@ -3527,16 +3549,6 @@ 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(
|
||||||
@@ -8126,10 +8138,11 @@ 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
|
from account_risk_lib import CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
on_manual_close(
|
on_user_initiated_close(
|
||||||
conn,
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
trade_record_id=insert_trade_record_id(conn),
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||||
trading_day=session_date,
|
trading_day=session_date,
|
||||||
@@ -8190,6 +8203,16 @@ 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 CLOSE_SOURCE_USER_INSTANCE, insert_trade_record_id, on_user_initiated_close
|
||||||
|
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
|
trade_record_id=insert_trade_record_id(conn),
|
||||||
|
closed_at_ms=_to_ms_with_fallback(None, closed_at),
|
||||||
|
trading_day=session_date,
|
||||||
|
now=app_now(),
|
||||||
|
)
|
||||||
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
conn.execute("UPDATE order_monitors SET status='stopped' WHERE id=?", (id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -8849,6 +8872,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,
|
||||||
risk_status_fn=hub_account_risk_status,
|
risk_status_fn=hub_account_risk_status,
|
||||||
|
user_close_fn=hub_user_initiated_close,
|
||||||
)
|
)
|
||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] okx: {_hub_err}")
|
print(f"[hub_bridge] okx: {_hub_err}")
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# 账户冷静期 / 日冻结风控
|
# 账户冷静期 / 日冻结风控
|
||||||
|
|
||||||
四所实例(币安 / OKX / Gate / Gate 趋势)共用 `account_risk_lib.py`,在手动平仓、外部平仓与交易复盘保存时更新 `account_risk_state` 表,并在开仓前 `precheck_risk` 拦截。
|
四所实例(币安 / OKX / Gate / Gate 趋势)共用 `account_risk_lib.py`。
|
||||||
|
**仅用户主动平仓**计入风控;交易所止盈/止损、空仓同步、改保本/改委托等**不触发**冷静期。
|
||||||
|
|
||||||
## 状态展示
|
## 状态展示
|
||||||
|
|
||||||
实例页顶「交易所」标签旁、中控监控卡片账户名后显示:
|
实例页顶、中控监控卡片账户名旁:
|
||||||
|
|
||||||
| 状态 | 含义 |
|
| 状态 | 含义 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -13,42 +14,59 @@
|
|||||||
| 4h冻结 | 冷静期中(默认 4 小时) |
|
| 4h冻结 | 冷静期中(默认 4 小时) |
|
||||||
| 日冻结 | 当日禁止一切新开仓 |
|
| 日冻结 | 当日禁止一切新开仓 |
|
||||||
|
|
||||||
|
## 什么算「手动平仓」(计入风控)
|
||||||
|
|
||||||
|
以下操作通过 `close_source` 登记为 **用户主动平仓**:
|
||||||
|
|
||||||
|
| 来源标识 | 操作 |
|
||||||
|
|----------|------|
|
||||||
|
| `user_instance` | 实例页删单/手动平仓(`del_order`) |
|
||||||
|
| `user_hub` | 中控「平仓」「全平」「紧急全平」 |
|
||||||
|
| `user_trend_stop` | 趋势计划 **「结束计划」**(手动结束) |
|
||||||
|
|
||||||
|
**不算**手动平仓(不触发风控):
|
||||||
|
|
||||||
|
- 趋势 **「保本移交下单监控」**
|
||||||
|
- 中控/实例修改委托、挂止盈止损、移动保本
|
||||||
|
- 交易所止盈/止损/条件单成交
|
||||||
|
- 后台 `reconcile_external_closes` 空仓同步(即使记账为「外部平仓」)
|
||||||
|
- 监控轮询自动止盈/止损/保本
|
||||||
|
|
||||||
## 触发规则
|
## 触发规则
|
||||||
|
|
||||||
| 事件 | 行为 |
|
| 事件 | 行为 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 页面手动平仓 | 默认 **4h** 冷静期;累计手动平仓次数 +1 |
|
| 第 1 次用户主动平仓 | 默认 **4h** 冷静期 |
|
||||||
| 当日第 2 次手动平仓 | **日冻结**(默认上限 2 次,可配置) |
|
| 第 2 次用户主动平仓(同一交易日) | **日冻结** |
|
||||||
| 复盘:离场触发=手动平仓 且补充说明非空 | 将当前冷静期降为 **1h**(自上次平仓时刻起算) |
|
| 复盘勾选任意情绪标签 | **日冻结** |
|
||||||
| 复盘:情绪标签任一项勾选 | **日冻结** |
|
| 复盘:离场=手动平仓 且说明非空 | 将当前冷静期降为 **1h**(须存在 pending 关联交易记录) |
|
||||||
| 外部平仓(`result=外部平仓`) | **4h** 冷静期(正常止盈/止损不触发) |
|
|
||||||
|
|
||||||
情绪标签(`mood_issues`):怕踏空、报复开仓、盈利飘了、拿不住单、扛单、重仓违规。
|
情绪标签:怕踏空、报复开仓、盈利飘了、拿不住单、扛单、重仓违规。
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
在各实例目录 `.env` 中配置(模板见各所 `.env.example`):
|
|
||||||
|
|
||||||
```env
|
```env
|
||||||
RISK_CONTROL_ENABLED=true
|
RISK_CONTROL_ENABLED=true
|
||||||
RISK_COOLING_HOURS_MANUAL=4
|
RISK_COOLING_HOURS_MANUAL=4
|
||||||
RISK_COOLING_HOURS_EXTERNAL=4
|
|
||||||
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
RISK_COOLING_HOURS_MANUAL_JOURNAL=1
|
||||||
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
RISK_MANUAL_CLOSE_DAILY_LIMIT=2
|
||||||
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
RISK_MOOD_ISSUES_DAILY_FREEZE=true
|
||||||
```
|
```
|
||||||
|
|
||||||
- `RISK_CONTROL_ENABLED=false` 时关闭整套逻辑,状态始终为「正常」。
|
`RISK_COOLING_HOURS_EXTERNAL` 已废弃(外部平仓不再触发风控)。
|
||||||
- 交易日切换(`TRADING_DAY_RESET_HOUR`)会清零当日手动平仓计数与日冻结标记;未过期的冷静期按 `cooloff_until_ms` 自然到期。
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
- 实例:`GET /api/account_snapshot` 返回 `risk_status`;`GET /api/account_risk_status`(hub_bridge)供中控拉取。
|
| 接口 | 说明 |
|
||||||
- 中控:`hub_monitor` 载荷含 `risk_status`,卡片标题旁展示 `status_label`。
|
|------|------|
|
||||||
|
| `GET /api/account_snapshot` | 返回 `risk_status` |
|
||||||
|
| `GET /api/account_risk_status` | hub_bridge,供中控拉取 |
|
||||||
|
| `POST /api/hub/account-risk/user-close` | 中控登记用户平仓,`body: { source, count }` |
|
||||||
|
|
||||||
## 相关代码
|
## 相关代码
|
||||||
|
|
||||||
- `account_risk_lib.py` — 核心状态机
|
- `account_risk_lib.py` — 状态机与 `on_user_initiated_close`
|
||||||
- 各所 `app.py` — `on_manual_close` / `on_external_close` / `on_journal_saved` 钩子
|
- `hub_bridge.py` — `/api/hub/account-risk/user-close`
|
||||||
- `hub_bridge.py` — 中控聚合 `risk_status`
|
- `manual_trading_hub/hub.py` — 中控平仓成功后调用 user-close
|
||||||
- `tests/test_account_risk_lib.py` — 单元测试
|
- `strategy_trend_register.py` — `stop_trend_pullback` 结束计划时登记风控
|
||||||
|
- `tests/test_account_risk_lib.py`
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ def install_on_app(
|
|||||||
volume_rank_fn=None,
|
volume_rank_fn=None,
|
||||||
reconcile_hub_flat_fn=None,
|
reconcile_hub_flat_fn=None,
|
||||||
risk_status_fn=None,
|
risk_status_fn=None,
|
||||||
|
user_close_fn=None,
|
||||||
):
|
):
|
||||||
app.config["HUB_CTX"] = {
|
app.config["HUB_CTX"] = {
|
||||||
"exchange": exchange,
|
"exchange": exchange,
|
||||||
@@ -228,6 +229,7 @@ def install_on_app(
|
|||||||
"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,
|
"risk_status_fn": risk_status_fn,
|
||||||
|
"user_close_fn": user_close_fn,
|
||||||
}
|
}
|
||||||
install_hub_embed_headers(app)
|
install_hub_embed_headers(app)
|
||||||
configure_hub_embed_session(app)
|
configure_hub_embed_session(app)
|
||||||
@@ -383,6 +385,41 @@ def register_hub_routes(app):
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@app.route("/api/hub/account-risk/user-close", methods=["POST"])
|
||||||
|
@_hub_auth_required
|
||||||
|
def api_hub_account_risk_user_close():
|
||||||
|
"""中控/实例:登记用户主动平仓(计入冷静期与日冻结)。"""
|
||||||
|
c = _ctx()
|
||||||
|
get_db = c.get("get_db")
|
||||||
|
user_close_fn = c.get("user_close_fn")
|
||||||
|
if not callable(get_db) or not callable(user_close_fn):
|
||||||
|
return jsonify({"ok": False, "msg": "未配置 user_close_fn"}), 501
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
source = (body.get("source") or request.form.get("source") or "").strip()
|
||||||
|
try:
|
||||||
|
count = max(0, int(body.get("count") if body.get("count") is not None else 1))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
count = 1
|
||||||
|
trade_record_id = body.get("trade_record_id")
|
||||||
|
closed_at_ms = body.get("closed_at_ms")
|
||||||
|
if count <= 0:
|
||||||
|
return jsonify({"ok": True, "skipped": True, "count": 0})
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
user_close_fn(
|
||||||
|
conn,
|
||||||
|
source=source,
|
||||||
|
count=count,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return jsonify({"ok": True, "count": count, "source": source})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"ok": False, "msg": str(e)}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
@app.route("/api/hub/monitor")
|
@app.route("/api/hub/monitor")
|
||||||
@_hub_auth_required
|
@_hub_auth_required
|
||||||
def api_hub_monitor():
|
def api_hub_monitor():
|
||||||
|
|||||||
@@ -1177,6 +1177,21 @@ async def _fetch_flask_json(
|
|||||||
return {"ok": False, "error": str(e)}
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify_instance_user_close(
|
||||||
|
client: httpx.AsyncClient, ex: dict, *, count: int = 1
|
||||||
|
) -> dict | None:
|
||||||
|
"""登记实例侧用户主动平仓风控(中控点平仓/全平)。"""
|
||||||
|
if count <= 0 or not (ex.get("flask_url") or "").strip():
|
||||||
|
return None
|
||||||
|
return await _fetch_flask_json(
|
||||||
|
client,
|
||||||
|
ex,
|
||||||
|
"/api/hub/account-risk/user-close",
|
||||||
|
method="POST",
|
||||||
|
json_body={"source": "user_hub", "count": int(count)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _flask_error_from_hub_mon(hub_mon: dict | None) -> str | None:
|
def _flask_error_from_hub_mon(hub_mon: dict | None) -> str | None:
|
||||||
if not isinstance(hub_mon, dict) or hub_mon.get("ok") is not False:
|
if not isinstance(hub_mon, dict) or hub_mon.get("ok") is not False:
|
||||||
return None
|
return None
|
||||||
@@ -1936,6 +1951,9 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody):
|
|||||||
)
|
)
|
||||||
if isinstance(sync_parsed, dict):
|
if isinstance(sync_parsed, dict):
|
||||||
out["trend_sync"] = sync_parsed
|
out["trend_sync"] = sync_parsed
|
||||||
|
risk_sync = await _notify_instance_user_close(flask_client, ex, count=1)
|
||||||
|
if isinstance(risk_sync, dict):
|
||||||
|
out["risk_sync"] = risk_sync
|
||||||
_schedule_board_refresh()
|
_schedule_board_refresh()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -1985,7 +2003,15 @@ async def api_close_exchange(exchange_id: str):
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
body = {"raw": (r.text or "")[:2000]}
|
body = {"raw": (r.text or "")[:2000]}
|
||||||
out = {"exchange": ex, "status_code": r.status_code, "payload": body}
|
ok = bool(isinstance(body, dict) and body.get("ok"))
|
||||||
|
out = {"exchange": ex, "status_code": r.status_code, "payload": body, "ok": ok}
|
||||||
|
if ok and isinstance(body, dict):
|
||||||
|
closed = body.get("closed") or []
|
||||||
|
n = len(closed) if isinstance(closed, list) else 0
|
||||||
|
if n > 0:
|
||||||
|
risk_sync = await _notify_instance_user_close(client, ex, count=n)
|
||||||
|
if isinstance(risk_sync, dict):
|
||||||
|
out["risk_sync"] = risk_sync
|
||||||
_schedule_board_refresh()
|
_schedule_board_refresh()
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -2005,7 +2031,15 @@ async def api_close_all(body: CloseAllBody | None = Body(default=None)):
|
|||||||
payload = r.json()
|
payload = r.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
payload = {"raw": (r.text or "")[:2000]}
|
payload = {"raw": (r.text or "")[:2000]}
|
||||||
return {"id": ex["id"], "name": ex["name"], "status_code": r.status_code, "payload": payload}
|
row = {"id": ex["id"], "name": ex["name"], "status_code": r.status_code, "payload": payload}
|
||||||
|
if isinstance(payload, dict) and payload.get("ok"):
|
||||||
|
closed = payload.get("closed") or []
|
||||||
|
n = len(closed) if isinstance(closed, list) else 0
|
||||||
|
if n > 0:
|
||||||
|
risk_sync = await _notify_instance_user_close(client, ex, count=n)
|
||||||
|
if isinstance(risk_sync, dict):
|
||||||
|
row["risk_sync"] = risk_sync
|
||||||
|
return row
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"id": ex["id"], "name": ex["name"], "status_code": None, "error": str(e)}
|
return {"id": ex["id"], "name": ex["name"], "status_code": None, "error": str(e)}
|
||||||
|
|
||||||
|
|||||||
@@ -838,7 +838,34 @@ def _bump_session_capital_no_commit(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) -> None:
|
def _apply_trend_user_risk_close(cfg: dict, conn, *, trade_record_id=None, closed_at_ms=None) -> None:
|
||||||
|
m = _m(cfg)
|
||||||
|
fn = getattr(m, "hub_user_initiated_close", None)
|
||||||
|
from account_risk_lib import CLOSE_SOURCE_USER_TREND_STOP
|
||||||
|
|
||||||
|
if callable(fn):
|
||||||
|
fn(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_TREND_STOP,
|
||||||
|
count=1,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
from account_risk_lib import on_user_initiated_close
|
||||||
|
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_TREND_STOP,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_at_ms,
|
||||||
|
trading_day=m.get_trading_day(),
|
||||||
|
now=m.app_now(),
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float, *, user_initiated_risk: bool = False) -> None:
|
||||||
m = _m(cfg)
|
m = _m(cfg)
|
||||||
plan_id = int(row["id"])
|
plan_id = int(row["id"])
|
||||||
active = conn.execute(
|
active = conn.execute(
|
||||||
@@ -897,6 +924,7 @@ def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) -
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
session_capital = None
|
session_capital = None
|
||||||
|
trade_record_id = None
|
||||||
if not _trend_plan_trade_exists(conn, plan_id):
|
if not _trend_plan_trade_exists(conn, plan_id):
|
||||||
session_date = row["session_date"] or m.get_trading_day()
|
session_date = row["session_date"] or m.get_trading_day()
|
||||||
session_capital = _bump_session_capital_no_commit(
|
session_capital = _bump_session_capital_no_commit(
|
||||||
@@ -928,6 +956,26 @@ def _finalize_plan(cfg: dict, conn, row, result_label: str, exit_price: float) -
|
|||||||
entry_reason=ENTRY_REASON_TREND_PULLBACK,
|
entry_reason=ENTRY_REASON_TREND_PULLBACK,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
from account_risk_lib import insert_trade_record_id
|
||||||
|
|
||||||
|
trade_record_id = insert_trade_record_id(conn)
|
||||||
|
except Exception:
|
||||||
|
trade_record_id = None
|
||||||
|
if user_initiated_risk:
|
||||||
|
closed_ms = None
|
||||||
|
to_ms = getattr(m, "_to_ms_with_fallback", None)
|
||||||
|
if callable(to_ms):
|
||||||
|
try:
|
||||||
|
closed_ms = to_ms(None, closed_at)
|
||||||
|
except Exception:
|
||||||
|
closed_ms = None
|
||||||
|
_apply_trend_user_risk_close(
|
||||||
|
cfg,
|
||||||
|
conn,
|
||||||
|
trade_record_id=trade_record_id,
|
||||||
|
closed_at_ms=closed_ms,
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
try:
|
try:
|
||||||
from strategy_wechat_notify import notify_trend_plan_ended
|
from strategy_wechat_notify import notify_trend_plan_ended
|
||||||
@@ -1845,7 +1893,7 @@ def register_trend_routes(app: Flask, cfg: dict) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
_finalize_plan(cfg, conn, row, "手动平仓", exit_p)
|
_finalize_plan(cfg, conn, row, "手动平仓", exit_p, user_initiated_risk=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE trend_pullback_plans SET status='stopped_manual', message=? "
|
"UPDATE trend_pullback_plans SET status='stopped_manual', message=? "
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from datetime import datetime, timezone
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from account_risk_lib import (
|
from account_risk_lib import (
|
||||||
|
CLOSE_SOURCE_USER_HUB,
|
||||||
|
CLOSE_SOURCE_USER_INSTANCE,
|
||||||
|
CLOSE_SOURCE_USER_TREND_STOP,
|
||||||
STATUS_DAILY,
|
STATUS_DAILY,
|
||||||
STATUS_FREEZE_1H,
|
STATUS_FREEZE_1H,
|
||||||
STATUS_FREEZE_4H,
|
STATUS_FREEZE_4H,
|
||||||
@@ -12,11 +15,10 @@ from account_risk_lib import (
|
|||||||
account_risk_blocks_trading,
|
account_risk_blocks_trading,
|
||||||
compute_account_risk_status,
|
compute_account_risk_status,
|
||||||
ensure_account_risk_schema,
|
ensure_account_risk_schema,
|
||||||
on_external_close,
|
|
||||||
on_journal_saved,
|
on_journal_saved,
|
||||||
on_manual_close,
|
on_manual_close,
|
||||||
|
on_user_initiated_close,
|
||||||
parse_mood_issues,
|
parse_mood_issues,
|
||||||
should_apply_external_close_risk,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -33,7 +35,6 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
self.env_patch.start()
|
self.env_patch.start()
|
||||||
os.environ["RISK_CONTROL_ENABLED"] = "1"
|
os.environ["RISK_CONTROL_ENABLED"] = "1"
|
||||||
os.environ["RISK_COOLING_HOURS_MANUAL"] = "4"
|
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_COOLING_HOURS_MANUAL_JOURNAL"] = "1"
|
||||||
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
|
os.environ["RISK_MANUAL_CLOSE_DAILY_LIMIT"] = "2"
|
||||||
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
|
os.environ["RISK_MOOD_ISSUES_DAILY_FREEZE"] = "1"
|
||||||
@@ -41,17 +42,13 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.env_patch.stop()
|
self.env_patch.stop()
|
||||||
|
|
||||||
def test_should_apply_external_close_risk_only_external(self):
|
def test_user_instance_sets_4h_cooloff(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()
|
conn = _mem_conn()
|
||||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||||
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
on_manual_close(
|
on_user_initiated_close(
|
||||||
conn,
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_INSTANCE,
|
||||||
trade_record_id=101,
|
trade_record_id=101,
|
||||||
closed_at_ms=close_ms,
|
closed_at_ms=close_ms,
|
||||||
trading_day="2026-06-14",
|
trading_day="2026-06-14",
|
||||||
@@ -60,20 +57,60 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||||
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||||
self.assertFalse(st["can_trade"])
|
self.assertFalse(st["can_trade"])
|
||||||
self.assertEqual(st["manual_close_count"], 1)
|
|
||||||
ok, reason = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now)
|
|
||||||
self.assertFalse(ok)
|
|
||||||
self.assertIn("冻结", reason)
|
|
||||||
|
|
||||||
def test_second_manual_close_daily_freeze(self):
|
def test_invalid_source_ignored(self):
|
||||||
|
conn = _mem_conn()
|
||||||
|
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source="exchange_tpsl",
|
||||||
|
trading_day="2026-06-14",
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||||
|
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||||
|
|
||||||
|
def test_second_user_close_daily_freeze(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||||
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
on_manual_close(conn, trade_record_id=1, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
|
on_user_initiated_close(
|
||||||
on_manual_close(conn, trade_record_id=2, closed_at_ms=close_ms + 1000, trading_day="2026-06-14", now=now)
|
conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms, trading_day="2026-06-14", now=now
|
||||||
|
)
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn, source=CLOSE_SOURCE_USER_HUB, closed_at_ms=close_ms + 1000, trading_day="2026-06-14", now=now
|
||||||
|
)
|
||||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||||
self.assertEqual(st["status"], STATUS_DAILY)
|
self.assertEqual(st["status"], STATUS_DAILY)
|
||||||
self.assertTrue(st["daily_frozen"])
|
|
||||||
|
def test_hub_close_all_count(self):
|
||||||
|
conn = _mem_conn()
|
||||||
|
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||||
|
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_HUB,
|
||||||
|
closed_at_ms=close_ms,
|
||||||
|
trading_day="2026-06-14",
|
||||||
|
now=now,
|
||||||
|
count=2,
|
||||||
|
)
|
||||||
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||||
|
self.assertEqual(st["manual_close_count"], 2)
|
||||||
|
self.assertEqual(st["status"], STATUS_DAILY)
|
||||||
|
|
||||||
|
def test_trend_stop_counts_as_manual(self):
|
||||||
|
conn = _mem_conn()
|
||||||
|
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||||
|
on_user_initiated_close(
|
||||||
|
conn,
|
||||||
|
source=CLOSE_SOURCE_USER_TREND_STOP,
|
||||||
|
trading_day="2026-06-14",
|
||||||
|
now=now,
|
||||||
|
)
|
||||||
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||||
|
self.assertEqual(st["manual_close_count"], 1)
|
||||||
|
self.assertEqual(st["status"], STATUS_FREEZE_4H)
|
||||||
|
|
||||||
def test_journal_manual_with_note_reduces_to_1h(self):
|
def test_journal_manual_with_note_reduces_to_1h(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
@@ -105,23 +142,16 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
st = compute_account_risk_status(conn, 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.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):
|
def test_cooloff_expired_returns_normal(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
start = datetime(2026, 6, 14, 8, 0, 0)
|
start = datetime(2026, 6, 14, 8, 0, 0)
|
||||||
close_ms = int(start.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
close_ms = int(start.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
||||||
on_external_close(conn, closed_at_ms=close_ms, trading_day="2026-06-14", now=start)
|
on_user_initiated_close(
|
||||||
|
conn, source=CLOSE_SOURCE_USER_INSTANCE, closed_at_ms=close_ms, trading_day="2026-06-14", now=start
|
||||||
|
)
|
||||||
later = datetime(2026, 6, 14, 13, 0, 0)
|
later = datetime(2026, 6, 14, 13, 0, 0)
|
||||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later)
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=later)
|
||||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||||
self.assertTrue(st["can_trade"])
|
|
||||||
|
|
||||||
def test_trading_day_reset_clears_daily_frozen(self):
|
def test_trading_day_reset_clears_daily_frozen(self):
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
@@ -137,7 +167,6 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
next_day = datetime(2026, 6, 15, 8, 0, 0)
|
next_day = datetime(2026, 6, 15, 8, 0, 0)
|
||||||
st = compute_account_risk_status(conn, trading_day="2026-06-15", now=next_day)
|
st = compute_account_risk_status(conn, trading_day="2026-06-15", now=next_day)
|
||||||
self.assertEqual(st["status"], STATUS_NORMAL)
|
self.assertEqual(st["status"], STATUS_NORMAL)
|
||||||
self.assertFalse(st["daily_frozen"])
|
|
||||||
|
|
||||||
def test_parse_mood_issues_filters_unknown(self):
|
def test_parse_mood_issues_filters_unknown(self):
|
||||||
self.assertEqual(parse_mood_issues("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"])
|
self.assertEqual(parse_mood_issues("怕踏空,未知标签,扛单"), ["怕踏空", "扛单"])
|
||||||
@@ -146,11 +175,14 @@ class AccountRiskLibTests(unittest.TestCase):
|
|||||||
os.environ["RISK_CONTROL_ENABLED"] = "0"
|
os.environ["RISK_CONTROL_ENABLED"] = "0"
|
||||||
conn = _mem_conn()
|
conn = _mem_conn()
|
||||||
now = datetime(2026, 6, 14, 12, 0, 0)
|
now = datetime(2026, 6, 14, 12, 0, 0)
|
||||||
close_ms = int(now.replace(tzinfo=timezone.utc).timestamp() * 1000)
|
on_user_initiated_close(
|
||||||
on_manual_close(conn, trade_record_id=1, closed_at_ms=close_ms, trading_day="2026-06-14", now=now)
|
conn, source=CLOSE_SOURCE_USER_INSTANCE, trading_day="2026-06-14", now=now
|
||||||
|
)
|
||||||
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
st = compute_account_risk_status(conn, trading_day="2026-06-14", now=now)
|
||||||
self.assertFalse(st["enabled"])
|
self.assertFalse(st["enabled"])
|
||||||
self.assertTrue(st["can_trade"])
|
self.assertTrue(st["can_trade"])
|
||||||
|
ok, _ = account_risk_blocks_trading(conn, trading_day="2026-06-14", now=now)
|
||||||
|
self.assertTrue(ok)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user