Add daily loss force-flatten at configurable equity limit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-03 12:42:13 +08:00
parent b6c3266a9e
commit 2081bf2da9
17 changed files with 850 additions and 97 deletions
+120 -73
View File
@@ -20,6 +20,7 @@ STATUS_NORMAL = "normal"
STATUS_FREEZE_1H = "freeze_1h"
STATUS_FREEZE_4H = "freeze_4h"
STATUS_DAILY = "freeze_daily"
STATUS_DAILY_LOSS = "freeze_daily_loss"
STATUS_FREEZE_POSITION = "freeze_position"
STATUS_LABELS = {
@@ -27,6 +28,7 @@ STATUS_LABELS = {
STATUS_FREEZE_1H: "1h冻结",
STATUS_FREEZE_4H: "4h冻结",
STATUS_DAILY: "日冻结",
STATUS_DAILY_LOSS: "风控",
STATUS_FREEZE_POSITION: "仓位上限冻结",
}
@@ -82,12 +84,49 @@ def daily_position_limit() -> int:
return 5
def daily_trading_risk_pct_limit() -> float:
"""当日累计止损风险占权益上限(%)。"""
def daily_trading_risk_pct_limit(
get_setting: Optional[Callable[[str, str], str]] = None,
) -> float:
"""当日亏损占权益强平线(%),默认 2。"""
return daily_loss_force_close_pct(get_setting)
def _default_get_setting(key: str, default: str = "") -> str:
try:
return max(0.1, float(os.getenv("RISK_DAILY_TRADING_RISK_PCT", "2")))
from modules.fees.fee_specs import get_setting
return get_setting(key, default)
except Exception:
return default
def daily_loss_force_close_pct(
get_setting: Optional[Callable[[str, str], str]] = None,
) -> float:
gs = get_setting or _default_get_setting
try:
return max(0.1, min(50.0, float(gs("daily_loss_force_close_pct", "2") or 2)))
except (TypeError, ValueError):
return 2.0
try:
return max(0.1, float(os.getenv("RISK_DAILY_TRADING_RISK_PCT", "2")))
except (TypeError, ValueError):
return 2.0
def daily_loss_slippage_buffer_pct(
get_setting: Optional[Callable[[str, str], str]] = None,
) -> float:
gs = get_setting or _default_get_setting
try:
return max(0.0, min(20.0, float(gs("daily_loss_slippage_buffer_pct", "1") or 1)))
except (TypeError, ValueError):
return 1.0
def daily_loss_total_cap_pct(
get_setting: Optional[Callable[[str, str], str]] = None,
) -> float:
return daily_loss_force_close_pct(get_setting) + daily_loss_slippage_buffer_pct(get_setting)
def trading_day_reset_hour() -> int:
@@ -260,68 +299,23 @@ def _risk_amount_for_monitor_row(r, equity: float) -> float:
def daily_trading_risk_used_pct(
conn, equity: float, now: Optional[datetime] = None,
conn, equity: float, now: Optional[datetime] = None, *, mode: Optional[str] = None,
) -> Optional[float]:
"""当日交易风险占权益(%):每品种槽位只计一次。
"""当日亏损占权益(%):已实现亏损 + 持仓浮亏。"""
from modules.risk.daily_loss_guard import daily_loss_used_pct
- 仍持仓:按止损距离算风险金额(以损定仓口径)
- 已平仓:按当日已实现亏损计(pnl_net<0),不再重复累加历史监控行
"""
if equity <= 0:
return None
slots = _daily_open_slots(conn, now)
if not slots:
return 0.0
trade_mode = mode
if not trade_mode:
try:
from modules.core.trading_context import get_trading_mode
from modules.fees.fee_specs import get_setting
active_risk: dict[tuple[str, str], float] = {}
for r in conn.execute(
"""SELECT symbol, direction, lots, entry_price, stop_loss, take_profit, open_time
FROM trade_order_monitors
WHERE status='active' AND open_time IS NOT NULL AND trim(open_time) <> ''"""
).fetchall():
if not _opened_in_trading_day(r["open_time"], now):
continue
key = _position_slot_key(r["symbol"], r["direction"])
if key not in slots:
continue
amt = _risk_amount_for_monitor_row(r, equity)
if amt > 0:
active_risk[key] = amt
closed_risk: dict[tuple[str, str], float] = {}
for r in conn.execute(
"""SELECT symbol, direction, pnl_net, open_time
FROM trade_logs
WHERE open_time IS NOT NULL AND trim(open_time) <> ''"""
).fetchall():
if not _opened_in_trading_day(r["open_time"], now):
continue
key = _position_slot_key(r["symbol"], r["direction"])
if key not in slots or key in active_risk:
continue
loss = max(0.0, -float(r["pnl_net"] or 0))
if loss > 0:
closed_risk[key] = max(closed_risk.get(key, 0.0), loss)
for r in conn.execute(
"""SELECT symbol, direction, lots, entry_price, stop_loss, take_profit, open_time
FROM trade_order_monitors
WHERE status='closed' AND open_time IS NOT NULL AND trim(open_time) <> ''
ORDER BY id DESC"""
).fetchall():
if not _opened_in_trading_day(r["open_time"], now):
continue
key = _position_slot_key(r["symbol"], r["direction"])
if key not in slots or key in active_risk or key in closed_risk:
continue
amt = _risk_amount_for_monitor_row(r, equity)
if amt > 0:
closed_risk[key] = amt
total = sum(active_risk.values()) + sum(closed_risk.values())
if total <= 0:
return 0.0
return round(total / equity * 100, 2)
trade_mode = get_trading_mode(get_setting)
except Exception:
trade_mode = "simulation"
return daily_loss_used_pct(conn, equity, trade_mode, now=now)
def count_active_trade_monitors(conn) -> int:
@@ -483,6 +477,8 @@ def get_risk_status(
now: Optional[datetime] = None,
active_count: Optional[int] = None,
equity: Optional[float] = None,
mode: Optional[str] = None,
get_setting: Optional[Callable[[str, str], str]] = None,
) -> dict:
def _load() -> dict:
ensure_account_risk_schema(conn)
@@ -526,12 +522,28 @@ def get_risk_status(
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_lim = daily_trading_risk_pct_limit(get_setting)
slip_buf = daily_loss_slippage_buffer_pct(get_setting)
daily_risk_cap = daily_loss_total_cap_pct(get_setting)
daily_risk_limit_hit = False
if equity and float(equity) > 0:
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity), now)
trade_mode = mode
if not trade_mode and get_setting:
try:
from modules.core.trading_context import get_trading_mode
trade_mode = get_trading_mode(get_setting)
except Exception:
trade_mode = None
if equity and float(equity) > 0 and trade_mode:
daily_risk_used = daily_trading_risk_used_pct(
conn, float(equity), now, mode=trade_mode,
)
if daily_risk_used is not None and daily_risk_used >= daily_risk_lim:
daily_risk_limit_hit = True
elif equity and float(equity) > 0:
daily_risk_used = 0.0
loss_locked = is_daily_loss_locked(conn, now=now)
base = {
"active_count": active,
@@ -540,25 +552,37 @@ def get_risk_status(
"daily_position_limit": daily_pos_lim,
"daily_risk_used_pct": daily_risk_used,
"daily_trading_risk_pct_limit": daily_risk_lim,
"daily_loss_slippage_buffer_pct": slip_buf,
"daily_loss_total_cap_pct": daily_risk_cap,
}
if daily:
if daily or loss_locked:
reason = "当日日冻结,禁止新开仓"
if loss_locked and daily_risk_used is not None:
reason = (
f"当日亏损已达 {daily_risk_used:.2f}%(上限 {daily_risk_lim:.2f}% 权益),"
"禁止开仓"
)
return {
**base,
"status": STATUS_DAILY,
"status_label": STATUS_LABELS[STATUS_DAILY],
"status": STATUS_DAILY_LOSS if loss_locked else STATUS_DAILY,
"status_label": STATUS_LABELS[STATUS_DAILY_LOSS] if loss_locked else STATUS_LABELS[STATUS_DAILY],
"can_trade": False,
"can_roll": False,
"reason": "当日日冻结,禁止新开仓",
"reason": reason,
}
if daily_risk_limit_hit:
return {
**base,
"status": STATUS_DAILY,
"status_label": STATUS_LABELS[STATUS_DAILY],
"status": STATUS_DAILY_LOSS,
"status_label": STATUS_LABELS[STATUS_DAILY_LOSS],
"can_trade": False,
"can_roll": pos_limit,
"reason": f"已达日交易风险上限 {daily_risk_used:.2f}%/{daily_risk_lim:.2f}%",
"can_roll": False,
"reason": (
f"当日亏损已达 {daily_risk_used:.2f}%(上限 {daily_risk_lim:.2f}% 权益),"
"正在强制平仓,禁止开仓"
),
"force_flatten_required": True,
}
if daily_open_limit:
return {
@@ -590,13 +614,36 @@ def get_risk_status(
return _db_retry(_load)
def is_daily_loss_locked(conn, *, now=None) -> bool:
ensure_account_risk_schema(conn)
td = trading_day_label(now)
row = conn.execute("SELECT trading_day, daily_frozen FROM account_risk_state WHERE id=1").fetchone()
if not row:
return False
stored = str(row["trading_day"] if isinstance(row, dict) else row[0] or "")
frozen = int((row["daily_frozen"] if isinstance(row, dict) else row[1]) or 0)
return stored == td and frozen == 1
def should_skip_sl_tp_for_daily_loss(conn) -> bool:
return is_daily_loss_locked(conn)
def assert_can_open(
conn,
*,
active_count: Optional[int] = None,
equity: Optional[float] = None,
mode: Optional[str] = None,
get_setting: Optional[Callable[[str, str], str]] = None,
) -> Optional[str]:
rs = get_risk_status(conn, active_count=active_count, equity=equity)
rs = get_risk_status(
conn,
active_count=active_count,
equity=equity,
mode=mode,
get_setting=get_setting,
)
if not rs.get("can_trade"):
return rs.get("reason") or "当前不可开仓"
return None