Add daily loss force-flatten at configurable equity limit
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||
"""日亏损风控:达权益比例上限后强制清仓并当日禁开。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from modules.core.contract_specs import calc_position_metrics
|
||||
from modules.market.market_sessions import is_trading_session
|
||||
from modules.risk.account_risk_lib import (
|
||||
_default_get_setting,
|
||||
daily_loss_force_close_pct,
|
||||
daily_loss_slippage_buffer_pct,
|
||||
daily_loss_total_cap_pct,
|
||||
ensure_account_risk_schema,
|
||||
risk_control_enabled,
|
||||
trading_day_label,
|
||||
trading_day_start,
|
||||
_parse_open_time_ms,
|
||||
)
|
||||
from modules.ctp.vnpy_bridge import (
|
||||
ctp_cancel_order,
|
||||
ctp_get_tick_price,
|
||||
ctp_list_active_orders,
|
||||
ctp_list_positions,
|
||||
ctp_status,
|
||||
execute_order,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHECK_INTERVAL_SEC = 2
|
||||
DISCONNECTED_SLEEP_SEC = 5
|
||||
CLOSED_MARKET_SLEEP_SEC = 30
|
||||
|
||||
_flatten_lock = threading.Lock()
|
||||
_flatten_in_progress = False
|
||||
_last_flatten_attempt: float = 0.0
|
||||
FLATTEN_COOLDOWN_SEC = 15
|
||||
|
||||
|
||||
def _closed_in_trading_day(close_time: str, now=None) -> bool:
|
||||
oms = _parse_open_time_ms((close_time or "").replace("T", " "))
|
||||
if oms is None:
|
||||
return False
|
||||
return oms >= int(trading_day_start(now).timestamp() * 1000)
|
||||
|
||||
|
||||
def daily_realized_loss_amount(conn, *, now=None) -> float:
|
||||
"""当日已平仓实现的亏损金额(正数)。"""
|
||||
total = 0.0
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT pnl_net, close_time FROM trade_logs WHERE close_time IS NOT NULL"
|
||||
).fetchall()
|
||||
except Exception:
|
||||
return 0.0
|
||||
for r in rows:
|
||||
if isinstance(r, dict):
|
||||
ct = r.get("close_time") or ""
|
||||
pnl = float(r.get("pnl_net") or 0)
|
||||
else:
|
||||
ct = r[1] if len(r) > 1 else ""
|
||||
pnl = float(r[0] or 0)
|
||||
if not _closed_in_trading_day(ct, now):
|
||||
continue
|
||||
if pnl < 0:
|
||||
total += -pnl
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def daily_floating_loss_amount(mode: str) -> float:
|
||||
"""当前持仓浮亏金额(正数),含隔夜仓跳空。"""
|
||||
if not mode:
|
||||
return 0.0
|
||||
loss = 0.0
|
||||
try:
|
||||
positions = ctp_list_positions(mode, refresh_if_empty=False, refresh_margin=False)
|
||||
except Exception:
|
||||
return 0.0
|
||||
for p in positions or []:
|
||||
lots = int(p.get("lots") or 0)
|
||||
if lots <= 0:
|
||||
continue
|
||||
sym = (p.get("symbol") or "").strip()
|
||||
direction = (p.get("direction") or "long").strip().lower()
|
||||
entry = float(p.get("avg_price") or p.get("entry_price") or 0)
|
||||
if entry <= 0 or not sym:
|
||||
continue
|
||||
mark = float(p.get("mark_price") or p.get("current_price") or 0)
|
||||
if mark <= 0:
|
||||
try:
|
||||
mark = float(ctp_get_tick_price(mode, sym) or 0)
|
||||
except Exception:
|
||||
mark = 0.0
|
||||
if mark <= 0:
|
||||
continue
|
||||
m = calc_position_metrics(
|
||||
direction, entry, entry, entry, lots, mark, 1.0, sym,
|
||||
)
|
||||
fp = float(m.get("float_pnl") or 0)
|
||||
if fp < 0:
|
||||
loss += -fp
|
||||
return round(loss, 2)
|
||||
|
||||
|
||||
def daily_loss_amount(
|
||||
conn,
|
||||
equity: float,
|
||||
mode: str,
|
||||
*,
|
||||
now=None,
|
||||
) -> tuple[float, float]:
|
||||
"""返回 (亏损金额, 占权益%)。"""
|
||||
if equity <= 0:
|
||||
return 0.0, 0.0
|
||||
realized = daily_realized_loss_amount(conn, now=now)
|
||||
floating = daily_floating_loss_amount(mode)
|
||||
total = realized + floating
|
||||
pct = round(total / float(equity) * 100, 2)
|
||||
return round(total, 2), pct
|
||||
|
||||
|
||||
def daily_loss_used_pct(
|
||||
conn,
|
||||
equity: float,
|
||||
mode: str,
|
||||
*,
|
||||
now=None,
|
||||
) -> Optional[float]:
|
||||
if equity <= 0:
|
||||
return None
|
||||
_, pct = daily_loss_amount(conn, equity, mode, now=now)
|
||||
return pct
|
||||
|
||||
|
||||
def mark_daily_loss_lock(conn, *, now=None) -> None:
|
||||
ensure_account_risk_schema(conn)
|
||||
td = trading_day_label(now)
|
||||
conn.execute(
|
||||
"""UPDATE account_risk_state SET trading_day=?, daily_frozen=1,
|
||||
cooloff_until_ms=NULL, cooloff_hours=NULL, updated_at=? WHERE id=1""",
|
||||
(td, time.strftime("%Y-%m-%d %H:%M:%S")),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _cancel_all_close_orders(mode: str) -> int:
|
||||
cancelled = 0
|
||||
try:
|
||||
active = ctp_list_active_orders(mode)
|
||||
except Exception:
|
||||
return 0
|
||||
for o in active or []:
|
||||
offset_s = (o.get("offset") or "").upper()
|
||||
if "CLOSE" not in offset_s:
|
||||
continue
|
||||
oid = str(o.get("order_id") or o.get("vt_order_id") or "")
|
||||
if oid and ctp_cancel_order(mode, oid):
|
||||
cancelled += 1
|
||||
return cancelled
|
||||
|
||||
|
||||
def force_flatten_all_positions(
|
||||
conn,
|
||||
mode: str,
|
||||
*,
|
||||
equity: float,
|
||||
reason: str = "",
|
||||
notify_fn: Callable[[str], None] | None = None,
|
||||
get_setting: Callable[[str, str], str] | None = None,
|
||||
) -> int:
|
||||
"""无条件市价平掉全部持仓;返回提交平仓笔数。"""
|
||||
global _flatten_in_progress, _last_flatten_attempt
|
||||
if not ctp_status(mode).get("connected"):
|
||||
return 0
|
||||
with _flatten_lock:
|
||||
if _flatten_in_progress:
|
||||
return 0
|
||||
if time.time() - _last_flatten_attempt < FLATTEN_COOLDOWN_SEC:
|
||||
return 0
|
||||
_flatten_in_progress = True
|
||||
_last_flatten_attempt = time.time()
|
||||
submitted = 0
|
||||
try:
|
||||
mark_daily_loss_lock(conn)
|
||||
cancelled = _cancel_all_close_orders(mode)
|
||||
if cancelled:
|
||||
logger.info("日亏损强平:已撤平仓挂单 %d 笔", cancelled)
|
||||
positions = [
|
||||
p for p in (ctp_list_positions(mode) or [])
|
||||
if int(p.get("lots") or 0) > 0
|
||||
]
|
||||
if not positions:
|
||||
return 0
|
||||
slip_buf = daily_loss_slippage_buffer_pct(get_setting)
|
||||
for p in positions:
|
||||
sym = (p.get("symbol") or "").strip()
|
||||
direction = (p.get("direction") or "long").strip().lower()
|
||||
lots = int(p.get("lots") or 0)
|
||||
if not sym or lots <= 0:
|
||||
continue
|
||||
mark = float(p.get("mark_price") or p.get("current_price") or 0)
|
||||
if mark <= 0:
|
||||
mark = float(ctp_get_tick_price(mode, sym) or p.get("avg_price") or 0)
|
||||
if mark <= 0:
|
||||
logger.warning("日亏损强平跳过 %s:无有效价格", sym)
|
||||
continue
|
||||
offset = "close_long" if direction == "long" else "close_short"
|
||||
try:
|
||||
execute_order(
|
||||
conn,
|
||||
mode=mode,
|
||||
offset=offset,
|
||||
symbol=sym,
|
||||
direction=direction,
|
||||
lots=lots,
|
||||
price=mark,
|
||||
order_type="market",
|
||||
urgency="risk_flatten",
|
||||
equity=equity,
|
||||
slippage_buffer_pct=slip_buf,
|
||||
)
|
||||
submitted += 1
|
||||
logger.info(
|
||||
"日亏损强平已报单 %s %s %d手 @%s",
|
||||
sym, direction, lots, mark,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("日亏损强平失败 %s: %s", sym, exc)
|
||||
if submitted and notify_fn:
|
||||
lim = daily_loss_force_close_pct(get_setting)
|
||||
cap = daily_loss_total_cap_pct(get_setting)
|
||||
msg = (
|
||||
f"日亏损风控:已达权益 {lim:g}% 上限,已强制平仓 {submitted} 笔"
|
||||
f"(含滑点预留至 {cap:g}%)。当日禁止开仓。"
|
||||
)
|
||||
if reason:
|
||||
msg = f"{reason} {msg}"
|
||||
try:
|
||||
notify_fn(msg)
|
||||
except Exception as exc:
|
||||
logger.debug("daily loss notify: %s", exc)
|
||||
if submitted:
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE trade_order_monitors SET status='closed' WHERE status='active'"
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
logger.debug("close monitors after flatten: %s", exc)
|
||||
return submitted
|
||||
finally:
|
||||
with _flatten_lock:
|
||||
_flatten_in_progress = False
|
||||
|
||||
|
||||
def check_daily_loss_and_flatten(
|
||||
conn,
|
||||
mode: str,
|
||||
*,
|
||||
equity: float,
|
||||
notify_fn: Callable[[str], None] | None = None,
|
||||
get_setting: Callable[[str, str], str] | None = None,
|
||||
) -> int:
|
||||
"""达日亏损上限则锁日并强平。返回强平报单笔数。"""
|
||||
if not risk_control_enabled():
|
||||
return 0
|
||||
gs = get_setting or _default_get_setting
|
||||
lim = daily_loss_force_close_pct(gs)
|
||||
if equity <= 0:
|
||||
return 0
|
||||
used = daily_loss_used_pct(conn, equity, mode)
|
||||
if used is None or used < lim:
|
||||
return 0
|
||||
amt, pct = daily_loss_amount(conn, equity, mode)
|
||||
reason = f"当日亏损 {amt:.0f}元({pct:.2f}%/权益)"
|
||||
return force_flatten_all_positions(
|
||||
conn,
|
||||
mode,
|
||||
equity=equity,
|
||||
reason=reason,
|
||||
notify_fn=notify_fn,
|
||||
get_setting=gs,
|
||||
)
|
||||
|
||||
|
||||
def start_daily_loss_guard_worker(
|
||||
*,
|
||||
db_path: str,
|
||||
get_mode_fn: Callable[[], str],
|
||||
get_capital_fn: Callable,
|
||||
get_setting_fn: Callable[[str, str], str] | None = None,
|
||||
init_tables_fn: Callable | None = None,
|
||||
notify_fn: Callable[[str], None] | None = None,
|
||||
interval: int = CHECK_INTERVAL_SEC,
|
||||
) -> None:
|
||||
from modules.core.db_conn import connect_db
|
||||
|
||||
def _loop() -> None:
|
||||
time.sleep(25)
|
||||
while True:
|
||||
sleep_sec = max(1, interval)
|
||||
try:
|
||||
mode = get_mode_fn()
|
||||
if not ctp_status(mode).get("connected"):
|
||||
time.sleep(DISCONNECTED_SLEEP_SEC)
|
||||
continue
|
||||
if not is_trading_session():
|
||||
sleep_sec = max(sleep_sec, CLOSED_MARKET_SLEEP_SEC)
|
||||
conn = connect_db(db_path)
|
||||
try:
|
||||
if init_tables_fn:
|
||||
init_tables_fn(conn)
|
||||
equity = 0.0
|
||||
try:
|
||||
equity = float(get_capital_fn(conn) or 0)
|
||||
except Exception:
|
||||
equity = 0.0
|
||||
if equity <= 0:
|
||||
try:
|
||||
from modules.ctp.vnpy_bridge import ctp_get_account
|
||||
|
||||
acc = ctp_get_account(mode) or {}
|
||||
equity = float(acc.get("balance") or 0)
|
||||
except Exception:
|
||||
equity = 0.0
|
||||
if equity > 0:
|
||||
n = check_daily_loss_and_flatten(
|
||||
conn,
|
||||
mode,
|
||||
equity=equity,
|
||||
notify_fn=notify_fn,
|
||||
get_setting=get_setting_fn,
|
||||
)
|
||||
if n:
|
||||
logger.info("日亏损守护: 强制平仓 %d 笔", n)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.warning("daily_loss_guard worker: %s", exc)
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
threading.Thread(target=_loop, daemon=True, name="daily-loss-guard").start()
|
||||
Reference in New Issue
Block a user