Files
crypto_monitor/lib/env/env_ui_manifest.py
T
dekun d339971c26 Fix env config UI to show runtime defaults when keys are missing from .env.
Account risk fields now match the risk policy page; uncomment RISK_* defaults in .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 21:54:47 +08:00

271 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""env 配置页 UI 白名单:中文标签、按交易所过滤。"""
from __future__ import annotations
import os
from typing import Any, Optional
from lib.env.env_file_lib import env_get_all, read_env_lines
from lib.env.env_schema import (
_field_type,
_hot_reload,
_is_sensitive,
_mask_value,
_restart_required,
parse_env_example_schema,
)
# 各所「交易所与实盘」字段(顺序即页面顺序)
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("OKX_API_KEY", "API Key", "永续子账户"),
("OKX_API_SECRET", "API Secret", "永续子账户"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
("OKX_POS_MODE", "持仓模式", "hedge=双向,net=单向净持仓"),
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
"binance": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("BINANCE_API_KEY", "API Key", "永续子账户"),
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
("BINANCE_MARGIN_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
("BINANCE_POSITION_MODE", "持仓模式", "hedge=双向,one_way=单向"),
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
"gate": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("GATE_API_KEY", "API Key", "永续子账户"),
("GATE_API_SECRET", "API Secret", "永续子账户"),
("GATE_TD_MODE", "保证金模式", "cross=全仓,isolated=逐仓"),
("GATE_POS_MODE", "持仓模式", "hedge=双向,single=单向"),
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
}
_SHARED_SECTIONS: list[dict[str, Any]] = [
{
"title": "企业微信",
"fields": [
("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"),
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
],
},
{
"title": "AI 复盘",
"fields": [
("AI_PROVIDER", "AI 提供方", "openai 或 ollama"),
("OPENAI_API_BASE", "API 地址", "OpenAI 兼容接口"),
("OPENAI_API_KEY", "API 密钥", "留空表示不修改"),
("OPENAI_MODEL", "云端模型", ""),
("OLLAMA_API", "Ollama 地址", "本地服务 URL"),
("AI_MODEL", "Ollama 模型", ""),
("AI_TIMEOUT_SECONDS", "请求超时(秒)", "默认 120"),
],
},
{
"title": "交易执行",
"fields": [
("POSITION_SIZING_MODE", "计仓模式", "risk=以损定仓,full_margin=全仓杠杆"),
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
("ALT_LEVERAGE", "山寨默认杠杆", ""),
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
("TRADE_DIRECTION", "允许方向", "long_only / short_only / both"),
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
("TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "切点前禁止新开仓", ""),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"),
("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
],
},
{
"title": "交易风控",
"fields": [
("DAILY_OPEN_ALERT_THRESHOLD", "单日开仓提醒阈值", "达次数后 AI 提醒,不拦单"),
("DAILY_OPEN_HARD_LIMIT", "单日开仓硬上限", "0=不启用"),
],
},
{
"title": "账户冷静期",
"fields": [
("RISK_CONTROL_ENABLED", "冷静期总开关", ""),
("RISK_COOLING_HOURS_MANUAL", "手动平仓冷静(小时)", ""),
("RISK_COOLING_HOURS_MANUAL_JOURNAL", "复盘情绪冷静(小时)", ""),
("RISK_MANUAL_CLOSE_DAILY_LIMIT", "日手动平仓次数上限", ""),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
{
"title": "自动划转",
"fields": [
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
("AUTO_TRANSFER_AMOUNT", "目标余额(U", "交易账户目标 USDT"),
("AUTO_TRANSFER_FROM", "划出账户", "funding 或 swap"),
("AUTO_TRANSFER_TO", "划入账户", "swap 或 funding"),
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
("TRANSFER_CCY", "划转币种", "默认 USDT"),
],
},
{
"title": "当日资金",
"fields": [
("DAILY_START_CAPITAL", "日起始基数(U", ""),
("DAILY_LOSS_CAPITAL", "回撤后基数(U", ""),
("DAILY_PROFIT_CAPITAL", "盈利后基数(U", ""),
],
},
]
_OPTIONS_SECTION: dict[str, Any] = {
"title": "期权账户",
"exchanges": frozenset({"okx"}),
"fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", ""),
("OKX_OPTIONS_API_KEY", "期权 API Key", "主账户,与永续子账户分离"),
("OKX_OPTIONS_API_SECRET", "期权 API Secret", ""),
("OKX_OPTIONS_API_PASSPHRASE", "期权 API Passphrase", ""),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
("OKX_OPTIONS_TRADE_BUDGET_USDC", "单笔预算(USDC", ""),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95"),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
],
}
# 与运行时 os.getenv 默认一致;.env 未写明时展示实际生效值(同风控说明页)
_RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"RISK_CONTROL_ENABLED": "true",
"RISK_COOLING_HOURS_MANUAL": "4",
"RISK_COOLING_HOURS_MANUAL_JOURNAL": "1",
"RISK_MANUAL_CLOSE_DAILY_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
}
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key in file_values:
return file_values[key]
runtime = os.getenv(key)
if runtime is not None and str(runtime).strip() != "":
return str(runtime).strip()
if schema_default:
return schema_default
return _RUNTIME_ENV_DEFAULTS.get(key, "")
def _schema_field_map(example_path: str) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for group in parse_env_example_schema(example_path):
for field in group.get("fields") or []:
out[field["key"]] = dict(field)
return out
def _build_field(
key: str,
label: str,
note: str,
schema: dict[str, dict[str, Any]],
values: dict[str, str],
) -> dict[str, Any]:
meta = schema.get(key) or {}
schema_default = meta.get("default") or ""
val = _effective_env_value(key, values, schema_default)
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
return {
"key": key,
"label": label,
"note": note or meta.get("note") or "",
"default": val,
"type": ftype,
"sensitive": meta.get("sensitive", _is_sensitive(key)),
"restart_required": meta.get("restart_required", _restart_required(key)),
"hot_reload": meta.get("hot_reload", _hot_reload(key)),
"current": masked["value"] if not _is_sensitive(key) else "",
"masked": masked["masked"],
"tail": masked.get("tail") or "",
"has_value": masked["has_value"],
}
def ui_sections_for_exchange(exchange_key: str) -> list[dict[str, Any]]:
ex = (exchange_key or "").strip().lower()
sections: list[dict[str, Any]] = []
live_fields = _EXCHANGE_LIVE_FIELDS.get(ex, _EXCHANGE_LIVE_FIELDS["okx"])
sections.append({"title": "交易所与实盘", "fields": live_fields})
sections.extend(_SHARED_SECTIONS)
if ex in _OPTIONS_SECTION.get("exchanges", frozenset()):
sections.append(_OPTIONS_SECTION)
return sections
def ui_allowed_keys(exchange_key: str) -> frozenset[str]:
keys: set[str] = set()
for sec in ui_sections_for_exchange(exchange_key):
for item in sec["fields"]:
keys.add(item[0])
return frozenset(keys)
def build_env_ui_payload(
exchange_key: str,
example_path: str,
env_path: str,
) -> list[dict[str, Any]]:
schema = _schema_field_map(example_path)
env_lines = read_env_lines(env_path)
values = env_get_all(env_lines)
groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange(exchange_key):
fields = [
_build_field(key, label, note, schema, values)
for key, label, note in sec["fields"]
]
groups.append({
"title": sec["title"],
"fields": fields,
"has_restart": any(f.get("restart_required") for f in fields),
})
return groups
def filter_updates_for_ui(exchange_key: str, updates: dict[str, str]) -> dict[str, str]:
allowed = ui_allowed_keys(exchange_key)
return {k: v for k, v in (updates or {}).items() if k in allowed}
def validate_env_ui_updates(
exchange_key: str,
example_path: str,
updates: dict[str, str],
) -> tuple[dict[str, str], list[str]]:
from lib.env.env_schema import validate_env_updates
schema = _schema_field_map(example_path)
groups: list[dict[str, Any]] = []
for sec in ui_sections_for_exchange(exchange_key):
fields: list[dict[str, Any]] = []
for key, _label, _note in sec["fields"]:
if key in schema:
fields.append(schema[key])
else:
default = ""
fields.append(
{
"key": key,
"type": _field_type(key, default),
"sensitive": _is_sensitive(key),
"restart_required": _restart_required(key),
"hot_reload": _hot_reload(key),
}
)
groups.append({"title": sec["title"], "fields": fields})
return validate_env_updates(groups, updates)