c0ad50a7b5
Per-instance TRADE_DIRECTION / TRADE_SYMBOL_WHITELIST restricts UI and API for manual orders, key monitors, and strategies; includes sync script for deployment profiles. Co-authored-by: Cursor <cursoragent@cursor.com>
206 lines
6.7 KiB
Python
206 lines
6.7 KiB
Python
"""
|
|
三所共用:账户级方向 / 币种白名单(.env 开关,默认关闭=不限制)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Callable, FrozenSet, Optional, Sequence, Tuple
|
|
|
|
DIR_BOTH = "both"
|
|
DIR_LONG_ONLY = "long_only"
|
|
DIR_SHORT_ONLY = "short_only"
|
|
VALID_DIRECTION_MODES = frozenset({DIR_BOTH, DIR_LONG_ONLY, DIR_SHORT_ONLY})
|
|
|
|
_DIR_ALIASES = {
|
|
"both": DIR_BOTH,
|
|
"双向": DIR_BOTH,
|
|
"long": DIR_LONG_ONLY,
|
|
"long_only": DIR_LONG_ONLY,
|
|
"多": DIR_LONG_ONLY,
|
|
"仅多": DIR_LONG_ONLY,
|
|
"做多": DIR_LONG_ONLY,
|
|
"short": DIR_SHORT_ONLY,
|
|
"short_only": DIR_SHORT_ONLY,
|
|
"空": DIR_SHORT_ONLY,
|
|
"仅空": DIR_SHORT_ONLY,
|
|
"做空": DIR_SHORT_ONLY,
|
|
}
|
|
|
|
|
|
def _env_bool(raw: Optional[str], default: bool = False) -> bool:
|
|
if raw is None:
|
|
return default
|
|
return (raw or "").strip().lower() in ("1", "true", "yes", "on")
|
|
|
|
|
|
def normalize_direction_mode(raw: Optional[str]) -> str:
|
|
v = (raw or DIR_BOTH).strip().lower()
|
|
return _DIR_ALIASES.get(v, v if v in VALID_DIRECTION_MODES else DIR_BOTH)
|
|
|
|
|
|
def symbol_base_coin(symbol: str) -> str:
|
|
"""BTC/USDT:USDT、BTC/USDT、BTC、btc -> BTC"""
|
|
s = (symbol or "").strip().upper()
|
|
if not s:
|
|
return ""
|
|
if ":" in s:
|
|
s = s.split(":", 1)[0]
|
|
if "/" in s:
|
|
return s.split("/", 1)[0].strip()
|
|
if s.endswith("USDT") and len(s) > 4:
|
|
return s[:-4]
|
|
return s
|
|
|
|
|
|
def parse_symbol_whitelist(raw: Optional[str]) -> Tuple[str, ...]:
|
|
if not raw or not str(raw).strip():
|
|
return ()
|
|
parts = []
|
|
for piece in str(raw).replace(";", ",").split(","):
|
|
base = symbol_base_coin(piece.strip())
|
|
if base and base not in parts:
|
|
parts.append(base)
|
|
return tuple(parts)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TradePolicy:
|
|
direction_restrict_enabled: bool
|
|
direction_mode: str
|
|
symbol_restrict_enabled: bool
|
|
symbol_whitelist: Tuple[str, ...]
|
|
|
|
@property
|
|
def allows_long(self) -> bool:
|
|
if not self.direction_restrict_enabled:
|
|
return True
|
|
return self.direction_mode in (DIR_BOTH, DIR_LONG_ONLY)
|
|
|
|
@property
|
|
def allows_short(self) -> bool:
|
|
if not self.direction_restrict_enabled:
|
|
return True
|
|
return self.direction_mode in (DIR_BOTH, DIR_SHORT_ONLY)
|
|
|
|
|
|
def load_trade_policy(env: Optional[dict] = None) -> TradePolicy:
|
|
e = env if env is not None else os.environ
|
|
direction_restrict = _env_bool(e.get("TRADE_DIRECTION_RESTRICT_ENABLED"), False)
|
|
symbol_restrict = _env_bool(e.get("TRADE_SYMBOL_RESTRICT_ENABLED"), False)
|
|
direction_mode = normalize_direction_mode(e.get("TRADE_DIRECTION"))
|
|
whitelist = parse_symbol_whitelist(e.get("TRADE_SYMBOL_WHITELIST"))
|
|
if symbol_restrict and not whitelist:
|
|
symbol_restrict = False
|
|
return TradePolicy(
|
|
direction_restrict_enabled=direction_restrict,
|
|
direction_mode=direction_mode,
|
|
symbol_restrict_enabled=symbol_restrict,
|
|
symbol_whitelist=whitelist,
|
|
)
|
|
|
|
|
|
def direction_mode_label_zh(mode: str) -> str:
|
|
m = normalize_direction_mode(mode)
|
|
if m == DIR_LONG_ONLY:
|
|
return "仅多"
|
|
if m == DIR_SHORT_ONLY:
|
|
return "仅空"
|
|
return "双向"
|
|
|
|
|
|
def trade_policy_badge_parts(policy: TradePolicy) -> Tuple[str, ...]:
|
|
parts: list[str] = []
|
|
if policy.direction_restrict_enabled:
|
|
if policy.direction_mode == DIR_LONG_ONLY:
|
|
parts.append("仅多")
|
|
elif policy.direction_mode == DIR_SHORT_ONLY:
|
|
parts.append("仅空")
|
|
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
|
|
parts.append("/".join(policy.symbol_whitelist))
|
|
return tuple(parts)
|
|
|
|
|
|
def trade_policy_to_dict(policy: TradePolicy) -> dict:
|
|
badges = trade_policy_badge_parts(policy)
|
|
return {
|
|
"direction_restrict_enabled": policy.direction_restrict_enabled,
|
|
"direction_mode": policy.direction_mode,
|
|
"direction_label_zh": (
|
|
direction_mode_label_zh(policy.direction_mode)
|
|
if policy.direction_restrict_enabled
|
|
else "双向"
|
|
),
|
|
"allows_long": policy.allows_long,
|
|
"allows_short": policy.allows_short,
|
|
"symbol_restrict_enabled": policy.symbol_restrict_enabled,
|
|
"symbol_whitelist": list(policy.symbol_whitelist),
|
|
"badge_parts": list(badges),
|
|
"badge_text": " · ".join(badges),
|
|
}
|
|
|
|
|
|
def normalize_open_direction(policy: TradePolicy, direction: str) -> str:
|
|
d = (direction or "long").strip().lower()
|
|
if d not in ("long", "short"):
|
|
d = "long"
|
|
if policy.direction_restrict_enabled:
|
|
if policy.direction_mode == DIR_LONG_ONLY:
|
|
return "long"
|
|
if policy.direction_mode == DIR_SHORT_ONLY:
|
|
return "short"
|
|
return d
|
|
|
|
|
|
def assert_direction_allowed(policy: TradePolicy, direction: str) -> Tuple[bool, str]:
|
|
d = (direction or "").strip().lower()
|
|
if d not in ("long", "short"):
|
|
if d in ("watch", ""):
|
|
return True, ""
|
|
return False, "方向无效,请选择做多或做空"
|
|
if d == "long" and not policy.allows_long:
|
|
return False, "当前账户配置为仅做空,不允许做多"
|
|
if d == "short" and not policy.allows_short:
|
|
return False, "当前账户配置为仅做多,不允许做空"
|
|
return True, ""
|
|
|
|
|
|
def assert_symbol_allowed(
|
|
policy: TradePolicy,
|
|
symbol: str,
|
|
*,
|
|
normalize_symbol_fn: Optional[Callable[[str], str]] = None,
|
|
) -> Tuple[bool, str]:
|
|
if not policy.symbol_restrict_enabled:
|
|
return True, ""
|
|
sym = (symbol or "").strip()
|
|
if not sym:
|
|
return False, "请选择币种"
|
|
if normalize_symbol_fn is not None:
|
|
sym_norm = (normalize_symbol_fn(sym) or "").strip()
|
|
else:
|
|
sym_norm = sym
|
|
base = symbol_base_coin(sym_norm or sym)
|
|
allowed: FrozenSet[str] = frozenset(policy.symbol_whitelist)
|
|
if base not in allowed:
|
|
allowed_txt = "、".join(policy.symbol_whitelist)
|
|
return False, f"当前账户仅允许 {allowed_txt},不允许 {base or sym}"
|
|
return True, ""
|
|
|
|
|
|
def assert_trade_policy_open(
|
|
policy: TradePolicy,
|
|
symbol: str,
|
|
direction: str,
|
|
normalize_symbol_fn: Optional[Callable[[str], str]] = None,
|
|
) -> Tuple[bool, str]:
|
|
ok_sym, msg_sym = assert_symbol_allowed(
|
|
policy, symbol, normalize_symbol_fn=normalize_symbol_fn
|
|
)
|
|
if not ok_sym:
|
|
return False, msg_sym
|
|
ok_dir, msg_dir = assert_direction_allowed(policy, direction)
|
|
if not ok_dir:
|
|
return False, msg_dir
|
|
return True, ""
|