Initial standalone crypto_okx with one-click deploy.

Add deploy/manage.sh bootstrap for git.bz121.com/dekun/crypto_okx and point docs at this repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-13 20:00:59 +08:00
commit a1abe159fa
238 changed files with 69214 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
"""读写实例目录 .env(行级 upsert,原子落盘)."""
from __future__ import annotations
import os
import re
import tempfile
from typing import Optional
_KEY_LINE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$")
def parse_env_lines(text: str) -> list[str]:
return text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
def read_env_lines(path: str) -> list[str]:
if not os.path.isfile(path):
return []
with open(path, "r", encoding="utf-8", errors="ignore") as f:
return parse_env_lines(f.read())
def env_get(lines: list[str], key: str) -> Optional[str]:
for line in lines:
m = _KEY_LINE.match(line)
if m and m.group(2) == key:
raw = m.group(3).strip()
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
return raw[1:-1]
return raw
return None
def env_get_all(lines: list[str]) -> dict[str, str]:
out: dict[str, str] = {}
for line in lines:
m = _KEY_LINE.match(line)
if m:
key = m.group(2)
raw = m.group(3).strip()
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
out[key] = raw[1:-1]
else:
out[key] = raw
return out
def upsert_env_line(lines: list[str], key: str, value: str) -> list[str]:
pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=")
out: list[str] = []
replaced = False
safe = value if value is not None else ""
if any(c in safe for c in (' ', '#', '"', "'")):
safe = '"' + safe.replace("\\", "\\\\").replace('"', '\\"') + '"'
new_line = f"{key}={safe}"
for line in lines:
if pat.match(line):
if not replaced:
out.append(new_line)
replaced = True
continue
out.append(line)
if not replaced:
if out and out[-1].strip():
out.append("")
out.append(new_line)
return out
def write_env_lines_atomic(path: str, lines: list[str]) -> None:
directory = os.path.dirname(os.path.abspath(path)) or "."
os.makedirs(directory, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=".env.", dir=directory, text=True)
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write("\n".join(lines))
if lines:
f.write("\n")
os.replace(tmp, path)
finally:
if os.path.exists(tmp):
try:
os.remove(tmp)
except OSError:
pass
def apply_env_updates(path: str, updates: dict[str, str]) -> list[str]:
lines = read_env_lines(path)
changed: list[str] = []
for key, value in updates.items():
if value is None:
continue
old = env_get(lines, key)
if old == value:
continue
lines = upsert_env_line(lines, key, value)
changed.append(key)
if changed:
write_env_lines_atomic(path, lines)
return changed
def load_env_file_into_environ(path: str) -> None:
if not os.path.exists(path):
return
with open(path, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
if text.startswith("\ufeff"):
text = text[1:]
for line in parse_env_lines(text):
s = line.strip()
if not s or s.startswith("#"):
continue
if "=" not in s:
continue
k, _, v = s.partition("=")
clean_key = k.strip()
clean_val = v.strip().strip('"').strip("'")
if clean_key:
os.environ[clean_key] = clean_val
+418
View File
@@ -0,0 +1,418 @@
"""从 .env.example 构建 env 配置 schema(分组,敏感,重启标注)."""
from __future__ import annotations
import os
import re
from typing import Any, Optional
from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
_SEPARATOR_RE = re.compile(r"^#\s*=+\s*$")
_SECTION_DASH_RE = re.compile(r"^#\s*---\s*(.+?)\s*---\s*$")
_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
RESTART_REQUIRED_EXACT = frozenset({
"APP_HOST",
"APP_PORT",
"APP_DEBUG",
"DB_PATH",
"UPLOAD_DIR",
"FLASK_SECRET_KEY",
"POSITION_SIZING_MODE",
"LIVE_TRADING_ENABLED",
"OKX_TD_MODE",
"OKX_POS_MODE",
"OKX_POSITION_INST_TYPE",
"BINANCE_MARGIN_MODE",
"BINANCE_POSITION_MODE",
"GATE_TD_MODE",
"GATE_POS_MODE",
"PM2_APP_NAME",
})
RESTART_REQUIRED_PREFIXES = (
"OKX_API_",
"OKX_OPTIONS_API_",
"BINANCE_API_",
"GATE_API_",
"OKX_SOCKS_",
"OKX_HTTP_",
"OKX_HTTPS_",
"BINANCE_HTTP_",
"BINANCE_HTTPS_",
"GATE_HTTP_",
"GATE_HTTPS_",
)
HOT_RELOAD_EXACT = frozenset({
"RISK_PERCENT",
"MAX_ACTIVE_POSITIONS",
"MANUAL_MIN_PLANNED_RR",
"DAILY_OPEN_ALERT_THRESHOLD",
"DAILY_OPEN_HARD_LIMIT",
"TRADING_DAY_RESET_HOUR",
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"RISK_CONTROL_ENABLED",
"RISK_COOLING_HOURS_MANUAL",
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
"RISK_DAILY_LOSS_LIMIT",
"RISK_MOOD_ISSUES_DAILY_FREEZE",
"TRADE_DIRECTION_RESTRICT_ENABLED",
"TRADE_DIRECTION",
"TRADE_SYMBOL_RESTRICT_ENABLED",
"TRADE_SYMBOL_WHITELIST",
"BALANCE_REFRESH_SECONDS",
"PRICE_REFRESH_SECONDS",
"MONITOR_POLL_SECONDS",
"AUTO_TRANSFER_ENABLED",
"AUTO_TRANSFER_AMOUNT",
"AUTO_TRANSFER_FROM",
"AUTO_TRANSFER_TO",
"AUTO_TRANSFER_BJ_HOUR",
"TRANSFER_CCY",
"FORCE_CLOSE_ENABLED",
"FORCE_CLOSE_BJ_HOUR",
"FORCE_CLOSE_GRACE_MINUTES",
"BTC_LEVERAGE",
"ALT_LEVERAGE",
"DAILY_START_CAPITAL",
"DAILY_LOSS_CAPITAL",
"DAILY_PROFIT_CAPITAL",
"FULL_MARGIN_BUFFER_RATIO",
"APP_USERNAME",
"APP_PASSWORD",
"APP_AUTH_DISABLED",
"WECHAT_WEBHOOK",
"HEDGE_PLAN_ENABLED",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
"OKX_SHOW_PERP_FUNDS",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_DTE_DAYS",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"OKX_OPTIONS_BUDGET_BUFFER",
"OKX_TRADE_MODE",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_LIVE_ORDER",
"HEDGE_PLAN_OPTION_PRIMARY",
"HEDGE_PLAN_OPEN_ORDER",
"HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS",
"HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS",
"HEDGE_PLAN_OO_CLOSE_WINNER_ONLY",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"HEDGE_PLAN_OO_BIAS_RATIO",
"HEDGE_PLAN_BUDGET_BUFFER",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"MAX_ACTIVE_HEDGE_PLANS",
"HEDGE_PLAN_MONITOR_POLL_SECONDS",
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
})
SENSITIVE_EXACT = frozenset({
"APP_PASSWORD",
"FLASK_SECRET_KEY",
"OPENAI_API_KEY",
})
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
# env 配置页下拉:value → 中文标签
SELECT_OPTIONS: dict[str, tuple[tuple[str, str], ...]] = {
"OKX_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
"OKX_POS_MODE": (("hedge", "双向"), ("net", "单向净持仓")),
"BINANCE_MARGIN_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
"BINANCE_POSITION_MODE": (("hedge", "双向"), ("one_way", "单向")),
"GATE_TD_MODE": (("cross", "全仓"), ("isolated", "逐仓")),
"GATE_POS_MODE": (("hedge", "双向"), ("single", "单向")),
"POSITION_SIZING_MODE": (("risk", "以损定仓"), ("full_margin", "全仓杠杆")),
"TRADE_DIRECTION": (
("both", "双向均可"),
("long_only", "仅做多"),
("short_only", "仅做空"),
),
"AUTO_TRANSFER_FROM": (
("funding", "funding 资金账户"),
("swap", "swap 交易账户"),
("spot", "spot 现货"),
),
"AUTO_TRANSFER_TO": (
("swap", "swap 交易账户"),
("funding", "funding 资金账户"),
("spot", "spot 现货"),
),
"TRANSFER_CCY": (("USDT", "USDT"),),
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": (
("budget", "预算金额"),
("sheets", "张数"),
),
"OKX_TRADE_MODE": (
("options", "单独期权"),
("perp_options", "永期对冲"),
("options_options", "期期对冲"),
),
"HEDGE_PLAN_OPTION_PRIMARY": (
("true", "以期权为主"),
("false", "保险模式"),
),
}
_SELECT_ALIASES: dict[str, dict[str, str]] = {
"OKX_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"BINANCE_MARGIN_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"GATE_TD_MODE": {"cross_margin": "cross", "isolated_margin": "isolated"},
"TRANSFER_CCY": {"usdt": "USDT"},
}
def _is_sensitive(key: str) -> bool:
if key in SENSITIVE_EXACT:
return True
return any(s in key for s in SENSITIVE_SUBSTR)
def select_options_for(key: str) -> list[dict[str, str]]:
opts = SELECT_OPTIONS.get(key) or ()
return [{"value": v, "label": lab} for v, lab in opts]
def normalize_select_value(key: str, value: Optional[str]) -> str:
raw = (value or "").strip()
if not raw:
return ""
low = raw.lower()
aliases = _SELECT_ALIASES.get(key) or {}
if low in aliases:
return aliases[low]
allowed = {v for v, _ in (SELECT_OPTIONS.get(key) or ())}
allowed_by_lower = {v.lower(): v for v in allowed}
if low in allowed:
return low
if raw in allowed:
return raw
if low in allowed_by_lower:
return allowed_by_lower[low]
return raw
def _restart_required(key: str) -> bool:
if key in HOT_RELOAD_EXACT:
return False
if key in RESTART_REQUIRED_EXACT:
return True
return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
def _hot_reload(key: str) -> bool:
if key in HOT_RELOAD_EXACT:
return True
if _restart_required(key):
return False
return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
def _field_type(key: str, value: str) -> str:
if key in SELECT_OPTIONS:
return "select"
low = (value or "").strip().lower()
if low in ("true", "false"):
return "bool"
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_") or key in (
"OKX_SHOW_PERP_FUNDS",
"HEDGE_PLAN_SHOW_PERP_OPTIONS",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS",
):
return "bool"
try:
if "." in low:
float(low)
return "float"
int(low)
return "int"
except ValueError:
pass
return "text"
def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
if value is None or value == "":
return {"value": "", "masked": "", "tail": "", "has_value": False}
if not _is_sensitive(key):
return {"value": value, "masked": value, "tail": "", "has_value": True}
tail = value[-4:] if len(value) >= 4 else value
return {"value": "", "masked": f"****{tail}", "tail": tail, "has_value": True}
def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
if not os.path.isfile(example_path):
return []
lines = read_env_lines(example_path)
groups: list[dict[str, Any]] = []
group_map: dict[str, dict[str, Any]] = {}
current_group = "基础配置"
pending_note: list[str] = []
in_section_block = False
section_title_set = False
allow_section_blocks = False
def _ensure_group(title: str) -> dict[str, Any]:
title = (title or "").strip() or "其他"
if title not in group_map:
group_map[title] = {"title": title, "fields": []}
groups.append(group_map[title])
return group_map[title]
for raw in lines:
line = raw.rstrip()
stripped = line.strip()
if not stripped:
pending_note = []
continue
if _SEPARATOR_RE.match(stripped):
if not allow_section_blocks:
continue
if not in_section_block:
in_section_block = True
section_title_set = False
else:
in_section_block = False
continue
if in_section_block and stripped.startswith("#"):
note = stripped.lstrip("#").strip()
if note and not section_title_set:
current_group = note
_ensure_group(current_group)
section_title_set = True
elif note:
pending_note.append(note)
continue
gm = _GROUP_RE.match(stripped)
if gm:
title = gm.group(1).strip()
if title and title != "=":
current_group = title
_ensure_group(current_group)
in_section_block = False
section_title_set = False
pending_note = []
continue
dash = _SECTION_DASH_RE.match(stripped)
if dash:
allow_section_blocks = True
current_group = dash.group(1).strip()
_ensure_group(current_group)
in_section_block = False
section_title_set = False
pending_note = []
continue
if stripped.startswith("#"):
note = stripped.lstrip("#").strip()
if note and not note.startswith("="):
pending_note.append(note)
continue
km = _KEY_LINE.match(stripped)
if not km:
continue
key = km.group(1)
allow_section_blocks = True
default_val = env_get(lines, key) or ""
grp = _ensure_group(current_group)
note = " ".join(pending_note).strip()
grp["fields"].append(
{
"key": key,
"label": key,
"note": note,
"default": default_val,
"type": _field_type(key, default_val),
"sensitive": _is_sensitive(key),
"restart_required": _restart_required(key),
"hot_reload": _hot_reload(key),
}
)
pending_note = []
return [g for g in groups if g.get("fields")]
def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
groups = parse_env_example_schema(example_path)
env_lines = read_env_lines(env_path)
values = env_get_all(env_lines)
for group in groups:
for field in group.get("fields") or []:
key = field["key"]
val = values.get(key)
if val is None:
val = field.get("default") or ""
masked = _mask_value(key, val)
field["current"] = masked["value"] if not field["sensitive"] else ""
field["masked"] = masked["masked"]
field["has_value"] = masked["has_value"]
return {"groups": groups}
def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
allowed = {}
for group in groups:
for field in group.get("fields") or []:
allowed[field["key"]] = field
clean: dict[str, str] = {}
errors: list[str] = []
for key, value in (updates or {}).items():
if key not in allowed:
errors.append(f"未知配置项: {key}")
continue
if value is None:
continue
val = str(value).strip()
if allowed[key].get("sensitive") and (val == "" or (val.startswith("****") and len(val) <= 8)):
continue
# API Key 被密码管理器/自动填充成登录密码时通常很短;OKX Key 一般为 36 位
if key.endswith("_API_KEY") and 0 < len(val) < 16:
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
continue
ftype = allowed[key].get("type")
if ftype == "bool":
low = val.lower()
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
errors.append(f"{key} 须为 true/false")
continue
val = "true" if low in ("true", "1", "yes", "on") else "false"
elif ftype == "select" or key in SELECT_OPTIONS:
allowed_vals = {
str(o.get("value") if isinstance(o, dict) else o[0]).lower()
for o in (allowed[key].get("options") or select_options_for(key))
}
norm = normalize_select_value(key, val)
if allowed_vals and norm.lower() not in allowed_vals:
labels = " / ".join(
f"{o['value']}({o['label']})" if isinstance(o, dict) else f"{o[0]}({o[1]})"
for o in (allowed[key].get("options") or select_options_for(key))
)
errors.append(f"{key} 须为: {labels}")
continue
val = norm
clean[key] = val
return clean, errors
def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
field_map = {}
for group in groups:
for field in group.get("fields") or []:
field_map[field["key"]] = field
for key in changed_keys:
meta = field_map.get(key) or {}
if meta.get("restart_required"):
return True
if not meta.get("hot_reload"):
return True
return False
+561
View File
@@ -0,0 +1,561 @@
"""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,
normalize_select_value,
parse_env_example_schema,
select_options_for,
)
# 各所「交易所与实盘」字段(顺序即页面顺序)
_EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
"okx": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("OKX_API_KEY", "API Key", "账户 API(永续+期权共用)"),
("OKX_API_SECRET", "API Secret", "账户 API(永续+期权共用)"),
("OKX_API_PASSPHRASE", "API Passphrase", "OKX 必填"),
("OKX_TD_MODE", "保证金模式", ""),
("OKX_POS_MODE", "持仓模式", ""),
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
("OKX_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
(
"OKX_SHOW_PERP_FUNDS",
"显示永续资金",
"默认开启;关闭后顶栏隐藏 USDT 资金账户与交易账户,总资金仅计期权 USDC 侧",
),
],
"binance": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("BINANCE_API_KEY", "API Key", "永续子账户"),
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
("BINANCE_MARGIN_MODE", "保证金模式", ""),
("BINANCE_POSITION_MODE", "持仓模式", ""),
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
"gate": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("GATE_API_KEY", "API Key", "永续子账户"),
("GATE_API_SECRET", "API Secret", "永续子账户"),
("GATE_TD_MODE", "保证金模式", ""),
("GATE_POS_MODE", "持仓模式", ""),
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
}
_SHARED_SECTIONS: list[dict[str, Any]] = [
{
"title": "企业微信",
"fields": [
("WECHAT_WEBHOOK", "机器人 Webhook", "行情与风控推送地址"),
("WECHAT_TIMEOUT_SECONDS", "推送超时(秒)", "默认 10"),
],
},
{
"title": "交易执行",
"fields": [
("POSITION_SIZING_MODE", "计仓模式", "切换须无仓后重启"),
("RISK_PERCENT", "以损定仓风险%", "单笔风险占资金比例"),
("FULL_MARGIN_BUFFER_RATIO", "全仓资金缓冲比例", "如 0.98"),
("BTC_LEVERAGE", "BTC 默认杠杆", ""),
("ALT_LEVERAGE", "山寨默认杠杆", ""),
("TRADE_DIRECTION_RESTRICT_ENABLED", "方向限制开关", ""),
("TRADE_DIRECTION", "允许方向", "需同时开启「方向限制开关」才生效"),
("TRADE_SYMBOL_RESTRICT_ENABLED", "币种白名单开关", ""),
("TRADE_SYMBOL_WHITELIST", "白名单币种", "逗号分隔,如 BTC,ETH"),
("TRADING_DAY_RESET_HOUR", "交易日切点(北京时间)", "整点,默认 8"),
(
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
"切点前禁止新开仓",
"默认 true;开启则北京时间切点前禁止斐波登记与人工开仓;说明见风控说明·交易执行",
),
("MAX_ACTIVE_POSITIONS", "最大同时持仓", ""),
("MANUAL_MIN_PLANNED_RR", "人工最低盈亏比", "如 1.4"), ("FORCE_CLOSE_ENABLED", "强制清仓开关", ""),
("FORCE_CLOSE_BJ_HOUR", "强制清仓整点(北京)", ""),
("FORCE_CLOSE_GRACE_MINUTES", "强制清仓窗口(分钟)", "默认 5;整点起该分钟内执行并禁止开仓"),
],
},
{
"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_DAILY_LOSS_LIMIT", "日亏损次数上限", "默认2;达限当日冻结开仓;0=不因亏损次数冻结"),
("RISK_MOOD_ISSUES_DAILY_FREEZE", "情绪标签日冻结", ""),
],
},
{
"title": "自动划转",
"fields": [
("AUTO_TRANSFER_ENABLED", "启用自动划转", ""),
("AUTO_TRANSFER_AMOUNT", "目标余额(U)", "交易账户目标 USDT"),
("AUTO_TRANSFER_FROM", "划出账户", "余额不足时从此账户划入交易账户"),
("AUTO_TRANSFER_TO", "划入账户", "目标余额所在账户,一般为 swap"),
("AUTO_TRANSFER_BJ_HOUR", "执行整点(北京时间)", ""),
("TRANSFER_CCY", "划转币种", ""),
],
},
{
"title": "当日资金",
"fields": [
("DAILY_START_CAPITAL", "日起始基数(U)", ""),
("DAILY_LOSS_CAPITAL", "回撤后基数(U)", ""),
("DAILY_PROFIT_CAPITAL", "盈利后基数(U)", ""),
],
},
]
_MODE_SECTION: dict[str, Any] = {
"title": "期权/对冲模式",
"exchanges": frozenset({"okx"}),
"fields": [
(
"OKX_TRADE_MODE",
"交易模式",
"三选一:单独期权 / 永期对冲 / 期期对冲.选单独期权时隐藏对冲导航与对冲配置;选对冲时不可单独开期权",
),
],
}
_OPTIONS_SECTION: dict[str, Any] = {
"title": "期权账户",
"exchanges": frozenset({"okx"}),
"fields": [
("OKX_OPTIONS_ENABLED", "启用期权模块", "与永续共用上方 OKX_API_*;不再单独配置期权密钥"),
("OKX_OPTIONS_ACCOUNT_LABEL", "期权账户备注", ""),
(
"OKX_OPTIONS_TRADE_BUDGET_USDC",
"单笔预算(USDC)",
"仅全仓复利关闭时显示/生效;用于「按可用余额打满」及张数/币数上限",
),
("OKX_OPTIONS_BUDGET_BUFFER", "预算缓冲比例", "如 0.95;打满/全仓复利共用"),
(
"OKX_OPTIONS_COMPOUND_FULL_ENABLED",
"全仓复利开关",
"默认 true;开启时隐藏单笔预算且不可用打满预算,下单以全仓复利为主;关闭则恢复单笔预算并隐藏全仓复利",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_ENABLED",
"全仓复利上限开关",
"仅全仓复利开启时有意义;默认 false=不设上限用期权户全部可用;true 时按下方上限封顶",
),
(
"OKX_OPTIONS_COMPOUND_FULL_CAP_USDC",
"全仓复利上限(USDC)",
"仅「全仓复利」且「上限开关」都开启时生效;例如 300",
),
(
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS",
"期权持仓上限(笔)",
"仅「单独期权」模式生效;默认 0=不限制;按交易所期权合约笔数计数,同合约加仓不占新笔数",
),
("OKX_OPTIONS_DEFAULT_UNDERLY", "默认标的", "如 ETH"),
(
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS",
"期权链展示天数",
"默认 14;下拉到期日只出现该天数内的合约(含明天)",
),
(
"OKX_OPTIONS_MAX_DTE_DAYS",
"开仓最大剩余天数",
"默认 2;单独开期权时拒绝更远到期(与链展示天数独立)",
),
(
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED",
"链上仅显示有卖一",
"默认 true;开启后隐藏无卖一深度或深度不足1张的合约(含标记价估算行)",
),
],
}
# 对冲公共字段(不含已由 OKX_TRADE_MODE 取代的 ENABLED/SHOW/MUTUAL)
_HEDGE_COMMON_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_LIVE_ORDER", "允许对冲真实下单", "再与实盘 LIVE_TRADING_ENABLED 同开才可启动"),
(
"MAX_ACTIVE_HEDGE_PLANS",
"对冲组数上限",
"默认 1;同时进行中的对冲计划组数(opening/active/partial),可改",
),
("HEDGE_PLAN_MONITOR_POLL_SECONDS", "对冲监控轮询(秒)", "默认 15"),
(
"HEDGE_PLAN_BUDGET_BUFFER",
"对冲预算缓冲比例",
"默认 0.95;仅对冲计划;与期权页 OKX_OPTIONS_BUDGET_BUFFER 独立",
),
(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL",
"半腿失败改手动补开",
"默认 true;开启时半腿失败不自动平,计划挂 partial,页面可补开;并强制关闭下方自动平",
),
(
"HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION",
"半腿失败时自动平期权",
"默认 true;若上方「半腿失败改手动补开」开启则本项强制无效",
),
]
_HEDGE_PO_FIELDS: list[tuple[str, str, str]] = [
(
"HEDGE_PLAN_OPTION_PRIMARY",
"永期模式(以期权为主/保险)",
"默认 true=以期权为主;false=保险模式;页面标题前显示标识,不可在页内切换",
),
("HEDGE_PLAN_OPEN_ORDER", "永期开仓顺序", "options_first 或 perp_first"),
("HEDGE_PLAN_ON_PERP_SL_CLOSE_OPTIONS", "永期止损后强制平期权", "保护机制,建议保持 true"),
("HEDGE_PLAN_ON_PERP_TP_CLOSE_OPTIONS", "永期止盈后强制平期权", "默认 false,保险腿不平"),
(
"HEDGE_PLAN_ITM_MAX_DIST_USD",
"永期实值最大深度(U)",
"默认空=沿用 OKX_OPTIONS_ITM_MAX_DIST_USD(常 30);0=不限制",
),
(
"HEDGE_PLAN_MIN_OPTION_HOURS",
"对冲期权最低剩余小时",
"默认 8;测算/启动时若传 hours_to_expiry 则校验",
),
(
"HEDGE_PLAN_MIN_OPTION_LEVERAGE",
"对冲期权最低杠杆(S/ask)",
"默认 0=不启用;>0 时拒绝杠杆过低的保险腿",
),
]
_HEDGE_OO_FIELDS: list[tuple[str, str, str]] = [
("HEDGE_PLAN_OO_CLOSE_WINNER_ONLY", "期期只平盈利腿", "达目标价只平盈利方"),
(
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED",
"期期平仓模式(方案C)",
"默认 true;开启后页面可选「到期平/全平」;关闭则固定到期平",
),
(
"HEDGE_PLAN_OO_BIAS_SPLIT_BY",
"期期做多做空拆分口径",
"默认预算金额;budget=按权利金预算分两腿;sheets=先算同张数再按比例拆",
),
(
"HEDGE_PLAN_OO_BIAS_RATIO",
"期期做多做空主腿占比",
"默认 0.7(即 7:3);做多主腿=Call,做空主腿=Put;须在 0~1 之间",
),
]
# 兼容旧测试/全量字段列表(写 env 时仍允许这些键,但 UI 按模式过滤)
_HEDGE_PLAN_SECTION: dict[str, Any] = {
"title": "对冲计划",
"exchanges": frozenset({"okx"}),
"fields": [
("HEDGE_PLAN_ENABLED", "启用对冲计划", "已由「交易模式」取代,一般无需再改"),
("HEDGE_PLAN_SHOW_PERP_OPTIONS", "显示永期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", "显示期期对冲", "已由「交易模式」取代"),
("HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE", "对冲与期权互斥门控", "已由「交易模式」三选一取代"),
*_HEDGE_COMMON_FIELDS,
*_HEDGE_PO_FIELDS,
*_HEDGE_OO_FIELDS,
],
}
# 与运行时 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_DAILY_LOSS_LIMIT": "2",
"RISK_MOOD_ISSUES_DAILY_FREEZE": "true",
"AUTO_TRANSFER_FROM": "funding",
"AUTO_TRANSFER_TO": "swap",
"TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"OKX_SHOW_PERP_FUNDS": "true",
"OKX_OPTIONS_CHAIN_ASK_LIQ_FILTER_ENABLED": "true",
"OKX_OPTIONS_CHAIN_MAX_DTE_DAYS": "14",
"OKX_OPTIONS_MAX_DTE_DAYS": "2",
"OKX_OPTIONS_MAX_ACTIVE_POSITIONS": "0",
"OKX_TRADE_MODE": "options",
"MAX_ACTIVE_HEDGE_PLANS": "1",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
"HEDGE_PLAN_OO_BIAS_RATIO": "0.7",
"HEDGE_PLAN_BUDGET_BUFFER": "0.95",
"HEDGE_PLAN_OPTION_PRIMARY": "true",
"HEDGE_PLAN_OPTIONS_MUTUAL_EXCLUSIVE": "true",
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL": "true",
}
def _effective_env_value(key: str, file_values: dict[str, str], schema_default: str = "") -> str:
if key == "OKX_TRADE_MODE":
# 展示值必须与运行时 get_okx_trade_mode() 一致,避免未写入时默认 options 静默改模式
file_val = str(file_values.get(key) or "").strip() if key in file_values else ""
if file_val:
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
return normalize_okx_trade_mode(file_val) or file_val
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
pass
if key in file_values:
file_val = str(file_values.get(key) or "").strip()
if file_val:
return file_val
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 _env_truthy(raw: str) -> bool:
return str(raw or "").strip().lower() in ("1", "true", "yes", "on")
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)
# 与运行时一致:手动补开开启时,「自动平期权」展示为关闭(实际也不会执行)
if key == "HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION":
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", values, "true"
)
if _env_truthy(manual):
val = "false"
masked = _mask_value(key, val)
ftype = meta.get("type") or _field_type(key, val or schema_default)
options = select_options_for(key)
if options:
ftype = "select"
val = normalize_select_value(key, val) or val
masked = _mask_value(key, val)
out: dict[str, Any] = {
"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"],
}
if options:
cur = (out["current"] or out["default"] or "").strip()
opt_vals = {o["value"] for o in options}
if cur and cur not in opt_vals:
options = [{"value": cur, "label": cur}] + options
out["options"] = options
return out
def _okx_mode_for_env_ui() -> str:
try:
from lib.hedge_plan.okx_trade_mode_lib import get_okx_trade_mode
return get_okx_trade_mode()
except Exception:
return "options"
def _options_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
fields = list(_OPTIONS_SECTION["fields"])
if mode != "options":
fields = [f for f in fields if f[0] != "OKX_OPTIONS_MAX_ACTIVE_POSITIONS"]
return fields
def _hedge_fields_for_mode(mode: str) -> list[tuple[str, str, str]]:
if mode == "perp_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_PO_FIELDS]
if mode == "options_options":
return [*_HEDGE_COMMON_FIELDS, *_HEDGE_OO_FIELDS]
return []
def ui_sections_for_exchange(
exchange_key: str,
*,
mode: str | None = None,
) -> 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 _MODE_SECTION.get("exchanges", frozenset()):
from lib.hedge_plan.okx_trade_mode_lib import normalize_okx_trade_mode
m = normalize_okx_trade_mode(mode) if mode else ""
if not m:
m = _okx_mode_for_env_ui()
sections.append(_MODE_SECTION)
sections.append({"title": "期权账户", "fields": _options_fields_for_mode(m)})
hedge_fields = _hedge_fields_for_mode(m)
if hedge_fields:
title = "对冲计划·永期" if m == "perp_options" else "对冲计划·期期"
sections.append({"title": title, "fields": hedge_fields})
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])
ex = (exchange_key or "").strip().lower()
if ex == "okx":
keys.add("OKX_TRADE_MODE")
# 允许写入遗留键,避免旧自动化/手改失败;页面不再展示
for item in _HEDGE_PLAN_SECTION["fields"]:
keys.add(item[0])
for item in _OPTIONS_SECTION["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, mode=values.get("OKX_TRADE_MODE") or ""
):
fields = [
_build_field(key, label, note, schema, values)
for key, label, note in sec["fields"]
]
fields = _mark_compound_budget_hidden(fields)
groups.append({
"title": sec["title"],
"fields": fields,
"has_restart": any(f.get("restart_required") for f in fields),
})
return groups
def _mark_compound_budget_hidden(fields: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""全仓复利开启时标记单笔预算为 hidden(供 SSR/前端隐藏;切换开关仍可再显示)."""
compound_on = True
for f in fields:
if f.get("key") == "OKX_OPTIONS_COMPOUND_FULL_ENABLED":
compound_on = _env_truthy(str(f.get("current") or f.get("default") or "true"))
break
if not compound_on:
return fields
out: list[dict[str, Any]] = []
for f in fields:
if f.get("key") == "OKX_OPTIONS_TRADE_BUDGET_USDC":
item = dict(f)
item["hidden"] = True
out.append(item)
else:
out.append(f)
return out
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:
field = dict(schema[key])
opts = select_options_for(key)
if opts:
field["type"] = "select"
field["options"] = opts
fields.append(field)
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),
"options": select_options_for(key),
}
)
groups.append({"title": sec["title"], "fields": fields})
return validate_env_updates(groups, updates)
def coerce_hedge_partial_close_with_manual(
clean: dict[str, str],
*,
env_path: str = "",
) -> dict[str, str]:
"""手动补开为开启时,强制把自动平写成 false(与运行时一致)."""
out = dict(clean or {})
manual = out.get("HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL")
if manual is None and env_path:
try:
from lib.env.env_file_lib import env_get_all, read_env_lines
file_vals = env_get_all(read_env_lines(env_path))
manual = _effective_env_value(
"HEDGE_PLAN_MANUAL_COMPLETE_ON_PARTIAL", file_vals, "true"
)
except Exception:
manual = "true"
if _env_truthy(str(manual or "")):
out["HEDGE_PLAN_PARTIAL_AUTO_CLOSE_OPTION"] = "false"
return out
+190
View File
@@ -0,0 +1,190 @@
"""Local AI env helpers (standalone project)."""
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from lib.env.env_file_lib import apply_env_updates, env_get_all, load_env_file_into_environ, read_env_lines
from lib.env.env_schema import (
_field_type,
_hot_reload,
_is_sensitive,
_mask_value,
_restart_required,
parse_env_example_schema,
validate_env_updates,
)
from lib.paths import REPO_ROOT
AI_ENV_FIELDS: list[tuple[str, str, str]] = [
("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"),
]
AI_ENV_KEYS = frozenset(k for k, _l, _n in AI_ENV_FIELDS)
def local_env_path() -> str:
return str(REPO_ROOT / ".env")
def local_example_path() -> str:
return str(REPO_ROOT / ".env.example")
def instance_example_path(exchange_key: str = "okx") -> str:
return local_example_path()
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 = values.get(key, "")
if val == "" and schema_default:
val = 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 build_ai_env_payload(env_path: str | None = None, example_path: str | None = None) -> dict[str, Any]:
env_path = env_path or local_env_path()
example_path = example_path or local_example_path()
schema = _schema_field_map(example_path)
values = env_get_all(read_env_lines(env_path))
fields = [
_build_field(key, label, note, schema, values)
for key, label, note in AI_ENV_FIELDS
]
sync_status = ai_sync_status()
return {
"title": "AI 复盘",
"fields": fields,
"sync_status": sync_status,
}
def ai_sync_status() -> dict[str, Any]:
"""Standalone project: no multi-instance hub sync."""
path = local_env_path()
if not os.path.isfile(path):
return {"all_synced": False, "instances": {"local": {"ok": False, "msg": "缺少 .env"}}}
return {"all_synced": True, "instances": {"local": {"ok": True, "mismatched_keys": []}}}
def _ai_validate_groups(example_path: str) -> list[dict[str, Any]]:
schema = _schema_field_map(example_path)
fields: list[dict[str, Any]] = []
for key, _label, _note in AI_ENV_FIELDS:
if key in schema:
fields.append(schema[key])
else:
fields.append(
{
"key": key,
"type": _field_type(key, ""),
"sensitive": _is_sensitive(key),
"restart_required": _restart_required(key),
"hot_reload": _hot_reload(key),
}
)
return [{"title": "AI 复盘", "fields": fields}]
def validate_ai_env_updates(updates: dict[str, str], example_path: str | None = None) -> tuple[dict[str, str], list[str]]:
example_path = example_path or local_example_path()
groups = _ai_validate_groups(example_path)
filtered = {k: v for k, v in (updates or {}).items() if k in AI_ENV_KEYS}
unknown = [k for k in (updates or {}) if k not in AI_ENV_KEYS]
errors = [f"未知配置项: {k}" for k in unknown]
clean, val_errors = validate_env_updates(groups, filtered)
errors.extend(val_errors)
return clean, errors
def apply_ai_env_to_all(updates: dict[str, str]) -> dict[str, Any]:
"""Write AI keys to local .env only."""
clean, errors = validate_ai_env_updates(updates)
if errors:
return {"ok": False, "errors": errors, "changed": {}}
if not clean:
return {"ok": True, "changed": {}, "restart_required": False}
path = local_env_path()
changed_keys = apply_env_updates(path, clean)
if changed_keys:
load_env_file_into_environ(path)
restart_required = any(
(not _hot_reload(k)) or _restart_required(k) for k in changed_keys
)
return {
"ok": True,
"changed": {"local": list(changed_keys)},
"restart_required": restart_required,
}
def restart_local_pm2() -> dict[str, Any]:
"""Restart this app via PM2 if configured."""
return _restart_pm2_app("crypto_okx")
def _restart_pm2_app(app_name: str) -> dict[str, Any]:
try:
proc = subprocess.run(
["pm2", "restart", app_name, "--update-env"],
capture_output=True,
text=True,
timeout=120,
)
return {
"ok": proc.returncode == 0,
"msg": (proc.stdout or proc.stderr or "").strip()[:500],
"returncode": proc.returncode,
}
except FileNotFoundError:
return {"ok": False, "msg": "未找到 pm2 命令"}
except subprocess.TimeoutExpired:
return {"ok": False, "msg": "pm2 restart 超时"}
except Exception as e:
return {"ok": False, "msg": str(e)}