diff --git a/docs/CTP_LIVE.md b/docs/CTP_LIVE.md index 49d81a5..7aa3fb1 100644 --- a/docs/CTP_LIVE.md +++ b/docs/CTP_LIVE.md @@ -128,9 +128,9 @@ CTP_LIVE_ENV=实盘 | 页面选项 | vnpy 类型 | 说明 | |----------|-----------|------| | 限价 | `OrderType.LIMIT` | 价格按最小变动价位取整 | -| 市价 | `OrderType.FAK` + 对手价偏移 | 非「无价格市价单」,而是 **带滑点的限价 FAK**,以提高 SimNow/各前置成交率 | +| 市价 | `OrderType.FAK` + **对手价(买一/卖一)** + 滑点 | 非「无价格市价单」;止损约 12 跳、强平约 20 跳(强平另受权益滑点预留上限约束) | -止盈止损触发、手动平仓、策略平仓均走 **`order_type=market`** 的上述 FAK 逻辑。 +止盈止损触发、手动平仓走 `urgency=stop_loss`;日亏损强平走 `urgency=risk_flatten`。 --- diff --git a/docs/RISK.md b/docs/RISK.md index f86f23a..e5df5cf 100644 --- a/docs/RISK.md +++ b/docs/RISK.md @@ -6,7 +6,19 @@ --- -## 仓位上限 +## 日亏损风控(强平线) + +| 项 | 默认值 | 说明 | +|----|--------|------| +| `daily_loss_force_close_pct` | 2 | 系统设置:当日亏损(已实现+浮亏)占 **权益** 比例;**≥ 即强制平掉全部持仓** 并当日禁止开仓 | +| `daily_loss_slippage_buffer_pct` | 1 | 强平执行允许的额外滑点占权益比例;与强平线合计默认 **3%** 上限 | +| 环境变量兜底 | `RISK_DAILY_TRADING_RISK_PCT` | 未配置系统设置时强平线可回退到此 env | + +- 亏损口径:**当日已平仓亏损 + 当前持仓浮亏**(含隔夜跳空),除以当前 CTP 权益。 +- 达限后:后台 `daily_loss_guard` 撤平仓挂单 → 对手价 FAK 强平 → `daily_frozen` → 看板/下单页显示 **风控**,开仓按钮灰色。 +- 与单笔止损关系:止损为常规退出;日亏损线为账户级熔断。 + +--- | 项 | 默认值 | 说明 | |----|--------|------| diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index 82c15f0..31f114e 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -12,7 +12,7 @@ |------|--------|----------| | 导航显示 | 策略、计划、行情、手续费、AI 等开关 | 全部可关闭菜单 | | 交易模式 | SimNow / 实盘 CTP | 下单、策略、同步 | -| 计仓与风险 | 固定手数/固定金额、risk_percent、max_margin_pct、roll_max_margin_pct | [ORDER_MONITOR](./ORDER_MONITOR.md)、[STRATEGY](./STRATEGY.md) | +| 计仓与风险 | 固定手数/固定金额、risk_percent、max_margin_pct、roll_max_margin_pct、日亏损强平线 | [ORDER_MONITOR](./ORDER_MONITOR.md)、[STRATEGY](./STRATEGY.md) | | 移动保本 | trailing_be_tick_buffer | 下单、关键位自动单 | | 挂单超时 | pending_order_timeout_sec | 下单监控 pending | | CTP 连接 | 前置、账号(可覆盖 .env) | 全部交易 | @@ -37,6 +37,8 @@ | risk_percent | 1 | 单笔风险占权益 % | | max_margin_pct | 30 | 新开仓保证金上限 | | roll_max_margin_pct | 单独 | 滚仓保证金上限 | +| daily_loss_force_close_pct | 2 | 日亏损强平线(%权益) | +| daily_loss_slippage_buffer_pct | 1 | 强平滑点预留(%权益),与强平线合计默认 3% | | fixed_lots / fixed_amount | — | 计仓模式 | | trailing_be_tick_buffer | 2 | 移动保本 1R 缓冲跳数 | diff --git a/docs/风控说明.md b/docs/风控说明.md index c43fa07..1fd1ffb 100644 --- a/docs/风控说明.md +++ b/docs/风控说明.md @@ -49,7 +49,7 @@ | **风控开关** | 是否启用账户风控(持仓/日限额等) | `.env` → `RISK_CONTROL_ENABLED` | | **持仓限制** | 当前 active 持仓数 / 同时持仓上限 | `.env` → `MAX_ACTIVE_POSITIONS` | | **日持仓限制** | 当日已开仓次数(含已平)/ 日开仓上限 | `.env` → `RISK_DAILY_POSITION_LIMIT`(默认 5) | -| **日交易风险** | 当日累计止损风险占权益 / 上限 | `.env` → `RISK_DAILY_TRADING_RISK_PCT`(默认 2%) | +| **日亏损风控** | 当日亏损(已实现+浮亏)占权益 / 强平线 | 系统设置 `daily_loss_force_close_pct`(默认 2%)+ `daily_loss_slippage_buffer_pct`(默认 1%) | | **手动平仓次数** | 当日手动平仓次数 / 上限(超限日冻结) | `.env` → `RISK_MANUAL_CLOSE_DAILY_LIMIT` | | **综合保证金占比** | 占用保证金占权益 / **综合上限(50%)** | 实时计算 + 系统设置 `roll_max_margin_pct` | | **单仓保证金上限** | 新开仓保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30%) | @@ -106,7 +106,7 @@ ## 与全局风控的关系 - 看板 **实时展示** 账户风控状态;下单前各板块仍调用 `assert_can_open()` 做相同校验。 -- **日持仓限制**、**日交易风险** 与「同时持仓上限」并列生效,任一超限即禁止新开仓。 +- **日亏损风控**、**日持仓限制** 与「同时持仓上限」并列生效;达日亏损强平线将 **强制清仓** 并禁止新开仓。 - **期货不使用本系统「手动平仓冷静期」**(交易所自有规则);手动平仓仅计入当日次数,超限触发日冻结。 - **综合保证金占比** 使用 CTP 柜台权益与占用保证金实时计算;断线时可能短暂显示 `—`。 diff --git a/modules/ctp/ctp_worker.py b/modules/ctp/ctp_worker.py index 11add83..ec9aa68 100644 --- a/modules/ctp/ctp_worker.py +++ b/modules/ctp/ctp_worker.py @@ -240,6 +240,25 @@ def _on_ctp_connected(mode: str) -> None: get_bridge().request_position_snapshot(force=True) get_bridge().calibrate_trading_state() _persist_snapshot(mode) + conn = connect_db(DB_PATH) + try: + _init_worker_tables(conn) + capital = _capital(conn) + if capital <= 0: + acc = ctp_get_account(mode) or {} + capital = float(acc.get("balance") or 0) + if capital > 0: + from modules.risk.daily_loss_guard import check_daily_loss_and_flatten + + check_daily_loss_and_flatten( + conn, + mode, + equity=capital, + notify_fn=_send_wechat_msg, + get_setting=get_setting, + ) + finally: + conn.close() except Exception as exc: logger.debug("worker ctp connected callback: %s", exc) @@ -287,6 +306,16 @@ def _start_background_workers() -> None: get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting), notify_fn=_send_wechat_msg, ) + from modules.risk.daily_loss_guard import start_daily_loss_guard_worker + + start_daily_loss_guard_worker( + db_path=DB_PATH, + get_mode_fn=_mode, + init_tables_fn=_init_worker_tables, + get_capital_fn=_capital, + get_setting_fn=get_setting, + notify_fn=_send_wechat_msg, + ) def _snapshot_loop() -> None: time.sleep(3) @@ -432,6 +461,9 @@ def api_order(): price=float(data.get("price") or 0), settings=data.get("settings") or {}, order_type=data.get("order_type") or "limit", + urgency=data.get("urgency") or "normal", + equity=data.get("equity"), + slippage_buffer_pct=data.get("slippage_buffer_pct"), ) _persist_snapshot(mode) return _json_ok(**result) diff --git a/modules/ctp/vnpy_bridge.py b/modules/ctp/vnpy_bridge.py index 5fe59d2..3c6426b 100644 --- a/modules/ctp/vnpy_bridge.py +++ b/modules/ctp/vnpy_bridge.py @@ -1189,6 +1189,9 @@ class CtpBridge: price: float, tick: float, use_market: bool, + urgency: str = "normal", + equity: Optional[float] = None, + slippage_buffer_pct: Optional[float] = None, ) -> str: """平仓:VeighNa OffsetConverter 自动拆分平今/平昨(与 CTA 引擎一致)。""" from vnpy.trader.constant import Offset @@ -1210,7 +1213,11 @@ class CtpBridge: lp = float(price) if use_market: - lp = self._aggressive_limit_price(ths_code, sym, ex_name, direction, tick, lp) + lp = self._aggressive_limit_price( + ths_code, sym, ex_name, direction, tick, lp, + urgency=urgency, equity=equity, + slippage_buffer_pct=slippage_buffer_pct, lots=lots, + ) else: lp = round_to_tick(lp, tick) if lp <= 0: @@ -1245,6 +1252,9 @@ class CtpBridge: if use_market: sub_price = self._aggressive_limit_price( ths_code, sym, ex_name, sub.direction, tick, float(price), + urgency=urgency, equity=equity, + slippage_buffer_pct=slippage_buffer_pct, + lots=int(sub.volume or lots), ) else: sub_price = round_to_tick(float(sub.price or lp), tick) @@ -1266,6 +1276,48 @@ class CtpBridge: logger.debug("offset converter order req: %s", exc) return last_vt + def _find_tick_obj(self, sym: str, ex_name: str) -> Any: + if not self._engine: + return None + sym_l = sym.lower() + ex_u = ex_name.upper() + try: + for tick in self._engine.get_all_ticks(): + ts = (getattr(tick, "symbol", "") or "").lower() + te = getattr(tick, "exchange", None) + te_s = str(te.value if hasattr(te, "value") else te or "").upper() + if ts == sym_l and te_s == ex_u: + return tick + except Exception as exc: + logger.debug("find tick: %s", exc) + return None + + def _opponent_price_from_tick(self, tick: Any, direction: Any) -> Optional[float]: + from vnpy.trader.constant import Direction + + if not tick: + return None + if direction == Direction.LONG: + attrs = ("ask_price_1", "last_price", "pre_close") + else: + attrs = ("bid_price_1", "last_price", "pre_close") + for attr in attrs: + try: + v = float(getattr(tick, attr, 0) or 0) + except (TypeError, ValueError): + v = 0.0 + if v > 0: + return v + return None + + def _urgency_slip_ticks(self, urgency: str) -> int: + table = { + "normal": 5, + "stop_loss": 12, + "risk_flatten": 20, + } + return max(1, int(table.get((urgency or "normal").strip().lower(), 5))) + def _aggressive_limit_price( self, ths_code: str, @@ -1274,19 +1326,47 @@ class CtpBridge: direction: Any, tick: float, fallback: float, + *, + urgency: str = "normal", + equity: Optional[float] = None, + slippage_buffer_pct: Optional[float] = None, + lots: int = 1, ) -> float: from vnpy.trader.constant import Direction self.subscribe_symbol(ths_code) - lp = fallback - detail = self.get_tick_detail(ths_code, mode=self._connected_mode or "") - if detail.get("price"): - lp = float(detail["price"]) - slip = max(tick, tick * 3) + tick_obj = self._find_tick_obj(sym, ex_name) + opp = self._opponent_price_from_tick(tick_obj, direction) + if opp is None or opp <= 0: + detail = self.get_tick_detail(ths_code, mode=self._connected_mode or "") + if detail.get("price"): + opp = float(detail["price"]) + else: + opp = float(fallback or 0) + if opp <= 0: + return 0.0 + + slip_ticks = self._urgency_slip_ticks(urgency) + slip_price = slip_ticks * max(tick, 1e-9) + if ( + (urgency or "").strip().lower() == "risk_flatten" + and equity + and float(equity) > 0 + and slippage_buffer_pct is not None + and float(slippage_buffer_pct) > 0 + and lots > 0 + ): + spec = get_contract_spec(ths_code) + mult = float(spec.get("mult") or 10) + max_yuan = float(equity) * float(slippage_buffer_pct) / 100.0 + denom = mult * max(1, int(lots)) + if denom > 0: + slip_price = min(slip_price, max_yuan / denom) + if direction == Direction.LONG: - lp = lp + slip + lp = opp + slip_price else: - lp = max(tick, lp - slip) + lp = max(tick, opp - slip_price) return round_to_tick(lp, tick) def ping(self) -> bool: @@ -2479,6 +2559,9 @@ class CtpBridge: lots: int, price: float, order_type: str = "limit", + urgency: str = "normal", + equity: Optional[float] = None, + slippage_buffer_pct: Optional[float] = None, ) -> str: from vnpy.trader.constant import Direction, Offset, OrderType from vnpy.trader.object import OrderRequest @@ -2502,7 +2585,11 @@ class CtpBridge: use_market = (order_type or "limit").lower() == "market" if use_market: ot = OrderType.FAK - price = self._aggressive_limit_price(ths_code, sym, ex_name, d, tick, price) + price = self._aggressive_limit_price( + ths_code, sym, ex_name, d, tick, price, + urgency=urgency, equity=equity, + slippage_buffer_pct=slippage_buffer_pct, lots=lots, + ) else: ot = OrderType.LIMIT price = round_to_tick(float(price), tick) @@ -2541,7 +2628,11 @@ class CtpBridge: ot = OrderType.LIMIT price = round_to_tick(float(price), tick) if use_market: - price = self._aggressive_limit_price(ths_code, sym, ex_name, d, tick, price) + price = self._aggressive_limit_price( + ths_code, sym, ex_name, d, tick, price, + urgency=urgency, equity=equity, + slippage_buffer_pct=slippage_buffer_pct, lots=lots, + ) if price <= 0: raise ValueError("委托价格无效,请检查行情或手动填写价格") return self._submit_close_orders( @@ -2556,6 +2647,9 @@ class CtpBridge: price=price, tick=tick, use_market=use_market, + urgency=urgency, + equity=equity, + slippage_buffer_pct=slippage_buffer_pct, ) raise ValueError(f"未知开平: {offset}") @@ -3062,6 +3156,9 @@ def execute_order( price: float, settings: dict | None = None, order_type: str = "limit", + urgency: str = "normal", + equity: Optional[float] = None, + slippage_buffer_pct: Optional[float] = None, ) -> dict[str, Any]: """统一下单:simulation=SimNow,live=期货公司 CTP。""" if _use_ctp_worker_client(): @@ -3074,6 +3171,9 @@ def execute_order( "price": price, "settings": settings or {}, "order_type": order_type, + "urgency": urgency, + "equity": equity, + "slippage_buffer_pct": slippage_buffer_pct, }) del conn, settings if mode not in ("simulation", "live"): @@ -3092,6 +3192,9 @@ def execute_order( lots=lots, price=price, order_type=order_type, + urgency=urgency, + equity=equity, + slippage_buffer_pct=slippage_buffer_pct, ) return { "order_id": order_id, diff --git a/modules/risk/account_risk_lib.py b/modules/risk/account_risk_lib.py index cef3d8d..81685a8 100644 --- a/modules/risk/account_risk_lib.py +++ b/modules/risk/account_risk_lib.py @@ -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 diff --git a/modules/risk/daily_loss_guard.py b/modules/risk/daily_loss_guard.py new file mode 100644 index 0000000..3cd7f2d --- /dev/null +++ b/modules/risk/daily_loss_guard.py @@ -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() diff --git a/modules/settings/routes.py b/modules/settings/routes.py index c7e8c91..d530d8d 100644 --- a/modules/settings/routes.py +++ b/modules/settings/routes.py @@ -173,6 +173,18 @@ def register(deps) -> None: except ValueError: flash("挂单超时无效") return redirect(url_for("settings")) + try: + dl = float(request.form.get("daily_loss_force_close_pct", "2") or 2) + set_setting("daily_loss_force_close_pct", str(max(0.1, min(50.0, dl)))) + except ValueError: + flash("日亏损强平线无效") + return redirect(url_for("settings")) + try: + sb = float(request.form.get("daily_loss_slippage_buffer_pct", "1") or 1) + set_setting("daily_loss_slippage_buffer_pct", str(max(0.0, min(20.0, sb)))) + except ValueError: + flash("强平滑点预留无效") + return redirect(url_for("settings")) flash("交易模式已保存") elif action == "ctp": from modules.ctp.ctp_settings import save_ctp_auto_connect, is_ctp_auto_connect_enabled @@ -293,6 +305,8 @@ def register(deps) -> None: small_account_margin_rec=small_account_margin_recommendations(), trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"), pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"), + daily_loss_force_close_pct=get_setting("daily_loss_force_close_pct", "2"), + daily_loss_slippage_buffer_pct=get_setting("daily_loss_slippage_buffer_pct", "1"), nav_items=get_nav_items(get_setting), nav_toggles=NAV_TOGGLES, backup_dir=str(backup_dir()), diff --git a/modules/stats/dashboard_lib.py b/modules/stats/dashboard_lib.py index bdda7c1..ab06c65 100644 --- a/modules/stats/dashboard_lib.py +++ b/modules/stats/dashboard_lib.py @@ -55,6 +55,10 @@ def build_risk_overview( equity: Optional[float] = None, margin_used: Optional[float] = None, ) -> dict[str, Any]: + from modules.risk.account_risk_lib import ( + daily_loss_slippage_buffer_pct, + daily_loss_total_cap_pct, + ) from risk.account_risk_lib import ( cooling_hours_manual, cooling_hours_manual_journal, @@ -92,7 +96,9 @@ def build_risk_overview( active_n = effective_active_position_count( conn, mode, ctp_connected=ctp_connected, ) - risk = dict(get_risk_status(conn, equity=equity, active_count=active_n) or {}) + risk = dict(get_risk_status( + conn, equity=equity, active_count=active_n, mode=mode, get_setting=get_setting, + ) or {}) row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone() td = trading_day_label() stored_td = str(row["trading_day"] or "") if row else "" @@ -109,7 +115,10 @@ def build_risk_overview( daily_opens = int(risk.get("daily_open_count") or count_daily_opens(conn)) daily_risk_used = risk.get("daily_risk_used_pct") if daily_risk_used is None and equity and equity > 0: - daily_risk_used = daily_trading_risk_used_pct(conn, float(equity)) + daily_risk_used = daily_trading_risk_used_pct(conn, float(equity), mode=mode) + 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) return { "enabled": risk_control_enabled(), @@ -123,7 +132,9 @@ def build_risk_overview( "position_mode": "single" if max_active_positions() <= 1 else "multi", "position_mode_label": "单仓模式" if max_active_positions() <= 1 else "多仓模式", "daily_position_limit": daily_position_limit(), - "daily_trading_risk_pct_limit": daily_trading_risk_pct_limit(), + "daily_trading_risk_pct_limit": daily_risk_lim, + "daily_loss_slippage_buffer_pct": slip_buf, + "daily_loss_total_cap_pct": daily_risk_cap, "manual_close_daily_limit": manual_close_daily_limit(), "cooling_hours_manual": cooling_hours_manual(), "cooling_hours_manual_journal": cooling_hours_manual_journal(), diff --git a/modules/trading/install.py b/modules/trading/install.py index 20db87b..a17e6a3 100644 --- a/modules/trading/install.py +++ b/modules/trading/install.py @@ -2463,6 +2463,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn, active_count=_effective_active_position_count(conn, mode), equity=capital, + mode=mode, + get_setting=get_setting, ) margin_used = ( ctp_account_margin_used(mode) if ctp_st.get("connected") else None @@ -2496,6 +2498,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn, active_count=_effective_active_position_count(conn, mode), equity=capital, + mode=mode, + get_setting=get_setting, ) syncing = bool(ctp_st.get("connected") or ctp_st.get("connecting")) payload = { @@ -2858,6 +2862,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn, mode, ctp_connected=connected, ), equity=capital, + mode=mode, + get_setting=get_setting, ) ctp_acc = _ctp_account(mode) if connected else {} bootstrap_live = position_hub.get_snapshot() @@ -3270,7 +3276,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se execute_order( conn, mode=mode, offset=offset, symbol=sym, direction=direction, lots=lots, price=price, settings=_settings_dict(), - order_type="market", + order_type="market", urgency="stop_loss", ) mark_close_pending(sym, direction) # 始终写本地记录:CTP 同步依赖内存开平配对,重启后或成交回报延迟时会漏记 @@ -3795,6 +3801,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn, active_count=_effective_active_position_count(conn, mode), equity=_capital(conn), + mode=mode, + get_setting=get_setting, ) if err: conn.close() @@ -4099,6 +4107,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn, active_count=_effective_active_position_count(conn, mode), equity=capital, + mode=mode, + get_setting=get_setting, ) conn.commit() ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {} @@ -4227,7 +4237,10 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn = get_db() init_strategy_tables(conn) capital = _capital(conn) - err = assert_can_open(conn, equity=capital) + mode = get_trading_mode(get_setting) + err = assert_can_open( + conn, equity=capital, mode=mode, get_setting=get_setting, + ) if err: conn.close() return jsonify({"ok": False, "error": err}), 403 @@ -4748,7 +4761,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se if int(plan["lots_open"] or 0) > 0: execute_order( conn, mode=mode, offset="close", symbol=plan["symbol"], - direction=plan["direction"], lots=int(plan["lots_open"]), price=price, settings=_settings_dict(), + direction=plan["direction"], lots=int(plan["lots_open"]), price=price, + settings=_settings_dict(), order_type="market", urgency="stop_loss", ) except ValueError: pass @@ -4787,6 +4801,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se execute_order( conn, mode=mode, offset="close", symbol=sym, direction=direction, lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(), + order_type="market", urgency="stop_loss", ) except ValueError: pass @@ -4901,6 +4916,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se conn, active_count=_effective_active_position_count(conn, mode), equity=_capital(conn), + mode=mode, + get_setting=get_setting, ) if err: _notify(False, err, entry=entry, sl=sl, tp=tp, lots=0) @@ -5065,6 +5082,17 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se notify_fn=send_wechat_msg, interval=1, ) + from modules.risk.daily_loss_guard import start_daily_loss_guard_worker + + start_daily_loss_guard_worker( + db_path=DB_PATH, + get_mode_fn=lambda: get_trading_mode(get_setting), + init_tables_fn=_init_tables, + get_capital_fn=_capital, + get_setting_fn=get_setting, + notify_fn=send_wechat_msg, + interval=2, + ) start_pending_order_worker( db_path=DB_PATH, get_mode_fn=lambda: get_trading_mode(get_setting), diff --git a/modules/trading/sl_tp_guard.py b/modules/trading/sl_tp_guard.py index d3e1dba..fb29e8e 100644 --- a/modules/trading/sl_tp_guard.py +++ b/modules/trading/sl_tp_guard.py @@ -818,6 +818,12 @@ def _execute_local_close( direction = (mon.get("direction") or "long").strip().lower() if close_pending_active(sym, direction): return + try: + from modules.risk.account_risk_lib import should_skip_sl_tp_for_daily_loss + if should_skip_sl_tp_for_daily_loss(conn): + return + except Exception: + pass positions = ctp_list_positions(mode) pos = _find_position(positions, sym, direction) if not pos: @@ -849,6 +855,7 @@ def _execute_local_close( lots=lots, price=mark, order_type="market", + urgency="stop_loss", ) mark_close_pending(sym, direction) _close_all_monitors_for_symbol(conn, sym, direction) diff --git a/modules/web/static/css/trade.css b/modules/web/static/css/trade.css index cfedadc..62b6b9e 100644 --- a/modules/web/static/css/trade.css +++ b/modules/web/static/css/trade.css @@ -49,6 +49,7 @@ .trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%} .trade-action-row .btn-open:disabled{opacity:.45;cursor:not-allowed;filter:grayscale(.25)} .trade-action-row .btn-open.btn-session-off{background:var(--text-muted);border-color:var(--text-muted)} +.trade-action-row .btn-open.btn-risk-off{background:var(--text-muted);border-color:var(--text-muted);opacity:.72;cursor:not-allowed} .trailing-be-toggle{display:flex;align-items:center;gap:.4rem;font-size:.78rem;color:var(--text-label);margin-bottom:.45rem;cursor:pointer;user-select:none} .trailing-be-toggle input{width:auto;margin:0} .trailing-be-hint{font-size:.72rem;margin:0;color:var(--text-muted)} diff --git a/modules/web/static/js/dashboard.js b/modules/web/static/js/dashboard.js index e9325d8..6e96b87 100644 --- a/modules/web/static/js/dashboard.js +++ b/modules/web/static/js/dashboard.js @@ -453,9 +453,22 @@ var dailyRiskLim = lim.daily_trading_risk_pct_limit != null ? lim.daily_trading_risk_pct_limit : st.daily_trading_risk_pct_limit; + var slipBuf = lim.daily_loss_slippage_buffer_pct != null + ? lim.daily_loss_slippage_buffer_pct + : st.daily_loss_slippage_buffer_pct; + var dailyRiskCap = lim.daily_loss_total_cap_pct != null + ? lim.daily_loss_total_cap_pct + : st.daily_loss_total_cap_pct; var dailyRiskText = dailyRiskUsed != null ? fmtNum(dailyRiskUsed) + '%' : '—'; if (dailyRiskLim != null && dailyRiskUsed != null) { dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%'; + if (slipBuf != null) { + dailyRiskText += '(+滑点' + fmtNum(slipBuf) + '%'; + if (dailyRiskCap != null) { + dailyRiskText += ',合计≤' + fmtNum(dailyRiskCap) + '%'; + } + dailyRiskText += ')'; + } } else if (dailyRiskLim != null) { dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%'; } @@ -489,7 +502,7 @@ }, { label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') }, { label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') }, - { label: '日交易风险', value: dailyRiskText }, + { label: '日亏损风控', value: dailyRiskText }, { label: '手动平仓次数', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') }, { label: '综合保证金占比', diff --git a/modules/web/static/js/trade.js b/modules/web/static/js/trade.js index 6519f5d..908e25c 100644 --- a/modules/web/static/js/trade.js +++ b/modules/web/static/js/trade.js @@ -28,6 +28,7 @@ var lastCtpLoginBanAt = 0; var ctpReconnecting = false; var ctpConnectInflight = false; + var lastRiskStatus = null; var isTradingSession = false; var hasSlTpMonitoring = false; var ctpConnected = false; @@ -313,6 +314,7 @@ } var riskBadge = document.getElementById('risk-badge'); if (riskBadge && data.risk_status) { + lastRiskStatus = data.risk_status; riskBadge.textContent = data.risk_status.status_label || ''; riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss'); } @@ -396,9 +398,13 @@ function updateSessionUi() { var btnOpen = document.getElementById('btn-open'); var sessionHint = document.getElementById('session-hint'); + var canTrade = !lastRiskStatus || lastRiskStatus.can_trade !== false; if (btnOpen) { - btnOpen.disabled = !isTradingSession; + var blocked = !isTradingSession || !canTrade; + btnOpen.disabled = blocked; btnOpen.classList.toggle('btn-session-off', !isTradingSession); + btnOpen.classList.toggle('btn-risk-off', isTradingSession && !canTrade); + btnOpen.textContent = (isTradingSession && !canTrade) ? '风控' : '开仓'; } if (sessionHint) { sessionHint.hidden = !!isTradingSession; diff --git a/modules/web/templates/settings.html b/modules/web/templates/settings.html index 6138175..3997c66 100644 --- a/modules/web/templates/settings.html +++ b/modules/web/templates/settings.html @@ -253,12 +253,21 @@ +
+ + +
+
+ + +

单仓保证金上限(默认 30%)用于新开仓校验与最大手数估算;综合保证金上限(默认 50%)在单仓模式下为滚仓合计上限、多仓模式下为全部持仓合计上限。固定金额计仓时先按止损算手数,再按单仓上限收紧移动保本:达 1R 后止损移至开仓价 ± N 跳。 挂单超时:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。 + 日亏损强平:当日亏损(已实现+浮亏)达「强平线」占权益比例时,无条件平掉全部持仓并当日禁止开仓;「滑点预留」为强平执行额外允许的最大亏损占权益比例(默认 2%+1%=3% 合计上限)。 {{ small_account_margin_rec.label }}。 CTP 账号与前置在下方「CTP 连接」中配置。

diff --git a/scripts/test_daily_loss_risk.py b/scripts/test_daily_loss_risk.py new file mode 100644 index 0000000..9c81616 --- /dev/null +++ b/scripts/test_daily_loss_risk.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""日亏损风控单元自检(不加载 CTP 桥)。""" +from __future__ import annotations + +import os +import sys +import tempfile +from datetime import datetime +from zoneinfo import ZoneInfo + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from modules.core.db_conn import connect_db +from modules.risk.account_risk_lib import ( + STATUS_DAILY_LOSS, + _parse_open_time_ms, + daily_trading_risk_pct_limit, + ensure_account_risk_schema, + get_risk_status, + trading_day_start, +) + + +def _gs(key: str, default: str = "") -> str: + defaults = { + "daily_loss_force_close_pct": "2", + "daily_loss_slippage_buffer_pct": "1", + "trading_mode": "simulation", + } + return defaults.get(key, default) + + +def _closed_in_trading_day(close_time: str, now=None) -> bool: + from modules.risk.account_risk_lib import _parse_open_time_ms, trading_day_start + + 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 + for r in conn.execute( + "SELECT pnl_net, close_time FROM trade_logs WHERE close_time IS NOT NULL" + ).fetchall(): + ct = r["close_time"] if isinstance(r, dict) else r[1] + pnl = float((r["pnl_net"] if isinstance(r, dict) else r[0]) or 0) + if not _closed_in_trading_day(ct, now): + continue + if pnl < 0: + total += -pnl + return round(total, 2) + + +def main() -> None: + now = datetime.now(ZoneInfo("Asia/Shanghai")) + close_ts = now.strftime("%Y-%m-%d %H:%M:%S") + + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + conn = connect_db(path) + try: + conn.execute( + """CREATE TABLE trade_logs ( + id INTEGER PRIMARY KEY, + pnl_net REAL, + close_time TEXT + )""" + ) + conn.execute( + """CREATE TABLE trade_order_monitors ( + id INTEGER PRIMARY KEY, + symbol TEXT, direction TEXT, open_time TEXT, status TEXT + )""" + ) + conn.execute( + """CREATE TABLE roll_groups ( + id INTEGER PRIMARY KEY, + order_monitor_id INTEGER, + status TEXT + )""" + ) + conn.execute( + "INSERT INTO trade_logs (pnl_net, close_time) VALUES (?, ?)", + (-2500.0, close_ts), + ) + conn.commit() + ensure_account_risk_schema(conn) + + lim = daily_trading_risk_pct_limit(_gs) + assert lim == 2.0, lim + + loss = daily_realized_loss_amount(conn, now=now) + assert loss == 2500.0, loss + + pct = round(loss / 100000.0 * 100, 2) + assert pct == 2.5, pct + + # 模拟 get_risk_status 路径(无 CTP 浮亏) + import unittest.mock as mock + + with mock.patch( + "modules.risk.account_risk_lib.daily_trading_risk_used_pct", + return_value=2.5, + ): + risk = get_risk_status( + conn, equity=100000.0, mode="simulation", get_setting=_gs, + ) + assert risk["can_trade"] is False, risk + assert risk["status"] == STATUS_DAILY_LOSS, risk + assert risk["status_label"] == "风控", risk + + print("OK daily loss risk tests passed") + finally: + conn.close() + os.unlink(path) + + +if __name__ == "__main__": + main()