06fbff04a7
Co-authored-by: Cursor <cursoragent@cursor.com>
433 lines
14 KiB
Python
433 lines
14 KiB
Python
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
|
# 专有软件 — 未经授权禁止复制、传播、转售。
|
|
# 严禁用于:带单/代客理财、向他人推荐期货品种或买卖建议、融资配资等业务。
|
|
# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md
|
|
|
|
"""账户冷静期 / 日冻结(自 crypto_monitor 复制并简化为单账户期货版)。"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
from typing import Any, Callable, Optional, TypeVar
|
|
from zoneinfo import ZoneInfo
|
|
|
|
from db_conn import OperationalError
|
|
|
|
T = TypeVar("T")
|
|
|
|
STATUS_NORMAL = "normal"
|
|
STATUS_FREEZE_1H = "freeze_1h"
|
|
STATUS_FREEZE_4H = "freeze_4h"
|
|
STATUS_DAILY = "freeze_daily"
|
|
STATUS_FREEZE_POSITION = "freeze_position"
|
|
|
|
STATUS_LABELS = {
|
|
STATUS_NORMAL: "正常",
|
|
STATUS_FREEZE_1H: "1h冻结",
|
|
STATUS_FREEZE_4H: "4h冻结",
|
|
STATUS_DAILY: "日冻结",
|
|
STATUS_FREEZE_POSITION: "仓位上限冻结",
|
|
}
|
|
|
|
MOOD_ISSUE_OPTIONS = (
|
|
"怕踏空", "报复开仓", "盈利飘了", "拿不住单", "扛单", "重仓违规",
|
|
)
|
|
|
|
CLOSE_SOURCE_USER = "user_instance"
|
|
CLOSE_SOURCE_TREND_STOP = "user_trend_stop"
|
|
|
|
|
|
def _app_tz():
|
|
name = (os.getenv("APP_TIMEZONE") or "Asia/Shanghai").strip()
|
|
try:
|
|
return ZoneInfo(name)
|
|
except Exception:
|
|
return ZoneInfo("Asia/Shanghai")
|
|
|
|
|
|
def risk_control_enabled() -> bool:
|
|
raw = (os.getenv("RISK_CONTROL_ENABLED") or "true").strip().lower()
|
|
return raw in ("1", "true", "yes", "on")
|
|
|
|
|
|
def cooling_hours_manual() -> float:
|
|
"""期货版不使用应用层冷静期(交易所自有规则),恒为 0。"""
|
|
return 0.0
|
|
|
|
|
|
def cooling_hours_manual_journal() -> float:
|
|
return 0.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 max_active_positions() -> int:
|
|
try:
|
|
return max(1, int(os.getenv("MAX_ACTIVE_POSITIONS", "1")))
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
|
|
def daily_position_limit() -> int:
|
|
"""当日最多开仓次数(含已平)。"""
|
|
try:
|
|
return max(1, int(os.getenv("RISK_DAILY_POSITION_LIMIT", "5")))
|
|
except (TypeError, ValueError):
|
|
return 5
|
|
|
|
|
|
def daily_trading_risk_pct_limit() -> float:
|
|
"""当日累计止损风险占权益上限(%)。"""
|
|
try:
|
|
return max(0.1, float(os.getenv("RISK_DAILY_TRADING_RISK_PCT", "2")))
|
|
except (TypeError, ValueError):
|
|
return 2.0
|
|
|
|
|
|
def trading_day_reset_hour() -> int:
|
|
try:
|
|
return max(0, min(23, int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))))
|
|
except (TypeError, ValueError):
|
|
return 8
|
|
|
|
|
|
_SCHEMA_READY = False
|
|
|
|
|
|
def _db_retry(action: Callable[[], T], *, retries: int = 8, base_delay: float = 0.03) -> T:
|
|
last: OperationalError | None = None
|
|
for i in range(retries):
|
|
try:
|
|
return action()
|
|
except OperationalError as exc:
|
|
msg = str(exc).lower()
|
|
if "locked" not in msg and "serialize" not in msg and "deadlock" not in msg:
|
|
raise
|
|
last = exc
|
|
time.sleep(base_delay * (2 ** i))
|
|
if last is not None:
|
|
raise last
|
|
raise RuntimeError("db retry failed")
|
|
|
|
|
|
def ensure_account_risk_schema(conn) -> None:
|
|
global _SCHEMA_READY
|
|
if _SCHEMA_READY:
|
|
try:
|
|
row = conn.execute(
|
|
"SELECT to_regclass('public.account_risk_state') AS reg"
|
|
).fetchone()
|
|
if row and row["reg"]:
|
|
return
|
|
except Exception:
|
|
pass
|
|
_SCHEMA_READY = False
|
|
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,
|
|
last_close_at_ms INTEGER,
|
|
updated_at TEXT
|
|
)"""
|
|
)
|
|
if not conn.execute("SELECT id FROM account_risk_state WHERE id=1").fetchone():
|
|
conn.execute(
|
|
"INSERT INTO account_risk_state (id, trading_day, manual_close_count, daily_frozen) VALUES (1, '', 0, 0)"
|
|
)
|
|
conn.commit()
|
|
_SCHEMA_READY = True
|
|
|
|
|
|
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(_app_tz())
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=_app_tz())
|
|
return int(dt.timestamp() * 1000)
|
|
|
|
|
|
def trading_day_label(now: Optional[datetime] = None) -> str:
|
|
dt = now or datetime.now(_app_tz())
|
|
if dt.hour < trading_day_reset_hour():
|
|
from datetime import timedelta
|
|
dt = dt - timedelta(days=1)
|
|
return dt.date().isoformat()
|
|
|
|
|
|
def trading_day_start(now: Optional[datetime] = None) -> datetime:
|
|
dt = now or datetime.now(_app_tz())
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=_app_tz())
|
|
reset_h = trading_day_reset_hour()
|
|
start = dt.replace(hour=reset_h, minute=0, second=0, microsecond=0)
|
|
if dt.hour < reset_h:
|
|
from datetime import timedelta
|
|
start = start - timedelta(days=1)
|
|
return start
|
|
|
|
|
|
def _parse_open_time_ms(open_time: str) -> Optional[int]:
|
|
s = (open_time or "").strip().replace("T", " ")[:19]
|
|
if not s:
|
|
return None
|
|
try:
|
|
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=_app_tz())
|
|
return int(dt.timestamp() * 1000)
|
|
except ValueError:
|
|
try:
|
|
dt = datetime.strptime(s[:10], "%Y-%m-%d").replace(tzinfo=_app_tz())
|
|
return int(dt.timestamp() * 1000)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _opened_in_trading_day(open_time: str, now: Optional[datetime] = None) -> bool:
|
|
oms = _parse_open_time_ms(open_time)
|
|
if oms is None:
|
|
return False
|
|
return oms >= int(trading_day_start(now).timestamp() * 1000)
|
|
|
|
|
|
def count_daily_opens(conn, now: Optional[datetime] = None) -> int:
|
|
rows = conn.execute(
|
|
"SELECT open_time FROM trade_order_monitors "
|
|
"WHERE open_time IS NOT NULL AND trim(open_time) <> ''"
|
|
).fetchall()
|
|
return sum(1 for r in rows if _opened_in_trading_day(r["open_time"], now))
|
|
|
|
|
|
def daily_trading_risk_used_pct(
|
|
conn, equity: float, now: Optional[datetime] = None,
|
|
) -> Optional[float]:
|
|
if equity <= 0:
|
|
return None
|
|
from contract_specs import calc_position_metrics
|
|
|
|
total = 0.0
|
|
rows = conn.execute(
|
|
"""SELECT symbol, direction, lots, entry_price, stop_loss, take_profit, open_time
|
|
FROM trade_order_monitors
|
|
WHERE open_time IS NOT NULL AND trim(open_time) <> ''"""
|
|
).fetchall()
|
|
for r in rows:
|
|
if not _opened_in_trading_day(r["open_time"], now):
|
|
continue
|
|
entry = float(r["entry_price"] or 0)
|
|
if entry <= 0:
|
|
continue
|
|
sl = float(r["stop_loss"] if r["stop_loss"] is not None else entry)
|
|
tp = float(r["take_profit"] if r["take_profit"] is not None else entry)
|
|
lots = int(r["lots"] or 0)
|
|
if lots <= 0:
|
|
continue
|
|
m = calc_position_metrics(
|
|
r["direction"] or "long",
|
|
entry,
|
|
sl,
|
|
tp,
|
|
lots,
|
|
entry,
|
|
equity,
|
|
r["symbol"] or "",
|
|
)
|
|
total += float(m.get("risk_amount") or 0)
|
|
if total <= 0:
|
|
return 0.0
|
|
return round(total / equity * 100, 2)
|
|
|
|
|
|
def count_active_trade_monitors(conn) -> int:
|
|
try:
|
|
n = conn.execute(
|
|
"SELECT COUNT(*) FROM trade_order_monitors WHERE status='active'"
|
|
).fetchone()[0]
|
|
return int(n or 0)
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
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_user_initiated_close(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
|
if not risk_control_enabled():
|
|
return
|
|
ensure_account_risk_schema(conn)
|
|
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
|
td = (trading_day or trading_day_label(now)).strip()
|
|
stored = str(_row_get(row, "trading_day") or "")
|
|
count = int(_row_get(row, "manual_close_count") or 0)
|
|
if stored != td:
|
|
count = 0
|
|
count += 1
|
|
close_ms = _now_ms(now)
|
|
if count >= manual_close_daily_limit():
|
|
conn.execute(
|
|
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
|
|
daily_frozen=1, cooloff_until_ms=NULL, cooloff_hours=NULL,
|
|
last_close_at_ms=?, updated_at=? WHERE id=1""",
|
|
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
|
)
|
|
return
|
|
conn.execute(
|
|
"""UPDATE account_risk_state SET trading_day=?, manual_close_count=?,
|
|
daily_frozen=0, cooloff_until_ms=NULL, cooloff_hours=NULL,
|
|
last_close_at_ms=?, updated_at=? WHERE id=1""",
|
|
(td, count, close_ms, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
|
)
|
|
|
|
|
|
def on_mood_journal_freeze(conn, *, trading_day: str) -> None:
|
|
if not risk_control_enabled():
|
|
return
|
|
ensure_account_risk_schema(conn)
|
|
td = (trading_day or trading_day_label()).strip()
|
|
conn.execute(
|
|
"UPDATE account_risk_state SET trading_day=?, daily_frozen=1, updated_at=? WHERE id=1",
|
|
(td, datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
|
|
)
|
|
|
|
|
|
def reduce_cooloff_after_journal(conn, *, trading_day: str, now: Optional[datetime] = None) -> None:
|
|
"""期货版无应用层冷静期,保留空实现兼容旧复盘钩子。"""
|
|
del conn, trading_day, now
|
|
return
|
|
|
|
|
|
def get_risk_status(
|
|
conn,
|
|
*,
|
|
now: Optional[datetime] = None,
|
|
active_count: Optional[int] = None,
|
|
equity: Optional[float] = None,
|
|
) -> dict:
|
|
def _load() -> dict:
|
|
ensure_account_risk_schema(conn)
|
|
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
|
td = trading_day_label(now)
|
|
stored = str(_row_get(row, "trading_day") or "")
|
|
if stored != td:
|
|
conn.execute(
|
|
"UPDATE account_risk_state SET trading_day=?, manual_close_count=0, daily_frozen=0 WHERE id=1 AND trading_day<>?",
|
|
(td, td),
|
|
)
|
|
conn.commit()
|
|
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
|
|
|
now_ms = _now_ms(now)
|
|
daily = int(_row_get(row, "daily_frozen") or 0) == 1
|
|
until = _row_get(row, "cooloff_until_ms")
|
|
if until:
|
|
conn.execute(
|
|
"UPDATE account_risk_state SET cooloff_until_ms=NULL, cooloff_hours=NULL WHERE id=1"
|
|
)
|
|
conn.commit()
|
|
active = count_active_trade_monitors(conn) if active_count is None else int(active_count)
|
|
mx = max_active_positions()
|
|
pos_limit = active >= mx
|
|
daily_opens = count_daily_opens(conn, now)
|
|
daily_pos_lim = daily_position_limit()
|
|
daily_open_limit = daily_opens >= daily_pos_lim
|
|
daily_risk_used: Optional[float] = None
|
|
daily_risk_lim = daily_trading_risk_pct_limit()
|
|
daily_risk_limit_hit = False
|
|
if equity and float(equity) > 0:
|
|
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity), now)
|
|
if daily_risk_used is not None and daily_risk_used >= daily_risk_lim:
|
|
daily_risk_limit_hit = True
|
|
|
|
base = {
|
|
"active_count": active,
|
|
"max_active_positions": mx,
|
|
"daily_open_count": daily_opens,
|
|
"daily_position_limit": daily_pos_lim,
|
|
"daily_risk_used_pct": daily_risk_used,
|
|
"daily_trading_risk_pct_limit": daily_risk_lim,
|
|
}
|
|
|
|
if daily:
|
|
return {
|
|
**base,
|
|
"status": STATUS_DAILY,
|
|
"status_label": STATUS_LABELS[STATUS_DAILY],
|
|
"can_trade": False,
|
|
"can_roll": False,
|
|
"reason": "当日日冻结,禁止新开仓",
|
|
}
|
|
if daily_risk_limit_hit:
|
|
return {
|
|
**base,
|
|
"status": STATUS_DAILY,
|
|
"status_label": STATUS_LABELS[STATUS_DAILY],
|
|
"can_trade": False,
|
|
"can_roll": pos_limit,
|
|
"reason": f"已达日交易风险上限 {daily_risk_used:.2f}%/{daily_risk_lim:.2f}%",
|
|
}
|
|
if daily_open_limit:
|
|
return {
|
|
**base,
|
|
"status": STATUS_DAILY,
|
|
"status_label": STATUS_LABELS[STATUS_DAILY],
|
|
"can_trade": False,
|
|
"can_roll": pos_limit,
|
|
"reason": f"已达日持仓上限 {daily_opens}/{daily_pos_lim}",
|
|
}
|
|
if pos_limit:
|
|
return {
|
|
**base,
|
|
"status": STATUS_FREEZE_POSITION,
|
|
"status_label": STATUS_LABELS[STATUS_FREEZE_POSITION],
|
|
"can_trade": False,
|
|
"can_roll": True,
|
|
"reason": f"已达仓位上限 {active}/{mx}",
|
|
}
|
|
return {
|
|
**base,
|
|
"status": STATUS_NORMAL,
|
|
"status_label": STATUS_LABELS[STATUS_NORMAL],
|
|
"can_trade": True,
|
|
"can_roll": True,
|
|
"reason": "可新开仓",
|
|
}
|
|
|
|
return _db_retry(_load)
|
|
|
|
|
|
def assert_can_open(
|
|
conn,
|
|
*,
|
|
active_count: Optional[int] = None,
|
|
equity: Optional[float] = None,
|
|
) -> Optional[str]:
|
|
rs = get_risk_status(conn, active_count=active_count, equity=equity)
|
|
if not rs.get("can_trade"):
|
|
return rs.get("reason") or "当前不可开仓"
|
|
return None
|