Isolate CTP in worker process and improve strategy roll UX.
Split vn.py into qihuo-ctp worker with IPC client bridge, keep CTP connected during breaks with cached account fallback, speed up strategy page loads, and allow off-session breakout roll submissions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+290
-6
@@ -14,9 +14,11 @@ import time
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import ctp_ipc_client
|
||||
from locale_fix import ensure_process_locale
|
||||
|
||||
ensure_process_locale()
|
||||
if ctp_ipc_client.is_worker_role():
|
||||
ensure_process_locale()
|
||||
|
||||
from ctp_settings import live_setting_dict, simnow_setting_dict
|
||||
from ctp_symbol import ths_to_vnpy_symbol, to_vnpy_exchange
|
||||
@@ -34,6 +36,10 @@ CTP_COOLDOWN_UNTIL_KEY = "ctp_login_cooldown_until"
|
||||
CTP_LAST_ERROR_KEY = "ctp_last_error"
|
||||
|
||||
|
||||
def _use_ctp_worker_client() -> bool:
|
||||
return not ctp_ipc_client.is_worker_role()
|
||||
|
||||
|
||||
def _persist_login_cooldown(seconds: float) -> None:
|
||||
from fee_specs import get_setting, set_setting
|
||||
|
||||
@@ -163,7 +169,7 @@ def _fire_position_refresh_callback_debounced(*, min_interval: float = 0.35) ->
|
||||
def _fire_position_refresh_burst() -> None:
|
||||
"""连接后持仓回报可能分批到达,分多次触发快照刷新。"""
|
||||
_fire_position_refresh_callback()
|
||||
for delay in (1.5, 4.0, 10.0, 18.0):
|
||||
for delay in (0.4, 0.9, 1.5, 3.0, 6.0, 12.0, 20.0):
|
||||
threading.Timer(delay, _fire_position_refresh_callback).start()
|
||||
|
||||
|
||||
@@ -183,10 +189,11 @@ def _schedule_after_instruments_ready(bridge: "CtpBridge") -> None:
|
||||
bridge._ensure_instrument_margin_hooks()
|
||||
with _ctp_td_lock:
|
||||
bridge.request_position_snapshot(force=True)
|
||||
time.sleep(2.0)
|
||||
time.sleep(0.8)
|
||||
with _ctp_td_lock:
|
||||
bridge.calibrate_trading_state()
|
||||
_fire_position_refresh_callback()
|
||||
_fire_position_refresh_burst()
|
||||
n = len(bridge._collect_positions())
|
||||
logger.info("CTP 合约加载完成,持仓 %s 条,已刷新快照", n)
|
||||
except Exception as exc:
|
||||
@@ -217,7 +224,7 @@ _bridge: Optional["CtpBridge"] = None
|
||||
_bridge_lock = threading.Lock()
|
||||
_ctp_td_lock = threading.RLock()
|
||||
POSITION_QUERY_MIN_INTERVAL_SEC = 5.0
|
||||
POSITION_QUERY_RETRY_DELAYS_SEC = (22.0, 50.0, 95.0)
|
||||
POSITION_QUERY_RETRY_DELAYS_SEC = (1.5, 4.0, 9.0, 18.0, 35.0)
|
||||
TRADE_QUERY_MIN_INTERVAL_SEC = 10.0
|
||||
|
||||
|
||||
@@ -337,6 +344,7 @@ class CtpBridge:
|
||||
self._trade_query_event = threading.Event()
|
||||
self._last_trade_query_ts: float = 0.0
|
||||
self._last_connect_ok_ts: float = 0.0
|
||||
self._connect_started_ts: float = 0.0
|
||||
self._tick_hooked = False
|
||||
self._position_hooked = False
|
||||
self._order_hooked = False
|
||||
@@ -704,6 +712,16 @@ class CtpBridge:
|
||||
cooldown = self.login_cooldown_remaining()
|
||||
connecting = bool(self._connect_in_progress and cooldown <= 0)
|
||||
last_error = self._last_error or _load_persisted_last_error()
|
||||
if (
|
||||
connecting
|
||||
and self._connect_started_ts > 0
|
||||
and time.time() - self._connect_started_ts > CONNECT_WAIT_SEC + 10
|
||||
and not last_error
|
||||
):
|
||||
last_error = (
|
||||
f"CTP 连接进行中已超过 {CONNECT_WAIT_SEC}s,"
|
||||
"可能前置不可达或柜台响应慢"
|
||||
)
|
||||
return {
|
||||
"vnpy_installed": self.available(),
|
||||
"connected": self._connected_mode == mode,
|
||||
@@ -746,6 +764,7 @@ class CtpBridge:
|
||||
raise ValueError(f"{_mode_label(mode)}:未配置交易服务器地址")
|
||||
|
||||
self._connect_in_progress = True
|
||||
self._connect_started_ts = time.time()
|
||||
try:
|
||||
with _ctp_td_lock:
|
||||
with self._connect_lock:
|
||||
@@ -806,6 +825,10 @@ class CtpBridge:
|
||||
self.calibrate_trading_state()
|
||||
except Exception as exc:
|
||||
logger.debug("post-connect calibrate: %s", exc)
|
||||
try:
|
||||
self.request_position_snapshot(force=True)
|
||||
except Exception as exc:
|
||||
logger.debug("post-connect position query: %s", exc)
|
||||
self._ensure_instrument_margin_hooks()
|
||||
_fire_position_refresh_burst()
|
||||
_schedule_position_query_retries(self)
|
||||
@@ -823,6 +846,7 @@ class CtpBridge:
|
||||
raise RuntimeError(hint)
|
||||
finally:
|
||||
self._connect_in_progress = False
|
||||
self._connect_started_ts = 0.0
|
||||
|
||||
def start_connect_async(
|
||||
self, mode: str, *, force: bool = False, scheduled: bool = False,
|
||||
@@ -859,13 +883,39 @@ class CtpBridge:
|
||||
except Exception as exc:
|
||||
logger.warning("CTP 后台连接失败: %s", exc)
|
||||
|
||||
def _watchdog() -> None:
|
||||
deadline = CONNECT_WAIT_SEC + 25
|
||||
time.sleep(deadline)
|
||||
if not self._connect_in_progress:
|
||||
return
|
||||
logger.warning(
|
||||
"CTP 连接 watchdog 超时 %.0fs,重置连接状态 [%s]",
|
||||
deadline,
|
||||
mode,
|
||||
)
|
||||
self._connect_in_progress = False
|
||||
self._connect_started_ts = 0.0
|
||||
hint = (
|
||||
f"CTP 连接超时(>{deadline:.0f}s),可能前置不可达或柜台无响应。"
|
||||
"请检查 SimNow 前置地址与账号,勿频繁重试。"
|
||||
)
|
||||
self._last_error = hint
|
||||
_persist_last_error(hint)
|
||||
try:
|
||||
self._close_gateway()
|
||||
except Exception as exc:
|
||||
logger.debug("watchdog gateway close: %s", exc)
|
||||
|
||||
threading.Thread(target=_run, daemon=True, name="ctp-connect-async").start()
|
||||
threading.Thread(target=_watchdog, daemon=True, name="ctp-connect-watchdog").start()
|
||||
return {"started": True, "connecting": True, "connected": False}
|
||||
|
||||
def ensure_connected(self, mode: str) -> None:
|
||||
if self._connected_mode == mode and self.ping():
|
||||
return
|
||||
self.connect(mode)
|
||||
if self._connect_in_progress:
|
||||
raise RuntimeError("CTP 连接中,请稍候")
|
||||
raise RuntimeError("请先连接 CTP")
|
||||
|
||||
def require_connected(self, mode: str) -> None:
|
||||
"""报单前检查:须已连接,不在此发起阻塞式 connect。"""
|
||||
@@ -2125,8 +2175,170 @@ class CtpBridge:
|
||||
return False
|
||||
|
||||
|
||||
def get_bridge() -> CtpBridge:
|
||||
class CtpBridgeProxy:
|
||||
"""Client-side stand-in for CtpBridge, forwarding calls to qihuo-ctp."""
|
||||
|
||||
_engine = None
|
||||
|
||||
@property
|
||||
def connected_mode(self) -> Optional[str]:
|
||||
st = ctp_ipc_client.health().get("status") or {}
|
||||
return st.get("connected_mode")
|
||||
|
||||
@property
|
||||
def last_error(self) -> str:
|
||||
st = ctp_ipc_client.health().get("status") or {}
|
||||
return str(st.get("last_error") or "")
|
||||
|
||||
@property
|
||||
def _last_connect_ok_ts(self) -> float:
|
||||
st = ctp_ipc_client.health().get("status") or {}
|
||||
try:
|
||||
return float(st.get("last_connect_ok_ts") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def available(self) -> bool:
|
||||
return bool(ctp_ipc_client.health().get("worker_online"))
|
||||
|
||||
def status(self, mode: str) -> dict[str, Any]:
|
||||
return ctp_ipc_client.status(mode)
|
||||
|
||||
def ping(self) -> bool:
|
||||
return bool(ctp_ipc_client.health().get("worker_online"))
|
||||
|
||||
def connect(self, mode: str, *, force: bool = False) -> dict[str, Any]:
|
||||
return ctp_ipc_client.connect(mode, force=force)
|
||||
|
||||
def start_connect_async(
|
||||
self,
|
||||
mode: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
scheduled: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
return ctp_ipc_client.start_connect(mode, force=force, scheduled=scheduled)
|
||||
|
||||
def connect_in_progress(self) -> bool:
|
||||
data = ctp_ipc_client.bridge_action("connect_in_progress")
|
||||
return bool(data.get("result"))
|
||||
|
||||
def login_cooldown_remaining(self) -> int:
|
||||
st = ctp_ipc_client.health().get("status") or {}
|
||||
try:
|
||||
return int(st.get("login_cooldown_sec") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def ensure_connected(self, mode: str) -> None:
|
||||
if not self.status(mode).get("connected"):
|
||||
raise RuntimeError("CTP worker 未连接,请重连后再操作")
|
||||
|
||||
def require_connected(self, mode: str) -> None:
|
||||
self.ensure_connected(mode)
|
||||
|
||||
def get_account(self) -> dict[str, Any]:
|
||||
mode = self.connected_mode or "simulation"
|
||||
return ctp_ipc_client.account(mode)
|
||||
|
||||
def list_positions(
|
||||
self,
|
||||
*,
|
||||
refresh_if_empty: bool = True,
|
||||
refresh_margin: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
mode = self.connected_mode or "simulation"
|
||||
return ctp_ipc_client.positions(
|
||||
mode,
|
||||
refresh_if_empty=refresh_if_empty,
|
||||
refresh_margin=refresh_margin,
|
||||
)
|
||||
|
||||
def list_active_orders(self) -> list[dict[str, Any]]:
|
||||
mode = self.connected_mode or "simulation"
|
||||
return ctp_ipc_client.active_orders(mode)
|
||||
|
||||
def list_trades(self, *, refresh: bool = False) -> list[dict[str, Any]]:
|
||||
mode = self.connected_mode or "simulation"
|
||||
return ctp_ipc_client.trades(mode, refresh=refresh)
|
||||
|
||||
def get_tick_price(self, ths_code: str, *, mode: str = "") -> Optional[float]:
|
||||
return ctp_ipc_client.tick_price(mode or self.connected_mode or "simulation", ths_code)
|
||||
|
||||
def get_tick_detail(self, ths_code: str, *, mode: str = "") -> dict[str, Any]:
|
||||
return ctp_ipc_client.tick_detail(mode or self.connected_mode or "simulation", ths_code)
|
||||
|
||||
def estimate_margin_one_lot(
|
||||
self,
|
||||
ths_code: str,
|
||||
price: float,
|
||||
*,
|
||||
direction: str = "long",
|
||||
) -> Optional[float]:
|
||||
return ctp_ipc_client.estimate_margin_one_lot(
|
||||
self.connected_mode or "simulation",
|
||||
ths_code,
|
||||
price,
|
||||
direction=direction,
|
||||
)
|
||||
|
||||
def lookup_contract_spec(self, ths_code: str) -> Optional[dict]:
|
||||
return ctp_ipc_client.contract_spec(self.connected_mode or "simulation", ths_code)
|
||||
|
||||
def send_order(self, **payload: Any) -> str:
|
||||
data = ctp_ipc_client.send_order(payload)
|
||||
return str(data.get("order_id") or "")
|
||||
|
||||
def cancel_order(self, vt_orderid: str) -> bool:
|
||||
return ctp_ipc_client.cancel_order(self.connected_mode or "simulation", vt_orderid)
|
||||
|
||||
def calibrate_trading_state(self) -> Any:
|
||||
return ctp_ipc_client.bridge_action("calibrate_trading_state").get("result")
|
||||
|
||||
def request_position_snapshot(self, *, force: bool = False) -> Any:
|
||||
return ctp_ipc_client.bridge_action(
|
||||
"request_position_snapshot",
|
||||
{"force": bool(force)},
|
||||
).get("result")
|
||||
|
||||
def subscribe_symbol(self, symbol: str) -> Any:
|
||||
return ctp_ipc_client.bridge_action("subscribe_symbol", {"symbol": symbol}).get("result")
|
||||
|
||||
def refresh_positions(self) -> Any:
|
||||
return ctp_ipc_client.bridge_action("refresh_positions").get("result")
|
||||
|
||||
def reconnect_after_settings_saved(self, mode: str) -> Any:
|
||||
return ctp_ipc_client.bridge_action(
|
||||
"reconnect_after_settings_saved",
|
||||
{"mode": mode},
|
||||
).get("result")
|
||||
|
||||
def query_all_commissions(self, *, mode: str = "") -> list[dict]:
|
||||
data = ctp_ipc_client.bridge_action("query_all_commissions", {"mode": mode})
|
||||
return list(data.get("result") or [])
|
||||
|
||||
def query_instrument_commission(self, symbol: str, *, mode: str = "") -> dict:
|
||||
data = ctp_ipc_client.bridge_action(
|
||||
"query_instrument_commission",
|
||||
{"symbol": symbol, "mode": mode or self.connected_mode or "simulation"},
|
||||
)
|
||||
return dict(data.get("result") or {})
|
||||
|
||||
def get_kline_bars_1m(self, ths_code: str, *, mode: str) -> list[dict]:
|
||||
data = ctp_ipc_client.bridge_action(
|
||||
"get_kline_bars_1m",
|
||||
{"symbol": ths_code, "mode": mode},
|
||||
)
|
||||
return list(data.get("result") or [])
|
||||
|
||||
def _close_gateway(self) -> None:
|
||||
ctp_ipc_client.disconnect()
|
||||
|
||||
|
||||
def get_bridge():
|
||||
global _bridge
|
||||
if _use_ctp_worker_client():
|
||||
return CtpBridgeProxy()
|
||||
with _bridge_lock:
|
||||
if _bridge is None:
|
||||
_bridge = CtpBridge()
|
||||
@@ -2134,10 +2346,14 @@ def get_bridge() -> CtpBridge:
|
||||
|
||||
|
||||
def try_init_vnpy(_settings: dict | None = None) -> bool:
|
||||
if _use_ctp_worker_client():
|
||||
return bool(ctp_ipc_client.health().get("worker_online"))
|
||||
return get_bridge().available()
|
||||
|
||||
|
||||
def vnpy_available() -> bool:
|
||||
if _use_ctp_worker_client():
|
||||
return bool(ctp_ipc_client.health().get("worker_online"))
|
||||
return get_bridge().available()
|
||||
|
||||
|
||||
@@ -2156,6 +2372,9 @@ def _ctp_connect_permitted(*, scheduled: bool = False) -> bool:
|
||||
|
||||
def ctp_disconnect(*, set_disabled_hint: bool = False) -> None:
|
||||
"""主动断开 CTP 并清理内存状态。"""
|
||||
if _use_ctp_worker_client():
|
||||
ctp_ipc_client.disconnect(set_disabled_hint=set_disabled_hint)
|
||||
return
|
||||
from ctp_settings import CTP_DISABLED_HINT
|
||||
|
||||
b = get_bridge()
|
||||
@@ -2169,6 +2388,8 @@ def ctp_disconnect(*, set_disabled_hint: bool = False) -> None:
|
||||
|
||||
|
||||
def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.connect(mode, force=force)
|
||||
b = get_bridge()
|
||||
b.connect(mode, force=force)
|
||||
return b.status(mode)
|
||||
@@ -2176,6 +2397,8 @@ def ctp_connect(mode: str, *, force: bool = False) -> dict[str, Any]:
|
||||
|
||||
def ctp_start_connect(mode: str, *, force: bool = False, scheduled: bool = False) -> dict[str, Any]:
|
||||
"""非阻塞发起连接,供 Web API 使用。"""
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.start_connect(mode, force=force, scheduled=scheduled)
|
||||
b = get_bridge()
|
||||
info = b.start_connect_async(mode, force=force, scheduled=scheduled)
|
||||
st = b.status(mode)
|
||||
@@ -2184,6 +2407,13 @@ def ctp_start_connect(mode: str, *, force: bool = False, scheduled: bool = False
|
||||
|
||||
def ctp_try_auto_reconnect(mode: str) -> bool:
|
||||
"""断线时静默异步重连;已连接且交易通道正常则不再重复 connect。"""
|
||||
if _use_ctp_worker_client():
|
||||
info = ctp_ipc_client.start_connect(mode, force=False, scheduled=True)
|
||||
return bool(
|
||||
info.get("connected")
|
||||
or info.get("connecting")
|
||||
or info.get("started")
|
||||
)
|
||||
if not _ctp_connect_permitted(scheduled=True):
|
||||
return False
|
||||
b = get_bridge()
|
||||
@@ -2222,6 +2452,10 @@ def ctp_try_auto_reconnect(mode: str) -> bool:
|
||||
def ctp_status(mode: str) -> dict[str, Any]:
|
||||
from ctp_settings import CTP_DISABLED_HINT, is_ctp_auto_connect_enabled
|
||||
|
||||
if _use_ctp_worker_client():
|
||||
st = ctp_ipc_client.status(mode)
|
||||
st["auto_connect_enabled"] = is_ctp_auto_connect_enabled()
|
||||
return st
|
||||
auto = is_ctp_auto_connect_enabled()
|
||||
st = get_bridge().status(mode)
|
||||
st["auto_connect_enabled"] = auto
|
||||
@@ -2245,6 +2479,8 @@ def ctp_status(mode: str) -> dict[str, Any]:
|
||||
|
||||
|
||||
def ctp_get_account(mode: str) -> dict[str, Any]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.account(mode)
|
||||
b = get_bridge()
|
||||
b.ensure_connected(mode)
|
||||
return b.get_account()
|
||||
@@ -2269,6 +2505,18 @@ def ctp_sum_position_margins(
|
||||
|
||||
def ctp_account_margin_used(mode: str) -> Optional[float]:
|
||||
"""账户实际占用保证金 ≈ 权益 − 可用(与顶栏柜台资金一致)。"""
|
||||
if _use_ctp_worker_client():
|
||||
try:
|
||||
acc = ctp_ipc_client.account(mode)
|
||||
balance = float(acc.get("balance") or 0)
|
||||
available = float(acc.get("available") or 0)
|
||||
if balance <= 0:
|
||||
return None
|
||||
used = balance - available
|
||||
return round(used, 2) if used > 0 else None
|
||||
except Exception as exc:
|
||||
logger.debug("ctp_account_margin_used ipc: %s", exc)
|
||||
return None
|
||||
b = get_bridge()
|
||||
if b.connected_mode != mode or not b.ping():
|
||||
return None
|
||||
@@ -2291,6 +2539,12 @@ def ctp_list_positions(
|
||||
refresh_if_empty: bool = True,
|
||||
refresh_margin: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.positions(
|
||||
mode,
|
||||
refresh_if_empty=refresh_if_empty,
|
||||
refresh_margin=refresh_margin,
|
||||
)
|
||||
b = get_bridge()
|
||||
if b.connected_mode != mode or not b.ping():
|
||||
return []
|
||||
@@ -2298,18 +2552,24 @@ def ctp_list_positions(
|
||||
|
||||
|
||||
def ctp_list_active_orders(mode: str) -> list[dict[str, Any]]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.active_orders(mode)
|
||||
b = get_bridge()
|
||||
b.ensure_connected(mode)
|
||||
return b.list_active_orders()
|
||||
|
||||
|
||||
def ctp_cancel_order(mode: str, vt_orderid: str) -> bool:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.cancel_order(mode, vt_orderid)
|
||||
b = get_bridge()
|
||||
b.ensure_connected(mode)
|
||||
return b.cancel_order(vt_orderid)
|
||||
|
||||
|
||||
def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.trades(mode, refresh=refresh)
|
||||
b = get_bridge()
|
||||
if b.connected_mode != mode or not b.ping():
|
||||
return []
|
||||
@@ -2318,6 +2578,8 @@ def ctp_list_trades(mode: str, *, refresh: bool = False) -> list[dict[str, Any]]
|
||||
|
||||
def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
|
||||
"""CTP 柜台最新价(需已连接并订阅)。"""
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.tick_price(mode, ths_code)
|
||||
b = get_bridge()
|
||||
if b.connected_mode != mode:
|
||||
return None
|
||||
@@ -2329,6 +2591,8 @@ def ctp_get_tick_price(mode: str, ths_code: str) -> Optional[float]:
|
||||
|
||||
|
||||
def ctp_get_tick_detail(mode: str, ths_code: str) -> dict[str, Any]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.tick_detail(mode, ths_code)
|
||||
b = get_bridge()
|
||||
if b.connected_mode != mode:
|
||||
return {}
|
||||
@@ -2346,6 +2610,13 @@ def ctp_estimate_margin_one_lot(
|
||||
*,
|
||||
direction: str = "long",
|
||||
) -> Optional[float]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.estimate_margin_one_lot(
|
||||
mode,
|
||||
ths_code,
|
||||
price,
|
||||
direction=direction,
|
||||
)
|
||||
b = get_bridge()
|
||||
if b.connected_mode != mode or not b.ping():
|
||||
return None
|
||||
@@ -2357,6 +2628,8 @@ def ctp_estimate_margin_one_lot(
|
||||
|
||||
|
||||
def ctp_lookup_contract_spec(mode: str, ths_code: str) -> Optional[dict]:
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.contract_spec(mode, ths_code)
|
||||
b = get_bridge()
|
||||
if b.connected_mode != mode or not b.ping():
|
||||
return None
|
||||
@@ -2390,6 +2663,17 @@ def execute_order(
|
||||
order_type: str = "limit",
|
||||
) -> dict[str, Any]:
|
||||
"""统一下单:simulation=SimNow,live=期货公司 CTP。"""
|
||||
if _use_ctp_worker_client():
|
||||
return ctp_ipc_client.send_order({
|
||||
"mode": mode,
|
||||
"offset": offset,
|
||||
"symbol": symbol,
|
||||
"direction": direction,
|
||||
"lots": lots,
|
||||
"price": price,
|
||||
"settings": settings or {},
|
||||
"order_type": order_type,
|
||||
})
|
||||
del conn, settings
|
||||
if mode not in ("simulation", "live"):
|
||||
raise ValueError("未知交易模式")
|
||||
|
||||
Reference in New Issue
Block a user