feat: 账户方向与币种白名单 env 开关(三所)

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>
This commit is contained in:
dekun
2026-07-05 00:30:49 +08:00
parent 65b911994c
commit c0ad50a7b5
22 changed files with 814 additions and 35 deletions
+39
View File
@@ -1572,3 +1572,42 @@ html[data-theme="light"] .order-preview-profit strong {
color: #087a50 !important;
}
/* ── 账户交易限制(方向 / 币种白名单)── */
.trade-policy-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
color: #8fc8ff;
background: rgba(31, 58, 90, 0.55);
border: 1px solid rgba(143, 200, 255, 0.35);
line-height: 1.4;
}
.trade-policy-dir-lock {
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 8px;
font-size: 0.82rem;
font-weight: 600;
color: #4cd97f;
background: rgba(76, 217, 127, 0.1);
border: 1px solid rgba(76, 217, 127, 0.28);
white-space: nowrap;
}
html[data-theme="light"] .trade-policy-badge {
color: #1a4a7a;
background: #e8f2fb;
border-color: #9ec5e8;
}
html[data-theme="light"] .trade-policy-dir-lock {
color: #087a50;
background: #e8f8f0;
border-color: #9ed4b8;
}
@@ -34,10 +34,9 @@
</div>
{% include order_rule_tips_tpl %}
<form id="add-order-form" action="/add_order" method="post" class="form-row" data-risk-percent="{{ risk_percent }}">
<input id="order-symbol" name="symbol" placeholder="BTC 或 BTC/USDT" required>
<select id="order-direction" name="direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction %}
{{ trade_policy_symbol('symbol', 'order-symbol') }}
{{ trade_policy_direction('direction', 'order-direction') }}
<select id="sltp-mode" name="sltp_mode">
<option value="fixed_rr" selected>止盈止损:固定盈亏比</option>
<option value="price">止盈止损:价格模式</option>
+4 -1
View File
@@ -7,7 +7,7 @@
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
<link rel="stylesheet" href="/static/instance_page.css?v=2">
<link rel="stylesheet" href="/static/instance_theme.css?v=48">
<link rel="stylesheet" href="/static/instance_theme.css?v=49">
<script src="/static/account_risk_badge.js?v=4"></script>
<meta name="theme-color" content="#0b0d14">
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
@@ -28,6 +28,9 @@
<h1>加密货币|交易监控 + AI复盘一体化</h1>
<div class="header-row">
<div class="exchange-tag">{{ exchange_display }}</div>
{% if trade_policy.badge_text %}
<span class="trade-policy-badge" title="账户交易限制(.env">{{ trade_policy.badge_text }}</span>
{% endif %}
<span class="risk-status-badge risk-status-{{ risk_status.status|default('normal') }}" id="account-risk-badge" role="status" title="{{ risk_status.reason|default('', true) }}" data-status-label="{{ risk_status.status_label|default('正常') }}"{% if risk_status.freeze_until_ms %} data-freeze-until-ms="{{ risk_status.freeze_until_ms }}"{% endif %}>{{ risk_status.status_label|default('正常') }}</span>
<div class="theme-toggle instance-theme-toggle" role="group" aria-label="界面主题">
<button type="button" class="theme-toggle-btn is-active" data-theme-value="dark" aria-pressed="true" title="暗色主题">
+5
View File
@@ -277,6 +277,11 @@ def _roll_context(cfg: dict, data: dict) -> tuple[Optional[dict], Optional[str]]
if not symbol:
return None, "请选择或填写币种"
direction = (data.get("direction") or "long").strip().lower()
validate_fn = getattr(m, "validate_trade_policy_open", None) if m is not None else None
if callable(validate_fn):
ok_pol, pol_msg = validate_fn(symbol, direction)
if not ok_pol:
return None, pol_msg
ex_sym = cfg["normalize_exchange_symbol"](symbol)
conn = get_db()
init_strategy_tables(conn)
+5
View File
@@ -257,6 +257,11 @@ def precheck_trend_start(cfg: dict, conn, *, symbol: str = "", direction: str =
pass
sym = (symbol or "").strip()
dir_l = (direction or "long").strip().lower()
validate_fn = getattr(m, "validate_trade_policy_open", None)
if callable(validate_fn) and sym:
ok_pol, pol_msg = validate_fn(sym, dir_l)
if not ok_pol:
return False, pol_msg
if sym and dir_l in ("long", "short") and hasattr(m, "precheck_risk"):
ok_risk, risk_msg = m.precheck_risk(conn, sym, dir_l)
if not ok_risk:
+6 -2
View File
@@ -8,6 +8,9 @@
<link rel="stylesheet" href="/static/focus_chart_page.css?v=1">
</head>
<body class="focus-page">
{% if trade_policy is not defined %}
{% set trade_policy = {'symbol_restrict_enabled': false, 'direction_restrict_enabled': false, 'symbol_whitelist': [], 'allows_long': true, 'allows_short': true, 'badge_text': ''} %}
{% endif %}
<div class="container">
<div class="card">
<div class="row" style="justify-content:space-between">
@@ -25,13 +28,14 @@
</div>
<div class="row">
<a class="btn" href="/">返回首页</a>
<strong class="focus-title">关键位放大(可输入币种)</strong><span class="exchange-tag">{{ exchange_display }}</span>
<strong class="focus-title">关键位放大{% if trade_policy.symbol_restrict_enabled %}(选择币种){% else %}(可输入币种){% endif %}</strong><span class="exchange-tag">{{ exchange_display }}</span>
</div>
<div class="status">最近刷新:<span id="updated-at">--</span></div>
</div>
<div class="row" style="margin-top:10px">
<label>币种</label>
<input id="symbol-input" value="{{ default_symbol }}" placeholder="BTC/USDT">
{% from 'trade_policy_fields.html' import trade_policy_symbol %}
{{ trade_policy_symbol('symbol', 'symbol-input', default_symbol, placeholder='BTC/USDT') }}
<label>关键位</label>
<select id="key-id">
<option value="">无(仅看K线)</option>
@@ -142,7 +142,8 @@
{% endif %}
</div>
<form id="key-form" action="/add_key" method="post" class="form-row">
<input name="symbol" placeholder="BTC 或 BTC/USDT" required>
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction %}
{{ trade_policy_symbol('symbol', 'key-symbol') }}
<select name="type" id="key-type-select" required>
{% if position_sizing_mode != 'full_margin' %}
<option value="箱体突破">箱体突破</option>
@@ -155,9 +156,7 @@
<option value="突破触价开仓">突破触价开仓</option>
<option value="关键支撑阻力">关键支撑阻力</option>
</select>
<select name="direction" id="key-direction" required>
<option value="">方向</option><option value="long">做多</option><option value="short">做空</option>
</select>
{{ trade_policy_direction('direction', 'key-direction') }}
<input name="key_price" id="key-fb-price" step="0.0001" placeholder="做空填高点/做多填低点" style="display:none">
<input name="trigger_entry" id="key-trigger-entry" step="0.0001" placeholder="计划入场价" style="display:none">
<input name="trigger_sl" id="key-trigger-sl" step="0.0001" placeholder="止损价" style="display:none">
@@ -24,12 +24,9 @@
{% endfor %}
{% endif %}
<form id="trend-pullback-form" action="{{ url_for('preview_trend_pullback') }}" method="post" class="form-row">
<input name="symbol" placeholder="BTC 或 ETH/USDT" required>
<select name="direction" id="trend-direction" required>
<option value="">方向</option>
<option value="long">做多</option>
<option value="short">做空</option>
</select>
{% from 'trade_policy_fields.html' import trade_policy_symbol, trade_policy_direction %}
{{ trade_policy_symbol('symbol', 'trend-symbol', placeholder='BTC 或 ETH/USDT') }}
{{ trade_policy_direction('direction', 'trend-direction') }}
<input name="leverage" type="number" min="1" step="1" placeholder="杠杆(必填)" required>
<input name="risk_percent" type="number" min="0.1" step="0.1" value="5" placeholder="风险%相对可用快照" title="默认5:最坏亏损约≤可用余额×5%">
<input name="sl" step="any" placeholder="止损价" required>
@@ -0,0 +1,29 @@
{# 方向 / 币种:env 账户级限制(三所共用宏) #}
{% macro trade_policy_symbol(name, id, value='', required=true, placeholder='BTC 或 BTC/USDT') -%}
{% if trade_policy.symbol_restrict_enabled and trade_policy.symbol_whitelist %}
<select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %} class="trade-policy-symbol-select">
<option value="">选择币种</option>
{% for sym in trade_policy.symbol_whitelist %}
<option value="{{ sym }}" {% if value and (value|upper == sym or value|upper.startswith(sym ~ '/')) %}selected{% endif %}>{{ sym }}/USDT</option>
{% endfor %}
</select>
{% else %}
<input id="{{ id }}" name="{{ name }}" placeholder="{{ placeholder }}" {% if required %}required{% endif %} value="{{ value }}">
{% endif %}
{%- endmacro %}
{% macro trade_policy_direction(name, id, required=true, include_empty=true) -%}
{% if trade_policy.direction_restrict_enabled and trade_policy.direction_mode == 'long_only' %}
<span class="trade-policy-dir-lock" title="账户配置:仅做多">做多</span>
<input type="hidden" name="{{ name }}" id="{{ id }}" value="long">
{% elif trade_policy.direction_restrict_enabled and trade_policy.direction_mode == 'short_only' %}
<span class="trade-policy-dir-lock" title="账户配置:仅做空">做空</span>
<input type="hidden" name="{{ name }}" id="{{ id }}" value="short">
{% else %}
<select name="{{ name }}" id="{{ id }}" {% if required %}required{% endif %}>
{% if include_empty %}<option value="">方向</option>{% endif %}
{% if trade_policy.allows_long %}<option value="long">做多</option>{% endif %}
{% if trade_policy.allows_short %}<option value="short">做空</option>{% endif %}
</select>
{% endif %}
{%- endmacro %}
+52
View File
@@ -0,0 +1,52 @@
"""Flask 实例接入 trade policy(三所 app.py 共用)。"""
from __future__ import annotations
from typing import Callable, Tuple
from lib.trade.trade_policy_lib import (
TradePolicy,
assert_direction_allowed,
assert_symbol_allowed,
assert_trade_policy_open,
trade_policy_to_dict,
)
def trade_policy_template_context(policy: TradePolicy) -> dict:
return trade_policy_to_dict(policy)
def default_symbol_for_policy(policy: TradePolicy, raw_default: str) -> str:
d = (raw_default or "BTC/USDT").strip() or "BTC/USDT"
if policy.symbol_restrict_enabled and policy.symbol_whitelist:
from lib.trade.trade_policy_lib import symbol_base_coin
base = symbol_base_coin(d)
if base not in policy.symbol_whitelist:
return f"{policy.symbol_whitelist[0]}/USDT"
return d
def check_symbol_policy(
policy: TradePolicy,
symbol: str,
normalize_symbol_fn: Callable[[str], str],
) -> Tuple[bool, str]:
return assert_symbol_allowed(
policy, symbol, normalize_symbol_fn=normalize_symbol_fn
)
def check_direction_policy(policy: TradePolicy, direction: str) -> Tuple[bool, str]:
return assert_direction_allowed(policy, direction)
def check_open_policy(
policy: TradePolicy,
symbol: str,
direction: str,
normalize_symbol_fn: Callable[[str], str],
) -> Tuple[bool, str]:
return assert_trade_policy_open(
policy, symbol, direction, normalize_symbol_fn=normalize_symbol_fn
)
+205
View File
@@ -0,0 +1,205 @@
"""
三所共用:账户级方向 / 币种白名单(.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, ""