from __future__ import annotations

import argparse
import json
import os
import signal
import threading
import time
from decimal import Decimal, ROUND_DOWN
from typing import Any

from eth_account import Account
from hyperliquid.exchange import Exchange

from hl1m_cli.copytrade._shared.events import FillEvent, UserEvent
from hl1m_cli.shared.secrets import init_env_file as init_state_env_file
from hl1m_cli.copytrade.copy.wallet import (
    resolve_copytrade_plain_private_key_from_env_file,
    resolve_copytrade_wallet_address_from_env_file,
)
from hl1m_cli.copytrade._shared.notifiers import send_message
from hl1m_cli.copytrade._shared.ws_subscriber import HyperliquidWsSubscriber, WsConfig
from hl1m_cli.copytrade._shared.hyperliquid_constants import DEFAULT_HTTP_BASE_URL
from hl1m_cli.copytrade._shared.hyperliquid_constants import DEFAULT_WS_URL
from hl1m_cli.copytrade._shared.hyperliquid_constants import MIN_ORDER_NOTIONAL_USD
from hl1m_cli.copytrade.copy.risk import RiskProfile
from hl1m_cli.copytrade._shared.sdk_info_client import HyperliquidInfoClient
from hl1m_cli.copytrade._shared.message_format import (
    format_common_status,
    format_copy_exec_error_message,
    format_copy_close_alert,
    format_copy_leader_liquidation_alert,
    format_copy_liquidation_warning,
    format_copy_max_notional_skip_alert,
    format_copy_min_notional_skip_alert,
    format_copy_open_alert,
    format_copy_started_status,
    format_copy_tp_sl_trigger,
    normalize_language,
)


def _f(v: Any) -> float:
    try:
        return float(v)
    except Exception:
        return 0.0


def _classify_leader_fill(dir_text: str, start_position: float, sz: float) -> str | None:
    """
    根据 leader userFills 的 dir + startPosition + sz 判断开/平/加/减仓。
    返回：
    - "OPEN_NEW" / "OPEN_ADD" / "REDUCE" / "CLOSE"
    - None：不符合预期形态（忽略）
    """
    d = str(dir_text or "").strip().lower()
    sp = float(start_position)
    s = abs(float(sz))
    eps = 1e-9

    if "open long" in d:
        if abs(sp) <= eps:
            return "OPEN_NEW"
        if sp > 0:
            return "OPEN_ADD"
        return None

    if "open short" in d:
        if abs(sp) <= eps:
            return "OPEN_NEW"
        if sp < 0:
            return "OPEN_ADD"
        return None

    if "close long" in d:
        if sp <= 0:
            return None
        if s + eps < sp:
            return "REDUCE"
        if abs(s - sp) <= eps:
            return "CLOSE"
        return None

    if "close short" in d:
        if sp >= 0:
            return None
        sp_abs = abs(sp)
        if s + eps < sp_abs:
            return "REDUCE"
        if abs(s - sp_abs) <= eps:
            return "CLOSE"
        return None

    return None


def _is_buy_from_dir(dir_text: str) -> bool | None:
    d = str(dir_text or "").strip().lower()
    if "open long" in d:
        return True
    if "open short" in d:
        return False
    if "close long" in d:
        return False
    if "close short" in d:
        return True
    return None


def _dex_for_coin(coin: str) -> str:
    return "xyz" if str(coin).startswith("xyz:") else ""


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(description="1m-trade Hyperliquid copy (WS userFills, real execution)")
    p.add_argument("--leader", type=str, default=None, help="leader address to follow")
    p.add_argument("--stop", action="store_true", help="stop running copy process (no leader needed)")
    p.add_argument("--scale", type=float, default=1.0, help="size scale factor")
    p.add_argument("--max-notional-usd", type=float, default=None, help="drop trades above this notional")
    p.add_argument("--max-leverage", type=float, default=None, help="cap copied leverage (best-effort)")
    p.add_argument("--slippage", type=float, default=0.05, help="slippage for market_open/market_close (default 0.05)")
    p.add_argument(
        "--coins",
        default="",
        help="allowed coins list (comma-separated), e.g. BTC,HYPE or xyz:CL",
    )
    p.add_argument("--take-profit-pct", type=float, default=None, help="TP threshold percent (based on closedPnl/notional); omit to disable")
    p.add_argument("--stop-loss-pct", type=float, default=None, help="SL threshold percent (based on closedPnl/notional)")
    p.add_argument("--target", default=None, help="openclaw message target (required)")
    p.add_argument("--language", default="zh", help="message language: zh/en (default: zh)")
    return p


def _parse_coin_list(raw: Any) -> list[str]:
    if raw is None:
        return []
    if isinstance(raw, list):
        return [str(x).strip() for x in raw if str(x).strip()]
    if isinstance(raw, str):
        s = raw.strip()
        if not s:
            return []
        return [p.strip() for p in s.split(",") if p.strip()]
    return []

def _runtime_dir() -> str:
    # Runtime files are only for local process coordination/param updates; do not commit.
    d = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "runtime"))
    os.makedirs(d, exist_ok=True)
    return d


def _copy_params_path() -> str:
    return os.path.join(_runtime_dir(), "copy_params.json")


def _copy_pid_path() -> str:
    return os.path.join(_runtime_dir(), "copy.pid")


def run(args: argparse.Namespace) -> int:

    # stop action: no leader needed
    if bool(getattr(args, "stop", False)):
        if not args.target:
            raise SystemExit("copy stop requires --target")
        language = normalize_language(getattr(args, "language", None))
        pid_path = _copy_pid_path()
        stopped_any = False
        try:
            if os.path.exists(pid_path):
                with open(pid_path, "r", encoding="utf-8") as f:
                    pid_raw = f.read().strip()
                pid = int(pid_raw) if pid_raw else None
                if pid:
                    os.kill(pid, signal.SIGTERM)
                    stopped_any = True
        except Exception:
            pass
        stop_msg = f"[copy] stopped; stopped_any={stopped_any}" if language == "en" else f"[copy] 已停止; stopped_any={stopped_any}"
        send_message(args.target, stop_msg)
        return 0

    leader = (str(args.leader).strip() if args.leader is not None else None)
    target = (str(args.target).strip() if args.target is not None else None)
    risk = RiskProfile(
        scale=float(args.scale),
        max_notional_usd=(float(args.max_notional_usd) if args.max_notional_usd is not None else None),
        max_leverage=(float(args.max_leverage) if args.max_leverage is not None else None),
    )
    ws_url = DEFAULT_WS_URL
    slippage = float(args.slippage)
    base_url = DEFAULT_HTTP_BASE_URL
    allowed_coins = _parse_coin_list(args.coins)
    language = normalize_language(args.language)
    take_profit_pct = float(args.take_profit_pct) if args.take_profit_pct is not None else None
    stop_loss_pct = float(args.stop_loss_pct) if args.stop_loss_pct is not None else None

    if not target:
        raise SystemExit("copy requires --target")
    if not leader:
        raise SystemExit("copy requires --leader")

    # Resolve encrypted private key from shared OpenClaw state `.env` (same as `trade` module).
    env = init_state_env_file()
    pk = resolve_copytrade_plain_private_key_from_env_file(env)
    wallet = Account.from_key(pk)
    account_address = resolve_copytrade_wallet_address_from_env_file(env, derived=getattr(wallet, "address", None))
    exchange = Exchange(wallet=wallet, base_url=base_url, account_address=account_address, perp_dexs=["", "xyz"])
    info_client = HyperliquidInfoClient(base_url=base_url)
    user_addr = str(account_address or getattr(wallet, "address", None) or str(wallet))

    def _safe_float(v: Any) -> float | None:
        try:
            return float(v)
        except Exception:
            return None

    def _get_balance_usdc_from_user_state(state: dict[str, Any] | None) -> float | None:
        if not isinstance(state, dict):
            return None
        # prefer withdrawable, fallback to marginSummary.accountValue
        w = _safe_float(state.get("withdrawable"))
        if w is not None:
            return w
        ms = state.get("marginSummary") or {}
        if isinstance(ms, dict):
            return _safe_float(ms.get("accountValue"))
        return None

    def _position_detail_for_coin(coin: str) -> tuple[float | None, float | None, float | None, bool | None]:
        """
        Return (entry_px, position_notional_usd, unrealized_pnl_usd, is_long) from cached_user_state if possible.
        """
        st = cached_user_state
        if not isinstance(st, dict):
            return None, None, None, None
        aps = st.get("assetPositions")
        if not isinstance(aps, list):
            return None, None, None, None
        for item in aps:
            if not isinstance(item, dict):
                continue
            pos = item.get("position")
            if not isinstance(pos, dict):
                continue
            if str(pos.get("coin", "") or "").strip() != str(coin):
                continue
            szi = _safe_float(pos.get("szi"))
            entry_px = _safe_float(pos.get("entryPx"))
            upnl = _safe_float(pos.get("unrealizedPnl"))
            if szi is None or entry_px is None:
                return None, None, upnl, None
            notional = abs(float(szi) * float(entry_px))
            is_long = bool(float(szi) > 0)
            return entry_px, notional, upnl, is_long
        return None, None, None, None

    def _quantize_sz_for_coin(coin: str, sz: float) -> float:
        """
        Hyperliquid SDK 的 float_to_wire 不允许发生 rounding。
        这里按 meta 的 szDecimals 对数量做截断量化，避免触发 rounding 异常。
        """
        try:
            # Ensure meta is loaded
            _ = exchange.info.meta()
        except Exception:
            pass
        try:
            asset = exchange.info.coin_to_asset[str(coin)]
            decimals = int(exchange.info.asset_to_sz_decimals[int(asset)])
        except Exception:
            # best-effort fallback: do not change
            return float(sz)

        if decimals < 0:
            return float(sz)
        q = Decimal("1") if decimals == 0 else (Decimal(10) ** (-decimals))
        d = Decimal(str(sz)).copy_abs()
        d2 = d.quantize(q, rounding=ROUND_DOWN)
        return float(d2)

    # 运行时可更新参数（watcher 线程会更新这些值；on_fill 读取它们）
    params_lock = threading.Lock()
    params: dict[str, Any] = {
        "risk": risk,
        "allowed_coins": allowed_coins,
        "take_profit_pct": take_profit_pct,
        "stop_loss_pct": stop_loss_pct,
    }

    def on_status(msg: str) -> None:
        send_message(target, format_common_status(language, msg))

    # Cooldown: after TP/SL triggers, wait for leader's next OPEN to resume following this coin.
    # key: coin
    cooldown_coins: set[str] = set()

    # 初次启动时：如果 leader 已有仓位，只等下次新开仓（OPEN_NEW）才开始跟单该币种
    follow_enabled: set[str] = set()

    # allMids cache (for TP/SL)
    mids_lock = threading.Lock()
    latest_mids: dict[tuple[str, str], float] = {}

    # user_state cache (for TP/SL entry + liquidation warnings)
    pos_cache_lock = threading.Lock()
    last_pos_ts: float = 0.0
    cached_positions_default: dict[str, tuple[float, float, float]] | None = None  # coin -> (szi, entry_px, liq_px)
    cached_positions_xyz: dict[str, tuple[float, float, float]] | None = None
    pos_cache_ttl_seconds = 8.0

    # user_state cache：避免每条 fill 都打 HTTP
    state_cache_lock = threading.Lock()
    last_state_ts = 0.0
    cached_leader_state: dict[str, Any] | None = None
    cached_user_state: dict[str, Any] | None = None
    state_cache_ttl_seconds = 15.0

    def _safe_float(v: Any) -> float | None:
        try:
            return float(v)
        except Exception:
            return None

    def _extract_positions(state: dict[str, Any]) -> dict[str, tuple[float, float, float]]:
        out: dict[str, tuple[float, float, float]] = {}
        aps = state.get("assetPositions")
        if not isinstance(aps, list):
            return out
        for item in aps:
            if not isinstance(item, dict):
                continue
            pos = item.get("position")
            if not isinstance(pos, dict):
                continue
            coin = str(pos.get("coin", "") or "").strip()
            if not coin:
                continue
            szi = _safe_float(pos.get("szi"))
            entry_px = _safe_float(pos.get("entryPx"))
            liq_px = _safe_float(pos.get("liquidationPx"))
            if szi is None or entry_px is None:
                continue
            if abs(szi) <= 1e-12 or entry_px <= 0:
                continue
            # liq_px 可能缺失：只用于预警；没取到则返回 NaN 让预警逻辑忽略
            liq_px_v = float(liq_px) if liq_px is not None else 0.0
            out[coin] = (float(szi), float(entry_px), liq_px_v)
        return out

    def _get_positions_by_dex(dex: str) -> dict[str, tuple[float, float, float]]:
        nonlocal last_pos_ts, cached_positions_default, cached_positions_xyz
        now = time.time()
        with pos_cache_lock:
            if (now - last_pos_ts) <= pos_cache_ttl_seconds:
                if dex == "xyz" and cached_positions_xyz is not None:
                    return cached_positions_xyz
                if dex != "xyz" and cached_positions_default is not None:
                    return cached_positions_default

            try:
                default_state = info_client.user_state_no_dex(user_addr)
            except Exception:
                default_state = {}
            try:
                xyz_state = info_client.user_state(user_addr, dex="xyz")
            except Exception:
                xyz_state = {}

            cached_positions_default = _extract_positions(default_state) if isinstance(default_state, dict) else {}
            cached_positions_xyz = _extract_positions(xyz_state) if isinstance(xyz_state, dict) else {}
            last_pos_ts = now

            if dex == "xyz":
                return cached_positions_xyz
            return cached_positions_default

    def _get_margin_ratio() -> float:
        nonlocal last_state_ts, cached_leader_state, cached_user_state
        now = time.time()
        with state_cache_lock:
            if (
                cached_leader_state is None
                or cached_user_state is None
                or (now - last_state_ts) > state_cache_ttl_seconds
            ):
                try:
                    cached_leader_state = info_client.user_state_no_dex(leader)
                except Exception:
                    cached_leader_state = None
                try:
                    cached_user_state = info_client.user_state_no_dex(user_addr)
                except Exception:
                    cached_user_state = None
                last_state_ts = now

        # 默认：如果拉不到状态，就不做资金/保证金换算
        if not cached_leader_state or not cached_user_state:
            return 1.0

        # 用户确认：使用 marginSummary.accountValue 口径
        leader_ms = cached_leader_state.get("marginSummary") or {}
        user_ms = cached_user_state.get("marginSummary") or {}
        leader_account_value = _safe_float(leader_ms.get("accountValue"))
        user_account_value = _safe_float(user_ms.get("accountValue"))
        if leader_account_value is None or leader_account_value <= 0:
            return 1.0
        if user_account_value is None:
            return 1.0
        return user_account_value / leader_account_value

    def _refresh_states(*, force: bool = False) -> None:
        """
        Best-effort refresh cached leader/user state for formatting alerts.
        """

        nonlocal last_state_ts, cached_leader_state, cached_user_state
        now = time.time()
        with state_cache_lock:
            should = (
                bool(force)
                or cached_leader_state is None
                or cached_user_state is None
                or (now - last_state_ts) > state_cache_ttl_seconds
            )
        if not should:
            return
        try:
            st_leader = info_client.user_state_no_dex(leader)
        except Exception:
            st_leader = None
        try:
            st_user = info_client.user_state_no_dex(user_addr)
        except Exception:
            st_user = None
        with state_cache_lock:
            cached_leader_state = st_leader if isinstance(st_leader, dict) else None
            cached_user_state = st_user if isinstance(st_user, dict) else None
            last_state_ts = now

    cached_leader_state_xyz: dict[str, Any] | None = None
    xyz_abstraction_enabled: bool = False

    def _leader_position_for_coin(coin: str) -> dict[str, Any] | None:
        """
        leader 仓位：
        - 默认 dex=""：使用 cached_leader_state（user_state_no_dex）
        - xyz dex：需要单独 user_state(leader, dex="xyz") 才能拿到 xyz:xxx 的仓位
        """
        nonlocal last_state_ts, cached_leader_state_xyz
        dex = _dex_for_coin(coin)
        now = time.time()

        if dex == "xyz":
            with state_cache_lock:
                should_refresh = cached_leader_state_xyz is None or (now - last_state_ts) > state_cache_ttl_seconds
            if should_refresh:
                try:
                    st_xyz = info_client.user_state(leader, dex="xyz")
                except Exception:
                    st_xyz = None
                with state_cache_lock:
                    cached_leader_state_xyz = st_xyz if isinstance(st_xyz, dict) else None
                    last_state_ts = now
            with state_cache_lock:
                st = cached_leader_state_xyz
        else:
            with state_cache_lock:
                st = cached_leader_state

        if not isinstance(st, dict):
            return None
        aps = st.get("assetPositions")
        if not isinstance(aps, list):
            return None
        for item in aps:
            if not isinstance(item, dict):
                continue
            pos = item.get("position")
            if not isinstance(pos, dict):
                continue
            if str(pos.get("coin", "")).strip() == str(coin).strip():
                return pos
        return None

    # liquidation warning (user-side) and leader liquidation notification
    liq_warn_enabled = True
    liq_warn_lock = threading.Lock()
    # (dex, coin) -> level: 0 none, 1 sent@10%, 2 sent@5%, 3 sent@1%
    liq_warn_level: dict[tuple[str, str], int] = {}
    # leader liquidation: dedupe by best-effort timestamp key
    leader_liq_lock = threading.Lock()
    leader_liq_seen: set[str] = set()

    # initial: only subscribe allMids if user already has positions
    try:
        liq_warn_should_subscribe = bool(_get_positions_by_dex("") or _get_positions_by_dex("xyz"))
    except Exception:
        liq_warn_should_subscribe = False
    liq_warn_subscribed = False
    process_start_ms = int(time.time() * 1000)

    def _maybe_send_leader_liquidation_alert_from_fill(ev: FillEvent) -> bool:
        """
        Detect leader liquidation via wsFills.fill.liquidation (FillLiquidation).

        Only treat it as leader liquidation when:
        - fill.liquidation exists
        - liquidation.liquidatedUser == leader (case-insensitive)

        Returns True if an alert was sent (or already deduped), so caller may stop further processing.
        """

        if not isinstance(ev.raw, dict):
            return False
        liq_fill = (ev.raw or {}).get("liquidation")
        if not isinstance(liq_fill, dict):
            return False

        liq_user = str(liq_fill.get("liquidatedUser") or "").strip()
        if not liq_user:
            return False
        if liq_user.lower() != str(leader).strip().lower():
            return False

        # dedupe by hash/tid/time (best-effort)
        liq_key = str((ev.raw or {}).get("hash") or ev.tid or ev.time_ms or "no_key")
        with leader_liq_lock:
            if liq_key in leader_liq_seen:
                return True
            leader_liq_seen.add(liq_key)

        mark_px = _safe_float(liq_fill.get("markPx"))

        # Refresh account balance (copy account)
        try:
            _refresh_states(force=True)
        except Exception:
            pass
        bal_usdc = _get_balance_usdc_from_user_state(cached_user_state)

        msg = format_copy_leader_liquidation_alert(
            lang=str(language),
            leader=str(liq_user),
            pair=str(ev.coin),
            dex=("xyz" if str(ev.coin).startswith("xyz:") else "default"),
            balance_usdc=bal_usdc,
            sz=float(abs(float(ev.sz))),
            liquidation_px=mark_px,
            pnl_usd=float(getattr(ev, "closed_pnl", 0.0) or 0.0),
        )
        send_message(target, msg)
        return True

    def on_fill(ev: FillEvent) -> None:
        nonlocal liq_warn_subscribed
        print("======== fill received ========")
        print(f"on_fill: {ev}")
        if str(ev.user).strip().lower() != str(leader).strip().lower():
            print(f"|- user mismatch: {ev.user} != {leader}")
            return

        if _maybe_send_leader_liquidation_alert_from_fill(ev):
            return

        with params_lock:
            risk_now: RiskProfile = params["risk"]
            allowed_coins_now: list[str] = params["allowed_coins"]
            take_profit_pct_now = params["take_profit_pct"]
            stop_loss_pct_now = params["stop_loss_pct"]
            print(f"|- 参数: {params}")
            print(f"|- allowed coins: {allowed_coins_now}")
            print(f"|- risk: {risk_now}")
            print(f"|- take_profit_pct: {take_profit_pct_now}")
            print(f"|- stop_loss_pct: {stop_loss_pct_now}")

        if allowed_coins_now and ev.coin not in allowed_coins_now:
            print(f"|- 币种不匹配: {ev.coin} not in {allowed_coins_now}")
            return

        dir_text = str((ev.raw or {}).get("dir", "") or "")
        start_pos = _f((ev.raw or {}).get("startPosition"))
        action = _classify_leader_fill(dir_text, start_pos, ev.sz)
        if action is None:
            print(f"|- unclassified; ignore dir={dir_text} startPosition={start_pos} sz={ev.sz}")
            return
        print(f"|- action: {action} dir={dir_text} startPosition={start_pos} sz={ev.sz}")

        # user current position (for proportional reduce/close follow)
        dex_for_coin = _dex_for_coin(ev.coin)
        user_positions = _get_positions_by_dex(dex_for_coin)
        user_pos = user_positions.get(ev.coin)
        user_szi = float(user_pos[0]) if user_pos else 0.0

        # cooldown：只在 leader 新开仓时恢复跟单
        if ev.coin in cooldown_coins and action != "OPEN_NEW":
            print(f"|- cooldown; waiting for next OPEN: coin={ev.coin}")
            return
        if action == "OPEN_NEW":
            cooldown_coins.discard(ev.coin)

        # 初次跟单/恢复跟单：默认只在 OPEN_NEW 时启用；
        # 但如果用户已持有该币种仓位，且 leader 在做减仓/平仓，则允许直接跟随。
        if ev.coin not in follow_enabled:
            allow_existing_position_follow = False
            if action in ("REDUCE", "CLOSE") and user_szi != 0.0:
                # Only follow reduce/close when user has same-side position
                if (start_pos == 0.0) or (user_szi * start_pos > 0):
                    allow_existing_position_follow = True
            if action != "OPEN_NEW" and (not allow_existing_position_follow):
                print(f"|- following not enabled yet; waiting for next OPEN: coin={ev.coin}")
                return
            follow_enabled.add(ev.coin)

        print("======== sizing ========")
        margin_ratio = _get_margin_ratio()
        print(f"|- margin_ratio: {margin_ratio}")
        effective_scale = float(risk_now.scale) * float(margin_ratio)
        planned_sz = risk_now.plan_size_with_scale(ev, effective_scale)

        # If leader is reducing/closing and user already has same coin position, follow proportionally.
        # - REDUCE: close user position by (leader_reduction / leader_start_position) ratio
        # - CLOSE: close user position fully
        if user_szi != 0.0 and action in ("REDUCE", "CLOSE"):
            # only act on same-side positions
            if (start_pos == 0.0) or (user_szi * start_pos > 0):
                if action == "CLOSE":
                    planned_sz = float(user_szi)  # sign doesn't matter; later we abs()
                else:
                    denom = abs(float(start_pos))
                    ratio = (abs(float(ev.sz)) / denom) if denom > 1e-12 else 0.0
                    ratio = max(0.0, min(1.0, ratio))
                    planned_sz = float(user_szi) * ratio
        planned_ntl = abs(ev.px * planned_sz)
        print(f"|- ev.px: {ev.px}")
        print(f"|- planned_sz: {planned_sz}")
        print(f"|- planned_ntl: {planned_ntl}")

        def _balance_usdc_after_refresh() -> float | None:
            try:
                _refresh_states(force=True)
            except Exception:
                pass
            return _get_balance_usdc_from_user_state(cached_user_state)

        if action in ("OPEN_NEW", "OPEN_ADD") and planned_ntl < float(MIN_ORDER_NOTIONAL_USD):
            bal = _balance_usdc_after_refresh()
            msg = format_copy_min_notional_skip_alert(
                lang=str(language),
                leader=str(leader),
                coin=str(ev.coin),
                min_notional_usd=float(MIN_ORDER_NOTIONAL_USD),
                balance_usdc=bal,
            )
            send_message(target, msg)
            return
        if not risk_now.allow_notional_usd(planned_ntl):
            bal = _balance_usdc_after_refresh()
            msg = format_copy_max_notional_skip_alert(
                lang=str(language),
                leader=str(leader),
                coin=str(ev.coin),
                max_notional_usd=float(risk_now.max_notional_usd or 0.0),
                balance_usdc=bal,
            )
            send_message(target, msg)
            return
        print("======== leverage ========")
        leader_lev = None
        if action in ("OPEN_NEW", "OPEN_ADD"):
            leader_pos = _leader_position_for_coin(ev.coin)
            leader_lev = risk_now.leader_leverage(ev, leader_position=leader_pos)
            print(f"|- leader_lev: {leader_lev}")
        capped = leader_lev is not None and risk_now.max_leverage is not None and leader_lev > risk_now.max_leverage
        # real trading execution
        if exchange is not None:
            try:
                sz_to_use = abs(planned_sz)
                if action == "CLOSE":
                    # leader full close -> full close
                    res = exchange.market_close(
                        coin=ev.coin,
                        sz=None,
                        px=ev.px,
                        slippage=slippage,
                    )
                    # best-effort: refresh cached states for account info
                    try:
                        _refresh_states(force=True)
                    except Exception:
                        pass
                    entry_px, pos_ntl, upnl, was_long = _position_detail_for_coin(ev.coin)
                    bal = _get_balance_usdc_from_user_state(cached_user_state)
                    msg = format_copy_close_alert(
                        lang=str(language),
                        coin=str(ev.coin),
                        was_long=bool(was_long) if was_long is not None else True,
                        sz=float(sz_to_use),
                        entry_px=entry_px,
                        position_notional=pos_ntl,
                        pnl_usd=upnl,
                        leader=str(leader),
                        balance_usdc=bal,
                    )
                elif action == "REDUCE":
                    sz_to_use = _quantize_sz_for_coin(ev.coin, sz_to_use)
                    if sz_to_use <= 0:
                        return
                    res = exchange.market_close(
                        coin=ev.coin,
                        sz=sz_to_use,
                        px=ev.px,
                        slippage=slippage,
                    )
                    try:
                        _refresh_states(force=True)
                    except Exception:
                        pass
                    entry_px, pos_ntl, upnl, was_long = _position_detail_for_coin(ev.coin)
                    bal = _get_balance_usdc_from_user_state(cached_user_state)
                    msg = format_copy_close_alert(
                        lang=str(language),
                        coin=str(ev.coin),
                        was_long=bool(was_long) if was_long is not None else True,
                        sz=float(sz_to_use),
                        entry_px=entry_px,
                        position_notional=pos_ntl,
                        pnl_usd=upnl,
                        leader=str(leader),
                        balance_usdc=bal,
                    )
                else:
                    is_buy = _is_buy_from_dir(dir_text)
                    if is_buy is None:
                        side_l = str(ev.side).lower()
                        is_buy = side_l.startswith("buy") or "buy" in side_l or side_l == "long"

                    # Subscribe allMids only once we see a real user position (liquidation distance warnings)
                    if liq_warn_enabled and (not liq_warn_subscribed) and action in ("OPEN_NEW", "OPEN_ADD"):
                        try:
                            sub.add_all_mids("")
                            sub.add_all_mids("xyz")
                            liq_warn_subscribed = True
                        except Exception:
                            pass

                    # xyz 新开仓前：启用 dex abstraction（只需要做一次）
                    nonlocal xyz_abstraction_enabled
                    if (not xyz_abstraction_enabled) and action == "OPEN_NEW" and _dex_for_coin(ev.coin) == "xyz":
                        try:
                            res_abs = exchange.user_dex_abstraction(user_addr, True)
                            xyz_abstraction_enabled = True
                        except Exception as e:
                            pass

                    # 仅新开仓/加仓时，按规则调整用户杠杆，并同步仓位类别（全仓/逐仓）
                    if action in ("OPEN_NEW", "OPEN_ADD"):
                        desired_lev = RiskProfile.desired_user_leverage_for_open_add(
                            leader_leverage=leader_lev, user_max_leverage=risk_now.max_leverage
                        )
                        if desired_lev is not None:
                            is_cross = True
                            leader_pos2 = _leader_position_for_coin(ev.coin)
                            is_cross_from_pos = RiskProfile.position_is_cross(leader_pos2)
                            if is_cross_from_pos is not None:
                                is_cross = is_cross_from_pos
                            try:
                                res_lev = exchange.update_leverage(leverage=desired_lev, name=ev.coin, is_cross=is_cross)
                                print(f"|- update_leverage: coin={ev.coin} lev={desired_lev} res={res_lev}")
                            except Exception as e:
                                print(f"|- update_leverage failed: coin={ev.coin} lev={desired_lev} err={e}")
                    sz_to_use = _quantize_sz_for_coin(ev.coin, sz_to_use)
                    if sz_to_use <= 0:
                        return
                    res = exchange.market_open(
                        name=ev.coin,
                        is_buy=is_buy,
                        sz=sz_to_use,
                        px=ev.px,
                        slippage=slippage,
                    )


                    print(f"|- market_open res: {res}")
                    try:
                        _refresh_states(force=True)
                    except Exception:
                        pass
                    bal = _get_balance_usdc_from_user_state(cached_user_state)
                    msg = format_copy_open_alert(
                        lang=str(language),
                        coin=str(ev.coin),
                        is_buy=bool(is_buy),
                        leverage_x=(float(leader_lev) if leader_lev is not None else None),
                        sz=float(sz_to_use),
                        entry_px=float(ev.px),
                        leader=str(leader),
                        balance_usdc=bal,
                    )

                send_message(target, msg)
            except Exception as e:
                print(f"|- market_open error: {e}")
                err = format_copy_exec_error_message(language=language, coin=ev.coin, leader=ev.user, err=e)
                send_message(target, err)

    def _maybe_trigger_tp_sl(
        dex: str,
        coin: str,
        mid: float,
        positions: dict[str, tuple[float, float, float]] | None = None,
    ) -> None:
        if exchange is None:
            return
        if mid <= 0:
            return
        with params_lock:
            tp_pct = params.get("take_profit_pct")
            sl_pct = params.get("stop_loss_pct")
        if tp_pct is None and sl_pct is None:
            return
        if coin in cooldown_coins:
            return
        if coin not in follow_enabled:
            return

        if positions is None:
            positions = _get_positions_by_dex(dex)
        p = positions.get(coin)
        if not p:
            return
        szi, entry_px, _liq_px = p
        if entry_px <= 0:
            return
        pct = (mid - entry_px) / entry_px * 100.0 if szi > 0 else (entry_px - mid) / entry_px * 100.0

        hit_tp = (tp_pct is not None) and (pct >= float(tp_pct))
        hit_sl = (sl_pct is not None) and (pct <= -float(sl_pct))
        if not (hit_tp or hit_sl):
            return

        msg = format_copy_tp_sl_trigger(
            lang=str(language),
            hit_tp=bool(hit_tp),
            coin=str(coin),
            dex=str(dex),
            pct=float(pct),
            entry_px=float(entry_px),
            mid=float(mid),
        )
        send_message(target, msg)

        try:
            exchange.market_close(coin=coin, sz=None, px=mid, slippage=slippage)
        except Exception as e:
            err = format_copy_exec_error_message(language=language, coin=coin, leader=leader, err=e)
            send_message(target, err)
            return

        cooldown_coins.add(coin)

    def _maybe_trigger_liq_warning(
        dex: str,
        coin: str,
        mid: float,
        positions: dict[str, tuple[float, float, float]] | None = None,
    ) -> None:
        """
        User-side liquidation risk warning:
        - compute remaining distance-to-liquidation from position.liquidationPx and current mid
        - only trigger once per threshold (10%/5%/1%) to avoid spam
        - prompt user to add margin
        """
        if exchange is None:
            return
        if mid <= 0:
            return

        if positions is None:
            positions = _get_positions_by_dex(dex)

        p = (positions or {}).get(coin)
        key = (str(dex), str(coin))

        with liq_warn_lock:
            level = liq_warn_level.get(key, 0)

        if not p:
            if level != 0:
                with liq_warn_lock:
                    liq_warn_level[key] = 0
            return

        szi, _entry_px, liq_px = p

        if abs(szi) <= 1e-12 or liq_px is None or liq_px <= 0:
            if level != 0:
                with liq_warn_lock:
                    liq_warn_level[key] = 0
            return

        pct = RiskProfile.liquidation_distance_pct(szi=szi, mid=mid, liquidation_px=liq_px)
        if pct is None:
            return

        new_level = level
        hit_thr = None
        if pct <= 1.0 and level < 3:
            new_level = 3
            hit_thr = 1.0
        elif pct <= 5.0 and level < 2:
            new_level = 2
            hit_thr = 5.0
        elif pct <= 10.0 and level < 1:
            new_level = 1
            hit_thr = 10.0

        if hit_thr is None:
            return

        msg = format_copy_liquidation_warning(
            lang=str(language),
            coin=str(coin),
            dex=str(dex),
            remaining_pct=float(pct),
            threshold_pct=float(hit_thr),
        )

        send_message(target, msg)
        with liq_warn_lock:
            liq_warn_level[key] = new_level

    def on_all_mids(dex: str, mids: dict[str, float]) -> None:
        dex_s = str(dex or "")
        with mids_lock:
            for c, m in mids.items():
                latest_mids[(dex_s, str(c))] = float(m)
        positions = _get_positions_by_dex(dex_s)
        for c, m in mids.items():
            coin_s = str(c)
            mid = float(m)
            _maybe_trigger_tp_sl(dex_s, coin_s, mid, positions=positions)
            if liq_warn_enabled:
                _maybe_trigger_liq_warning(dex_s, coin_s, mid, positions=positions)

    def on_user_event(ev: UserEvent) -> None:
        # Suppress userEvents-based liquidation alerts; we rely on wsFills.fill.liquidation instead.
        return

    stop_requested = threading.Event()

    def _close_all_positions_best_effort() -> None:
        if exchange is None:
            return
        default_state: dict[str, Any] | None
        xyz_state: dict[str, Any] | None
        try:
            default_state = info_client.user_state_no_dex(user_addr)
        except Exception:
            default_state = None
        try:
            xyz_state = info_client.user_state(user_addr, dex="xyz")
        except Exception:
            xyz_state = None

        aps_default = default_state.get("assetPositions") if isinstance(default_state, dict) else None
        aps_xyz = xyz_state.get("assetPositions") if isinstance(xyz_state, dict) else None
        aps_all: list[Any] = []
        if isinstance(aps_default, list):
            aps_all.extend(aps_default)
        if isinstance(aps_xyz, list):
            aps_all.extend(aps_xyz)
        if not aps_all:
            return

        # best-effort mids for close px
        mids_default: dict[str, Any] | None = None
        mids_xyz: dict[str, Any] | None = None
        try:
            fn = getattr(exchange.info, "all_mids", None)
            if callable(fn):
                mids_default = fn()  # type: ignore[misc]
                try:
                    mids_xyz = fn(dex="xyz")  # type: ignore[misc]
                except Exception:
                    mids_xyz = None
        except Exception:
            mids_default = None
            mids_xyz = None

        seen_coins: set[str] = set()
        for item in aps_all:
            if not isinstance(item, dict):
                continue
            pos = item.get("position")
            if not isinstance(pos, dict):
                continue
            coin = str(pos.get("coin", "") or "").strip()
            if not coin:
                continue
            if coin in seen_coins:
                continue
            seen_coins.add(coin)
            try:
                szi = float(pos.get("szi") or 0.0)
            except Exception:
                szi = 0.0
            if abs(szi) <= 1e-12:
                continue

            px = None
            try:
                mids_map = mids_xyz if coin.startswith("xyz:") else mids_default
                if isinstance(mids_map, dict) and coin in mids_map:
                    px = float(mids_map.get(coin))
            except Exception:
                px = None
            try:
                exchange.market_close(coin=coin, sz=None, px=px, slippage=slippage)
            except Exception:
                # best-effort; continue closing others
                pass

    def _handle_stop_signal(signum: int, frame: Any | None = None) -> None:  # noqa: ARG001
        if stop_requested.is_set():
            return
        stop_requested.set()
        try:
            # close positions first, then stop ws
            _close_all_positions_best_effort()
        finally:
            try:
                sub.stop()
            except Exception:
                pass

    sub = HyperliquidWsSubscriber(
        WsConfig(
            ws_url=ws_url,
            debug_ping_pong=True,
            use_app_ping=True,
            app_ping_interval_seconds=30,
        ),
        on_fill=on_fill,
        on_all_mids=on_all_mids,
        on_user_event=on_user_event,
        on_status=on_status,
        ignore_snapshots=True,
    )

    # graceful stop: close positions then stop ws loop
    try:
        signal.signal(signal.SIGTERM, _handle_stop_signal)
        signal.signal(signal.SIGINT, _handle_stop_signal)
    except Exception:
        pass

    sub.add_user_fills(leader, aggregate_by_time=True)
    sub.add_user_events(leader)
    if (take_profit_pct is not None) or (stop_loss_pct is not None) or (liq_warn_enabled and liq_warn_should_subscribe):
        sub.add_all_mids("")
        sub.add_all_mids("xyz")
        liq_warn_subscribed = True

    # watcher: 轮询 params 更新文件，并动态修改跟单限制
    params_update_path = _copy_params_path()
    last_seq: int = 0

    def _watch_params_updates() -> None:
        nonlocal last_seq

        def _parse_opt_float(v: Any) -> float | None:
            if v is None:
                return None
            return float(v)

        def _parse_coins_value(v: Any) -> list[str]:
            if v is None:
                return []
            if isinstance(v, list):
                return [str(x).strip() for x in v if str(x).strip()]
            if isinstance(v, str):
                s = v.strip()
                if not s:
                    return []
                if s.lower() in ("off", "none", "null"):
                    return []
                return [p.strip() for p in s.split(",") if p.strip()]
            return []

        while True:
            try:
                if not os.path.exists(params_update_path):
                    time.sleep(2.0)
                    continue
                with open(params_update_path, "r", encoding="utf-8") as f:
                    raw = json.load(f) if f.readable() else {}
                if not isinstance(raw, dict):
                    time.sleep(2.0)
                    continue
                seq_raw = raw.get("seq")
                try:
                    seq = int(seq_raw) if seq_raw is not None else 0
                except Exception:
                    seq = 0
                if seq <= last_seq:
                    time.sleep(2.0)
                    continue

                changed: list[str] = []
                with params_lock:
                    cur_risk: RiskProfile = params["risk"]
                    new_scale = cur_risk.scale
                    new_max_notional = cur_risk.max_notional_usd
                    new_max_leverage = cur_risk.max_leverage
                    if "scale" in raw:
                        new_scale = float(raw.get("scale"))
                        changed.append("scale")
                    if "maxNotionalUsd" in raw:
                        new_max_notional = _parse_opt_float(raw.get("maxNotionalUsd"))
                        changed.append("maxNotionalUsd")
                    if "maxLeverage" in raw:
                        new_max_leverage = _parse_opt_float(raw.get("maxLeverage"))
                        changed.append("maxLeverage")

                    # 风控配置更新
                    params["risk"] = RiskProfile(
                        scale=new_scale,
                        max_notional_usd=new_max_notional,
                        max_leverage=new_max_leverage,
                    )

                    if "coins" in raw:
                        params["allowed_coins"] = _parse_coins_value(raw.get("coins"))
                        changed.append("coins")
                    if "takeProfitPct" in raw:
                        v = raw.get("takeProfitPct")
                        params["take_profit_pct"] = _parse_opt_float(v)
                        changed.append("takeProfitPct")
                    if "stopLossPct" in raw:
                        v = raw.get("stopLossPct")
                        params["stop_loss_pct"] = _parse_opt_float(v)
                        changed.append("stopLossPct")

                last_seq = seq

                if changed:
                    if language == "en":
                        msg = "\n".join(["[copy][params][update]", f"seq={seq}", f"changed={changed}"])
                    else:
                        key_map = {
                            "scale": "规模系数",
                            "maxNotionalUsd": "最大名义金额",
                            "maxLeverage": "最大杠杆",
                            "coins": "币种白名单",
                            "takeProfitPct": "止盈",
                            "stopLossPct": "止损",
                        }
                        changed_zh = [key_map.get(k, k) for k in changed]
                        msg = "\n".join(["[copy][参数更新]", f"序号={seq}", f"已更新字段={','.join(changed_zh)}"])
                    send_message(target, msg)
                    # TP/SL 开关变化时，启用 allMids 订阅
                    if "takeProfitPct" in changed or "stopLossPct" in changed:
                        with params_lock:
                            tp_now = params.get("take_profit_pct")
                            sl_now = params.get("stop_loss_pct")
                        if tp_now is not None or sl_now is not None:
                            try:
                                sub.add_all_mids("")
                                sub.add_all_mids("xyz")
                            except Exception:
                                pass
            except Exception:
                # watch thread 不要影响主线程
                pass
            time.sleep(2.0)

    t = threading.Thread(target=_watch_params_updates, daemon=True)
    t.start()

    # persist pid for `--stop` usage (by target)
    try:
        with open(_copy_pid_path(), "w", encoding="utf-8") as f:
            f.write(str(os.getpid()))
    except Exception:
        pass

    status_msg = format_copy_started_status(
        lang=str(language),
        leader=str(leader),
        scale=float(risk.scale),
        max_notional_usd=(float(risk.max_notional_usd) if risk.max_notional_usd is not None else None),
        max_leverage=(float(risk.max_leverage) if risk.max_leverage is not None else None),
        allowed_coins=list(allowed_coins or []),
        take_profit_pct=(float(take_profit_pct) if take_profit_pct is not None else None),
        stop_loss_pct=(float(stop_loss_pct) if stop_loss_pct is not None else None),
    )
    on_status(status_msg)
    sub.run_forever()
    return 0


def main() -> int:
    args = build_parser().parse_args()
    return run(args)


if __name__ == "__main__":
    raise SystemExit(main())


