Add daily loss force-flatten at configurable equity limit
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user