Add daily loss force-flatten at configurable equity limit
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-2
@@ -128,9 +128,9 @@ CTP_LIVE_ENV=实盘
|
|||||||
| 页面选项 | vnpy 类型 | 说明 |
|
| 页面选项 | vnpy 类型 | 说明 |
|
||||||
|----------|-----------|------|
|
|----------|-----------|------|
|
||||||
| 限价 | `OrderType.LIMIT` | 价格按最小变动价位取整 |
|
| 限价 | `OrderType.LIMIT` | 价格按最小变动价位取整 |
|
||||||
| 市价 | `OrderType.FAK` + 对手价偏移 | 非「无价格市价单」,而是 **带滑点的限价 FAK**,以提高 SimNow/各前置成交率 |
|
| 市价 | `OrderType.FAK` + **对手价(买一/卖一)** + 滑点 | 非「无价格市价单」;止损约 12 跳、强平约 20 跳(强平另受权益滑点预留上限约束) |
|
||||||
|
|
||||||
止盈止损触发、手动平仓、策略平仓均走 **`order_type=market`** 的上述 FAK 逻辑。
|
止盈止损触发、手动平仓走 `urgency=stop_loss`;日亏损强平走 `urgency=risk_flatten`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+13
-1
@@ -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` → 看板/下单页显示 **风控**,开仓按钮灰色。
|
||||||
|
- 与单笔止损关系:止损为常规退出;日亏损线为账户级熔断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
| 项 | 默认值 | 说明 |
|
| 项 | 默认值 | 说明 |
|
||||||
|----|--------|------|
|
|----|--------|------|
|
||||||
|
|||||||
+3
-1
@@ -12,7 +12,7 @@
|
|||||||
|------|--------|----------|
|
|------|--------|----------|
|
||||||
| 导航显示 | 策略、计划、行情、手续费、AI 等开关 | 全部可关闭菜单 |
|
| 导航显示 | 策略、计划、行情、手续费、AI 等开关 | 全部可关闭菜单 |
|
||||||
| 交易模式 | SimNow / 实盘 CTP | 下单、策略、同步 |
|
| 交易模式 | 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 | 下单、关键位自动单 |
|
| 移动保本 | trailing_be_tick_buffer | 下单、关键位自动单 |
|
||||||
| 挂单超时 | pending_order_timeout_sec | 下单监控 pending |
|
| 挂单超时 | pending_order_timeout_sec | 下单监控 pending |
|
||||||
| CTP 连接 | 前置、账号(可覆盖 .env) | 全部交易 |
|
| CTP 连接 | 前置、账号(可覆盖 .env) | 全部交易 |
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
| risk_percent | 1 | 单笔风险占权益 % |
|
| risk_percent | 1 | 单笔风险占权益 % |
|
||||||
| max_margin_pct | 30 | 新开仓保证金上限 |
|
| max_margin_pct | 30 | 新开仓保证金上限 |
|
||||||
| roll_max_margin_pct | 单独 | 滚仓保证金上限 |
|
| roll_max_margin_pct | 单独 | 滚仓保证金上限 |
|
||||||
|
| daily_loss_force_close_pct | 2 | 日亏损强平线(%权益) |
|
||||||
|
| daily_loss_slippage_buffer_pct | 1 | 强平滑点预留(%权益),与强平线合计默认 3% |
|
||||||
| fixed_lots / fixed_amount | — | 计仓模式 |
|
| fixed_lots / fixed_amount | — | 计仓模式 |
|
||||||
| trailing_be_tick_buffer | 2 | 移动保本 1R 缓冲跳数 |
|
| trailing_be_tick_buffer | 2 | 移动保本 1R 缓冲跳数 |
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -49,7 +49,7 @@
|
|||||||
| **风控开关** | 是否启用账户风控(持仓/日限额等) | `.env` → `RISK_CONTROL_ENABLED` |
|
| **风控开关** | 是否启用账户风控(持仓/日限额等) | `.env` → `RISK_CONTROL_ENABLED` |
|
||||||
| **持仓限制** | 当前 active 持仓数 / 同时持仓上限 | `.env` → `MAX_ACTIVE_POSITIONS` |
|
| **持仓限制** | 当前 active 持仓数 / 同时持仓上限 | `.env` → `MAX_ACTIVE_POSITIONS` |
|
||||||
| **日持仓限制** | 当日已开仓次数(含已平)/ 日开仓上限 | `.env` → `RISK_DAILY_POSITION_LIMIT`(默认 5) |
|
| **日持仓限制** | 当日已开仓次数(含已平)/ 日开仓上限 | `.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` |
|
| **手动平仓次数** | 当日手动平仓次数 / 上限(超限日冻结) | `.env` → `RISK_MANUAL_CLOSE_DAILY_LIMIT` |
|
||||||
| **综合保证金占比** | 占用保证金占权益 / **综合上限(50%)** | 实时计算 + 系统设置 `roll_max_margin_pct` |
|
| **综合保证金占比** | 占用保证金占权益 / **综合上限(50%)** | 实时计算 + 系统设置 `roll_max_margin_pct` |
|
||||||
| **单仓保证金上限** | 新开仓保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30%) |
|
| **单仓保证金上限** | 新开仓保证金占权益上限 | 系统设置 `max_margin_pct`(默认 30%) |
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
## 与全局风控的关系
|
## 与全局风控的关系
|
||||||
|
|
||||||
- 看板 **实时展示** 账户风控状态;下单前各板块仍调用 `assert_can_open()` 做相同校验。
|
- 看板 **实时展示** 账户风控状态;下单前各板块仍调用 `assert_can_open()` 做相同校验。
|
||||||
- **日持仓限制**、**日交易风险** 与「同时持仓上限」并列生效,任一超限即禁止新开仓。
|
- **日亏损风控**、**日持仓限制** 与「同时持仓上限」并列生效;达日亏损强平线将 **强制清仓** 并禁止新开仓。
|
||||||
- **期货不使用本系统「手动平仓冷静期」**(交易所自有规则);手动平仓仅计入当日次数,超限触发日冻结。
|
- **期货不使用本系统「手动平仓冷静期」**(交易所自有规则);手动平仓仅计入当日次数,超限触发日冻结。
|
||||||
- **综合保证金占比** 使用 CTP 柜台权益与占用保证金实时计算;断线时可能短暂显示 `—`。
|
- **综合保证金占比** 使用 CTP 柜台权益与占用保证金实时计算;断线时可能短暂显示 `—`。
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,25 @@ def _on_ctp_connected(mode: str) -> None:
|
|||||||
get_bridge().request_position_snapshot(force=True)
|
get_bridge().request_position_snapshot(force=True)
|
||||||
get_bridge().calibrate_trading_state()
|
get_bridge().calibrate_trading_state()
|
||||||
_persist_snapshot(mode)
|
_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:
|
except Exception as exc:
|
||||||
logger.debug("worker ctp connected callback: %s", 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),
|
get_be_tick_buffer_fn=lambda: get_trailing_be_tick_buffer(get_setting),
|
||||||
notify_fn=_send_wechat_msg,
|
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:
|
def _snapshot_loop() -> None:
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
@@ -432,6 +461,9 @@ def api_order():
|
|||||||
price=float(data.get("price") or 0),
|
price=float(data.get("price") or 0),
|
||||||
settings=data.get("settings") or {},
|
settings=data.get("settings") or {},
|
||||||
order_type=data.get("order_type") or "limit",
|
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)
|
_persist_snapshot(mode)
|
||||||
return _json_ok(**result)
|
return _json_ok(**result)
|
||||||
|
|||||||
+113
-10
@@ -1189,6 +1189,9 @@ class CtpBridge:
|
|||||||
price: float,
|
price: float,
|
||||||
tick: float,
|
tick: float,
|
||||||
use_market: bool,
|
use_market: bool,
|
||||||
|
urgency: str = "normal",
|
||||||
|
equity: Optional[float] = None,
|
||||||
|
slippage_buffer_pct: Optional[float] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""平仓:VeighNa OffsetConverter 自动拆分平今/平昨(与 CTA 引擎一致)。"""
|
"""平仓:VeighNa OffsetConverter 自动拆分平今/平昨(与 CTA 引擎一致)。"""
|
||||||
from vnpy.trader.constant import Offset
|
from vnpy.trader.constant import Offset
|
||||||
@@ -1210,7 +1213,11 @@ class CtpBridge:
|
|||||||
|
|
||||||
lp = float(price)
|
lp = float(price)
|
||||||
if use_market:
|
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:
|
else:
|
||||||
lp = round_to_tick(lp, tick)
|
lp = round_to_tick(lp, tick)
|
||||||
if lp <= 0:
|
if lp <= 0:
|
||||||
@@ -1245,6 +1252,9 @@ class CtpBridge:
|
|||||||
if use_market:
|
if use_market:
|
||||||
sub_price = self._aggressive_limit_price(
|
sub_price = self._aggressive_limit_price(
|
||||||
ths_code, sym, ex_name, sub.direction, tick, float(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:
|
else:
|
||||||
sub_price = round_to_tick(float(sub.price or lp), tick)
|
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)
|
logger.debug("offset converter order req: %s", exc)
|
||||||
return last_vt
|
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(
|
def _aggressive_limit_price(
|
||||||
self,
|
self,
|
||||||
ths_code: str,
|
ths_code: str,
|
||||||
@@ -1274,19 +1326,47 @@ class CtpBridge:
|
|||||||
direction: Any,
|
direction: Any,
|
||||||
tick: float,
|
tick: float,
|
||||||
fallback: float,
|
fallback: float,
|
||||||
|
*,
|
||||||
|
urgency: str = "normal",
|
||||||
|
equity: Optional[float] = None,
|
||||||
|
slippage_buffer_pct: Optional[float] = None,
|
||||||
|
lots: int = 1,
|
||||||
) -> float:
|
) -> float:
|
||||||
from vnpy.trader.constant import Direction
|
from vnpy.trader.constant import Direction
|
||||||
|
|
||||||
self.subscribe_symbol(ths_code)
|
self.subscribe_symbol(ths_code)
|
||||||
lp = fallback
|
tick_obj = self._find_tick_obj(sym, ex_name)
|
||||||
detail = self.get_tick_detail(ths_code, mode=self._connected_mode or "")
|
opp = self._opponent_price_from_tick(tick_obj, direction)
|
||||||
if detail.get("price"):
|
if opp is None or opp <= 0:
|
||||||
lp = float(detail["price"])
|
detail = self.get_tick_detail(ths_code, mode=self._connected_mode or "")
|
||||||
slip = max(tick, tick * 3)
|
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:
|
if direction == Direction.LONG:
|
||||||
lp = lp + slip
|
lp = opp + slip_price
|
||||||
else:
|
else:
|
||||||
lp = max(tick, lp - slip)
|
lp = max(tick, opp - slip_price)
|
||||||
return round_to_tick(lp, tick)
|
return round_to_tick(lp, tick)
|
||||||
|
|
||||||
def ping(self) -> bool:
|
def ping(self) -> bool:
|
||||||
@@ -2479,6 +2559,9 @@ class CtpBridge:
|
|||||||
lots: int,
|
lots: int,
|
||||||
price: float,
|
price: float,
|
||||||
order_type: str = "limit",
|
order_type: str = "limit",
|
||||||
|
urgency: str = "normal",
|
||||||
|
equity: Optional[float] = None,
|
||||||
|
slippage_buffer_pct: Optional[float] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
from vnpy.trader.constant import Direction, Offset, OrderType
|
from vnpy.trader.constant import Direction, Offset, OrderType
|
||||||
from vnpy.trader.object import OrderRequest
|
from vnpy.trader.object import OrderRequest
|
||||||
@@ -2502,7 +2585,11 @@ class CtpBridge:
|
|||||||
use_market = (order_type or "limit").lower() == "market"
|
use_market = (order_type or "limit").lower() == "market"
|
||||||
if use_market:
|
if use_market:
|
||||||
ot = OrderType.FAK
|
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:
|
else:
|
||||||
ot = OrderType.LIMIT
|
ot = OrderType.LIMIT
|
||||||
price = round_to_tick(float(price), tick)
|
price = round_to_tick(float(price), tick)
|
||||||
@@ -2541,7 +2628,11 @@ class CtpBridge:
|
|||||||
ot = OrderType.LIMIT
|
ot = OrderType.LIMIT
|
||||||
price = round_to_tick(float(price), tick)
|
price = round_to_tick(float(price), tick)
|
||||||
if use_market:
|
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:
|
if price <= 0:
|
||||||
raise ValueError("委托价格无效,请检查行情或手动填写价格")
|
raise ValueError("委托价格无效,请检查行情或手动填写价格")
|
||||||
return self._submit_close_orders(
|
return self._submit_close_orders(
|
||||||
@@ -2556,6 +2647,9 @@ class CtpBridge:
|
|||||||
price=price,
|
price=price,
|
||||||
tick=tick,
|
tick=tick,
|
||||||
use_market=use_market,
|
use_market=use_market,
|
||||||
|
urgency=urgency,
|
||||||
|
equity=equity,
|
||||||
|
slippage_buffer_pct=slippage_buffer_pct,
|
||||||
)
|
)
|
||||||
raise ValueError(f"未知开平: {offset}")
|
raise ValueError(f"未知开平: {offset}")
|
||||||
|
|
||||||
@@ -3062,6 +3156,9 @@ def execute_order(
|
|||||||
price: float,
|
price: float,
|
||||||
settings: dict | None = None,
|
settings: dict | None = None,
|
||||||
order_type: str = "limit",
|
order_type: str = "limit",
|
||||||
|
urgency: str = "normal",
|
||||||
|
equity: Optional[float] = None,
|
||||||
|
slippage_buffer_pct: Optional[float] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""统一下单:simulation=SimNow,live=期货公司 CTP。"""
|
"""统一下单:simulation=SimNow,live=期货公司 CTP。"""
|
||||||
if _use_ctp_worker_client():
|
if _use_ctp_worker_client():
|
||||||
@@ -3074,6 +3171,9 @@ def execute_order(
|
|||||||
"price": price,
|
"price": price,
|
||||||
"settings": settings or {},
|
"settings": settings or {},
|
||||||
"order_type": order_type,
|
"order_type": order_type,
|
||||||
|
"urgency": urgency,
|
||||||
|
"equity": equity,
|
||||||
|
"slippage_buffer_pct": slippage_buffer_pct,
|
||||||
})
|
})
|
||||||
del conn, settings
|
del conn, settings
|
||||||
if mode not in ("simulation", "live"):
|
if mode not in ("simulation", "live"):
|
||||||
@@ -3092,6 +3192,9 @@ def execute_order(
|
|||||||
lots=lots,
|
lots=lots,
|
||||||
price=price,
|
price=price,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
|
urgency=urgency,
|
||||||
|
equity=equity,
|
||||||
|
slippage_buffer_pct=slippage_buffer_pct,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"order_id": order_id,
|
"order_id": order_id,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ STATUS_NORMAL = "normal"
|
|||||||
STATUS_FREEZE_1H = "freeze_1h"
|
STATUS_FREEZE_1H = "freeze_1h"
|
||||||
STATUS_FREEZE_4H = "freeze_4h"
|
STATUS_FREEZE_4H = "freeze_4h"
|
||||||
STATUS_DAILY = "freeze_daily"
|
STATUS_DAILY = "freeze_daily"
|
||||||
|
STATUS_DAILY_LOSS = "freeze_daily_loss"
|
||||||
STATUS_FREEZE_POSITION = "freeze_position"
|
STATUS_FREEZE_POSITION = "freeze_position"
|
||||||
|
|
||||||
STATUS_LABELS = {
|
STATUS_LABELS = {
|
||||||
@@ -27,6 +28,7 @@ STATUS_LABELS = {
|
|||||||
STATUS_FREEZE_1H: "1h冻结",
|
STATUS_FREEZE_1H: "1h冻结",
|
||||||
STATUS_FREEZE_4H: "4h冻结",
|
STATUS_FREEZE_4H: "4h冻结",
|
||||||
STATUS_DAILY: "日冻结",
|
STATUS_DAILY: "日冻结",
|
||||||
|
STATUS_DAILY_LOSS: "风控",
|
||||||
STATUS_FREEZE_POSITION: "仓位上限冻结",
|
STATUS_FREEZE_POSITION: "仓位上限冻结",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,12 +84,49 @@ def daily_position_limit() -> int:
|
|||||||
return 5
|
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:
|
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):
|
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:
|
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(
|
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]:
|
) -> Optional[float]:
|
||||||
"""当日交易风险占权益(%):每品种槽位只计一次。
|
"""当日亏损占权益(%):已实现亏损 + 持仓浮亏。"""
|
||||||
|
from modules.risk.daily_loss_guard import daily_loss_used_pct
|
||||||
|
|
||||||
- 仍持仓:按止损距离算风险金额(以损定仓口径)
|
|
||||||
- 已平仓:按当日已实现亏损计(pnl_net<0),不再重复累加历史监控行
|
|
||||||
"""
|
|
||||||
if equity <= 0:
|
if equity <= 0:
|
||||||
return None
|
return None
|
||||||
slots = _daily_open_slots(conn, now)
|
trade_mode = mode
|
||||||
if not slots:
|
if not trade_mode:
|
||||||
return 0.0
|
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] = {}
|
trade_mode = get_trading_mode(get_setting)
|
||||||
for r in conn.execute(
|
except Exception:
|
||||||
"""SELECT symbol, direction, lots, entry_price, stop_loss, take_profit, open_time
|
trade_mode = "simulation"
|
||||||
FROM trade_order_monitors
|
return daily_loss_used_pct(conn, equity, trade_mode, now=now)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def count_active_trade_monitors(conn) -> int:
|
def count_active_trade_monitors(conn) -> int:
|
||||||
@@ -483,6 +477,8 @@ def get_risk_status(
|
|||||||
now: Optional[datetime] = None,
|
now: Optional[datetime] = None,
|
||||||
active_count: Optional[int] = None,
|
active_count: Optional[int] = None,
|
||||||
equity: Optional[float] = None,
|
equity: Optional[float] = None,
|
||||||
|
mode: Optional[str] = None,
|
||||||
|
get_setting: Optional[Callable[[str, str], str]] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
def _load() -> dict:
|
def _load() -> dict:
|
||||||
ensure_account_risk_schema(conn)
|
ensure_account_risk_schema(conn)
|
||||||
@@ -526,12 +522,28 @@ def get_risk_status(
|
|||||||
daily_pos_lim = daily_position_limit()
|
daily_pos_lim = daily_position_limit()
|
||||||
daily_open_limit = daily_opens >= daily_pos_lim
|
daily_open_limit = daily_opens >= daily_pos_lim
|
||||||
daily_risk_used: Optional[float] = None
|
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
|
daily_risk_limit_hit = False
|
||||||
if equity and float(equity) > 0:
|
trade_mode = mode
|
||||||
daily_risk_used = daily_trading_risk_used_pct(conn, float(equity), now)
|
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:
|
if daily_risk_used is not None and daily_risk_used >= daily_risk_lim:
|
||||||
daily_risk_limit_hit = True
|
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 = {
|
base = {
|
||||||
"active_count": active,
|
"active_count": active,
|
||||||
@@ -540,25 +552,37 @@ def get_risk_status(
|
|||||||
"daily_position_limit": daily_pos_lim,
|
"daily_position_limit": daily_pos_lim,
|
||||||
"daily_risk_used_pct": daily_risk_used,
|
"daily_risk_used_pct": daily_risk_used,
|
||||||
"daily_trading_risk_pct_limit": daily_risk_lim,
|
"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 {
|
return {
|
||||||
**base,
|
**base,
|
||||||
"status": STATUS_DAILY,
|
"status": STATUS_DAILY_LOSS if loss_locked else STATUS_DAILY,
|
||||||
"status_label": STATUS_LABELS[STATUS_DAILY],
|
"status_label": STATUS_LABELS[STATUS_DAILY_LOSS] if loss_locked else STATUS_LABELS[STATUS_DAILY],
|
||||||
"can_trade": False,
|
"can_trade": False,
|
||||||
"can_roll": False,
|
"can_roll": False,
|
||||||
"reason": "当日日冻结,禁止新开仓",
|
"reason": reason,
|
||||||
}
|
}
|
||||||
if daily_risk_limit_hit:
|
if daily_risk_limit_hit:
|
||||||
return {
|
return {
|
||||||
**base,
|
**base,
|
||||||
"status": STATUS_DAILY,
|
"status": STATUS_DAILY_LOSS,
|
||||||
"status_label": STATUS_LABELS[STATUS_DAILY],
|
"status_label": STATUS_LABELS[STATUS_DAILY_LOSS],
|
||||||
"can_trade": False,
|
"can_trade": False,
|
||||||
"can_roll": pos_limit,
|
"can_roll": False,
|
||||||
"reason": f"已达日交易风险上限 {daily_risk_used:.2f}%/{daily_risk_lim:.2f}%",
|
"reason": (
|
||||||
|
f"当日亏损已达 {daily_risk_used:.2f}%(上限 {daily_risk_lim:.2f}% 权益),"
|
||||||
|
"正在强制平仓,禁止开仓"
|
||||||
|
),
|
||||||
|
"force_flatten_required": True,
|
||||||
}
|
}
|
||||||
if daily_open_limit:
|
if daily_open_limit:
|
||||||
return {
|
return {
|
||||||
@@ -590,13 +614,36 @@ def get_risk_status(
|
|||||||
return _db_retry(_load)
|
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(
|
def assert_can_open(
|
||||||
conn,
|
conn,
|
||||||
*,
|
*,
|
||||||
active_count: Optional[int] = None,
|
active_count: Optional[int] = None,
|
||||||
equity: Optional[float] = None,
|
equity: Optional[float] = None,
|
||||||
|
mode: Optional[str] = None,
|
||||||
|
get_setting: Optional[Callable[[str, str], str]] = None,
|
||||||
) -> Optional[str]:
|
) -> 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"):
|
if not rs.get("can_trade"):
|
||||||
return rs.get("reason") or "当前不可开仓"
|
return rs.get("reason") or "当前不可开仓"
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -173,6 +173,18 @@ def register(deps) -> None:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
flash("挂单超时无效")
|
flash("挂单超时无效")
|
||||||
return redirect(url_for("settings"))
|
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("交易模式已保存")
|
flash("交易模式已保存")
|
||||||
elif action == "ctp":
|
elif action == "ctp":
|
||||||
from modules.ctp.ctp_settings import save_ctp_auto_connect, is_ctp_auto_connect_enabled
|
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(),
|
small_account_margin_rec=small_account_margin_recommendations(),
|
||||||
trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"),
|
trailing_be_tick_buffer=get_setting("trailing_be_tick_buffer", "2"),
|
||||||
pending_order_timeout_min=get_setting("pending_order_timeout_min", "5"),
|
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_items=get_nav_items(get_setting),
|
||||||
nav_toggles=NAV_TOGGLES,
|
nav_toggles=NAV_TOGGLES,
|
||||||
backup_dir=str(backup_dir()),
|
backup_dir=str(backup_dir()),
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ def build_risk_overview(
|
|||||||
equity: Optional[float] = None,
|
equity: Optional[float] = None,
|
||||||
margin_used: Optional[float] = None,
|
margin_used: Optional[float] = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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 (
|
from risk.account_risk_lib import (
|
||||||
cooling_hours_manual,
|
cooling_hours_manual,
|
||||||
cooling_hours_manual_journal,
|
cooling_hours_manual_journal,
|
||||||
@@ -92,7 +96,9 @@ def build_risk_overview(
|
|||||||
active_n = effective_active_position_count(
|
active_n = effective_active_position_count(
|
||||||
conn, mode, ctp_connected=ctp_connected,
|
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()
|
row = conn.execute("SELECT * FROM account_risk_state WHERE id=1").fetchone()
|
||||||
td = trading_day_label()
|
td = trading_day_label()
|
||||||
stored_td = str(row["trading_day"] or "") if row else ""
|
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_opens = int(risk.get("daily_open_count") or count_daily_opens(conn))
|
||||||
daily_risk_used = risk.get("daily_risk_used_pct")
|
daily_risk_used = risk.get("daily_risk_used_pct")
|
||||||
if daily_risk_used is None and equity and equity > 0:
|
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 {
|
return {
|
||||||
"enabled": risk_control_enabled(),
|
"enabled": risk_control_enabled(),
|
||||||
@@ -123,7 +132,9 @@ def build_risk_overview(
|
|||||||
"position_mode": "single" if max_active_positions() <= 1 else "multi",
|
"position_mode": "single" if max_active_positions() <= 1 else "multi",
|
||||||
"position_mode_label": "单仓模式" if max_active_positions() <= 1 else "多仓模式",
|
"position_mode_label": "单仓模式" if max_active_positions() <= 1 else "多仓模式",
|
||||||
"daily_position_limit": daily_position_limit(),
|
"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(),
|
"manual_close_daily_limit": manual_close_daily_limit(),
|
||||||
"cooling_hours_manual": cooling_hours_manual(),
|
"cooling_hours_manual": cooling_hours_manual(),
|
||||||
"cooling_hours_manual_journal": cooling_hours_manual_journal(),
|
"cooling_hours_manual_journal": cooling_hours_manual_journal(),
|
||||||
|
|||||||
@@ -2463,6 +2463,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
conn,
|
conn,
|
||||||
active_count=_effective_active_position_count(conn, mode),
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
equity=capital,
|
equity=capital,
|
||||||
|
mode=mode,
|
||||||
|
get_setting=get_setting,
|
||||||
)
|
)
|
||||||
margin_used = (
|
margin_used = (
|
||||||
ctp_account_margin_used(mode) if ctp_st.get("connected") else None
|
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,
|
conn,
|
||||||
active_count=_effective_active_position_count(conn, mode),
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
equity=capital,
|
equity=capital,
|
||||||
|
mode=mode,
|
||||||
|
get_setting=get_setting,
|
||||||
)
|
)
|
||||||
syncing = bool(ctp_st.get("connected") or ctp_st.get("connecting"))
|
syncing = bool(ctp_st.get("connected") or ctp_st.get("connecting"))
|
||||||
payload = {
|
payload = {
|
||||||
@@ -2858,6 +2862,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
conn, mode, ctp_connected=connected,
|
conn, mode, ctp_connected=connected,
|
||||||
),
|
),
|
||||||
equity=capital,
|
equity=capital,
|
||||||
|
mode=mode,
|
||||||
|
get_setting=get_setting,
|
||||||
)
|
)
|
||||||
ctp_acc = _ctp_account(mode) if connected else {}
|
ctp_acc = _ctp_account(mode) if connected else {}
|
||||||
bootstrap_live = position_hub.get_snapshot()
|
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(
|
execute_order(
|
||||||
conn, mode=mode, offset=offset, symbol=sym, direction=direction,
|
conn, mode=mode, offset=offset, symbol=sym, direction=direction,
|
||||||
lots=lots, price=price, settings=_settings_dict(),
|
lots=lots, price=price, settings=_settings_dict(),
|
||||||
order_type="market",
|
order_type="market", urgency="stop_loss",
|
||||||
)
|
)
|
||||||
mark_close_pending(sym, direction)
|
mark_close_pending(sym, direction)
|
||||||
# 始终写本地记录:CTP 同步依赖内存开平配对,重启后或成交回报延迟时会漏记
|
# 始终写本地记录:CTP 同步依赖内存开平配对,重启后或成交回报延迟时会漏记
|
||||||
@@ -3795,6 +3801,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
conn,
|
conn,
|
||||||
active_count=_effective_active_position_count(conn, mode),
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
equity=_capital(conn),
|
equity=_capital(conn),
|
||||||
|
mode=mode,
|
||||||
|
get_setting=get_setting,
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -4099,6 +4107,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
conn,
|
conn,
|
||||||
active_count=_effective_active_position_count(conn, mode),
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
equity=capital,
|
equity=capital,
|
||||||
|
mode=mode,
|
||||||
|
get_setting=get_setting,
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
ctp_acc = _ctp_account(mode) if ctp_st.get("connected") else {}
|
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()
|
conn = get_db()
|
||||||
init_strategy_tables(conn)
|
init_strategy_tables(conn)
|
||||||
capital = _capital(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:
|
if err:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({"ok": False, "error": err}), 403
|
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:
|
if int(plan["lots_open"] or 0) > 0:
|
||||||
execute_order(
|
execute_order(
|
||||||
conn, mode=mode, offset="close", symbol=plan["symbol"],
|
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:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
@@ -4787,6 +4801,7 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
execute_order(
|
execute_order(
|
||||||
conn, mode=mode, offset="close", symbol=sym, direction=direction,
|
conn, mode=mode, offset="close", symbol=sym, direction=direction,
|
||||||
lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(),
|
lots=int(plan["lots_open"] or 0), price=price, settings=_settings_dict(),
|
||||||
|
order_type="market", urgency="stop_loss",
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
@@ -4901,6 +4916,8 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
|
|||||||
conn,
|
conn,
|
||||||
active_count=_effective_active_position_count(conn, mode),
|
active_count=_effective_active_position_count(conn, mode),
|
||||||
equity=_capital(conn),
|
equity=_capital(conn),
|
||||||
|
mode=mode,
|
||||||
|
get_setting=get_setting,
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
_notify(False, err, entry=entry, sl=sl, tp=tp, lots=0)
|
_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,
|
notify_fn=send_wechat_msg,
|
||||||
interval=1,
|
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(
|
start_pending_order_worker(
|
||||||
db_path=DB_PATH,
|
db_path=DB_PATH,
|
||||||
get_mode_fn=lambda: get_trading_mode(get_setting),
|
get_mode_fn=lambda: get_trading_mode(get_setting),
|
||||||
|
|||||||
@@ -818,6 +818,12 @@ def _execute_local_close(
|
|||||||
direction = (mon.get("direction") or "long").strip().lower()
|
direction = (mon.get("direction") or "long").strip().lower()
|
||||||
if close_pending_active(sym, direction):
|
if close_pending_active(sym, direction):
|
||||||
return
|
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)
|
positions = ctp_list_positions(mode)
|
||||||
pos = _find_position(positions, sym, direction)
|
pos = _find_position(positions, sym, direction)
|
||||||
if not pos:
|
if not pos:
|
||||||
@@ -849,6 +855,7 @@ def _execute_local_close(
|
|||||||
lots=lots,
|
lots=lots,
|
||||||
price=mark,
|
price=mark,
|
||||||
order_type="market",
|
order_type="market",
|
||||||
|
urgency="stop_loss",
|
||||||
)
|
)
|
||||||
mark_close_pending(sym, direction)
|
mark_close_pending(sym, direction)
|
||||||
_close_all_monitors_for_symbol(conn, sym, direction)
|
_close_all_monitors_for_symbol(conn, sym, direction)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
.trade-action-row .btn-open{padding:.65rem .75rem;font-size:.9rem;width:100%}
|
.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: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-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{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-toggle input{width:auto;margin:0}
|
||||||
.trailing-be-hint{font-size:.72rem;margin:0;color:var(--text-muted)}
|
.trailing-be-hint{font-size:.72rem;margin:0;color:var(--text-muted)}
|
||||||
|
|||||||
@@ -453,9 +453,22 @@
|
|||||||
var dailyRiskLim = lim.daily_trading_risk_pct_limit != null
|
var dailyRiskLim = lim.daily_trading_risk_pct_limit != null
|
||||||
? lim.daily_trading_risk_pct_limit
|
? lim.daily_trading_risk_pct_limit
|
||||||
: st.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) + '%' : '—';
|
var dailyRiskText = dailyRiskUsed != null ? fmtNum(dailyRiskUsed) + '%' : '—';
|
||||||
if (dailyRiskLim != null && dailyRiskUsed != null) {
|
if (dailyRiskLim != null && dailyRiskUsed != null) {
|
||||||
dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%';
|
dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%';
|
||||||
|
if (slipBuf != null) {
|
||||||
|
dailyRiskText += '(+滑点' + fmtNum(slipBuf) + '%';
|
||||||
|
if (dailyRiskCap != null) {
|
||||||
|
dailyRiskText += ',合计≤' + fmtNum(dailyRiskCap) + '%';
|
||||||
|
}
|
||||||
|
dailyRiskText += ')';
|
||||||
|
}
|
||||||
} else if (dailyRiskLim != null) {
|
} else if (dailyRiskLim != null) {
|
||||||
dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%';
|
dailyRiskText += ' / ' + fmtNum(dailyRiskLim) + '%';
|
||||||
}
|
}
|
||||||
@@ -489,7 +502,7 @@
|
|||||||
},
|
},
|
||||||
{ label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') },
|
{ label: '持仓限制', value: active + ' / ' + (maxPos != null ? maxPos : '—') },
|
||||||
{ label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') },
|
{ label: '日持仓限制', value: dailyOpens + ' / ' + (dailyPosLim != null ? dailyPosLim : '—') },
|
||||||
{ label: '日交易风险', value: dailyRiskText },
|
{ label: '日亏损风控', value: dailyRiskText },
|
||||||
{ label: '手动平仓次数', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') },
|
{ label: '手动平仓次数', value: manualCnt + ' / ' + (manualLim != null ? manualLim : '—') },
|
||||||
{
|
{
|
||||||
label: '综合保证金占比',
|
label: '综合保证金占比',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
var lastCtpLoginBanAt = 0;
|
var lastCtpLoginBanAt = 0;
|
||||||
var ctpReconnecting = false;
|
var ctpReconnecting = false;
|
||||||
var ctpConnectInflight = false;
|
var ctpConnectInflight = false;
|
||||||
|
var lastRiskStatus = null;
|
||||||
var isTradingSession = false;
|
var isTradingSession = false;
|
||||||
var hasSlTpMonitoring = false;
|
var hasSlTpMonitoring = false;
|
||||||
var ctpConnected = false;
|
var ctpConnected = false;
|
||||||
@@ -313,6 +314,7 @@
|
|||||||
}
|
}
|
||||||
var riskBadge = document.getElementById('risk-badge');
|
var riskBadge = document.getElementById('risk-badge');
|
||||||
if (riskBadge && data.risk_status) {
|
if (riskBadge && data.risk_status) {
|
||||||
|
lastRiskStatus = data.risk_status;
|
||||||
riskBadge.textContent = data.risk_status.status_label || '';
|
riskBadge.textContent = data.risk_status.status_label || '';
|
||||||
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
|
riskBadge.className = 'badge ' + (data.risk_status.can_trade ? 'profit' : 'loss');
|
||||||
}
|
}
|
||||||
@@ -396,9 +398,13 @@
|
|||||||
function updateSessionUi() {
|
function updateSessionUi() {
|
||||||
var btnOpen = document.getElementById('btn-open');
|
var btnOpen = document.getElementById('btn-open');
|
||||||
var sessionHint = document.getElementById('session-hint');
|
var sessionHint = document.getElementById('session-hint');
|
||||||
|
var canTrade = !lastRiskStatus || lastRiskStatus.can_trade !== false;
|
||||||
if (btnOpen) {
|
if (btnOpen) {
|
||||||
btnOpen.disabled = !isTradingSession;
|
var blocked = !isTradingSession || !canTrade;
|
||||||
|
btnOpen.disabled = blocked;
|
||||||
btnOpen.classList.toggle('btn-session-off', !isTradingSession);
|
btnOpen.classList.toggle('btn-session-off', !isTradingSession);
|
||||||
|
btnOpen.classList.toggle('btn-risk-off', isTradingSession && !canTrade);
|
||||||
|
btnOpen.textContent = (isTradingSession && !canTrade) ? '风控' : '开仓';
|
||||||
}
|
}
|
||||||
if (sessionHint) {
|
if (sessionHint) {
|
||||||
sessionHint.hidden = !!isTradingSession;
|
sessionHint.hidden = !!isTradingSession;
|
||||||
|
|||||||
@@ -253,12 +253,21 @@
|
|||||||
<label>开仓挂单超时(分钟)</label>
|
<label>开仓挂单超时(分钟)</label>
|
||||||
<input name="pending_order_timeout_min" type="number" step="1" min="1" max="60" value="{{ pending_order_timeout_min }}">
|
<input name="pending_order_timeout_min" type="number" step="1" min="1" max="60" value="{{ pending_order_timeout_min }}">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>日亏损强平线(%权益)</label>
|
||||||
|
<input name="daily_loss_force_close_pct" type="number" step="0.1" min="0.1" max="50" value="{{ daily_loss_force_close_pct }}">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>强平滑点预留(%权益)</label>
|
||||||
|
<input name="daily_loss_slippage_buffer_pct" type="number" step="0.1" min="0" max="20" value="{{ daily_loss_slippage_buffer_pct }}">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
|
<button type="submit" class="btn-primary" style="margin-top:.75rem">保存交易设置</button>
|
||||||
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
|
<p class="hint" style="margin-top:.75rem;margin-bottom:0">
|
||||||
单仓保证金上限(默认 30%)用于<strong>新开仓</strong>校验与最大手数估算;综合保证金上限(默认 50%)在单仓模式下为滚仓合计上限、多仓模式下为全部持仓合计上限。固定金额计仓时<strong>先按止损算手数,再按单仓上限收紧</strong>。
|
单仓保证金上限(默认 30%)用于<strong>新开仓</strong>校验与最大手数估算;综合保证金上限(默认 50%)在单仓模式下为滚仓合计上限、多仓模式下为全部持仓合计上限。固定金额计仓时<strong>先按止损算手数,再按单仓上限收紧</strong>。
|
||||||
<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。
|
<strong>移动保本</strong>:达 1R 后止损移至开仓价 ± N 跳。
|
||||||
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。
|
<strong>挂单超时</strong>:限价开仓未成交时,超过设定分钟数自动向柜台撤单(1~60 分钟)。
|
||||||
|
<strong>日亏损强平</strong>:当日亏损(已实现+浮亏)达「强平线」占权益比例时,无条件平掉全部持仓并当日禁止开仓;「滑点预留」为强平执行额外允许的最大亏损占权益比例(默认 2%+1%=3% 合计上限)。
|
||||||
<span class="text-muted">{{ small_account_margin_rec.label }}。</span>
|
<span class="text-muted">{{ small_account_margin_rec.label }}。</span>
|
||||||
CTP 账号与前置在下方「CTP 连接」中配置。
|
CTP 账号与前置在下方「CTP 连接」中配置。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user