交易所 API 改为仅服务器配置:前端去掉密钥、新机示例为空,并防止坏钥反复请求触发 Gate 封 IP。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-25 12:21:43 +08:00
parent f675f9997a
commit 75f50fe083
15 changed files with 350 additions and 60 deletions
+3 -4
View File
@@ -76,10 +76,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 Binance 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true
# Binance API Key(需开通合约,万向划转等权限)
BINANCE_API_KEY=REPLACE_WITH_BINANCE_API_KEY
# Binance API Secret
BINANCE_API_SECRET=REPLACE_WITH_BINANCE_API_SECRET
# Binance API(仅服务器 .env 配置;新机保持为空,填真钥后 pm2 restart --update-env;勿用占位符以免鉴权狂打)
BINANCE_API_KEY=
BINANCE_API_SECRET=
# 保证金模式:cross=全仓,isolated=逐仓
BINANCE_MARGIN_MODE=cross
# 持仓模式:hedge=双向(需账户开启双向持仓,下单带 positionSide);oneway=单向
+40 -5
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
@@ -347,8 +352,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
BINANCE_API_KEY = (os.getenv("BINANCE_API_KEY") or "").strip()
BINANCE_API_SECRET = (os.getenv("BINANCE_API_SECRET") or "").strip()
BINANCE_API_KEY = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
BINANCE_API_SECRET = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
BINANCE_MARGIN_MODE = (os.getenv("BINANCE_MARGIN_MODE") or "cross").strip().lower()
# hedge=双向持仓(需 positionSide);oneway / single=单向持仓
_raw_binance_pos = (os.getenv("BINANCE_POSITION_MODE") or "hedge").strip().lower()
@@ -490,6 +495,8 @@ if BINANCE_API_KEY and BINANCE_API_SECRET:
exchange.apiKey = BINANCE_API_KEY
exchange.secret = BINANCE_API_SECRET
MARKETS_LOADED = False
# 鉴权失败后停止私有 API(资金/持仓),避免坏钥反复请求;尤其 Gate 易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0,
"funding_usdt": None,
@@ -2859,6 +2866,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (BINANCE_API_KEY and BINANCE_API_SECRET):
@@ -2893,9 +2902,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(BINANCE_API_KEY and BINANCE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, BINANCE_API_KEY, BINANCE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
BINANCE_API_KEY = ""
BINANCE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _float_balance_field(val):
if val is None or val == "":
return None
@@ -3178,11 +3201,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_binance_funding_usdt()
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_binance_swap_usdt_total()
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -3585,7 +3612,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False):
global MARKETS_LOADED
if force or not MARKETS_LOADED:
exchange.load_markets(reload=force)
try:
exchange.load_markets(reload=force)
except Exception as e:
# 坏钥时立刻去掉签名再拉公开 markets,避免反复鉴权(尤其勿拖累同机 Gate)
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True
+3 -4
View File
@@ -74,10 +74,9 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 Gate 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true
# Gate API Key(实盘)
GATE_API_KEY=REPLACE_WITH_GATE_API_KEY
# Gate API Secret(实盘)
GATE_API_SECRET=REPLACE_WITH_GATE_API_SECRET
# Gate API(仅服务器 .env 配置;新机保持为空,填真钥后重启;错误密钥反复请求易导致 Gate 封 IP)
GATE_API_KEY=
GATE_API_SECRET=
# 保证金模式:cross=全仓,isolated=逐仓
GATE_TD_MODE=cross
# 持仓筛选:hedge=双向持仓下按多空腿过滤;其它值(如 single)不按腿过滤
+40 -5
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
@@ -346,8 +351,8 @@ def _resolve_app_tz():
APP_TZ = _resolve_app_tz()
LIVE_TRADING_ENABLED = os.getenv("LIVE_TRADING_ENABLED", "false").lower() == "true"
GATE_API_KEY = (os.getenv("GATE_API_KEY") or "").strip()
GATE_API_SECRET = (os.getenv("GATE_API_SECRET") or "").strip()
GATE_API_KEY = normalize_api_credential(os.getenv("GATE_API_KEY"))
GATE_API_SECRET = normalize_api_credential(os.getenv("GATE_API_SECRET"))
GATE_TD_MODE = (os.getenv("GATE_TD_MODE") or "cross").strip().lower()
GATE_POS_MODE = (os.getenv("GATE_POS_MODE") or "hedge").strip().lower()
# 永续仓位止盈止损触发单:POST /futures/{settle}/price_orders,order_type=close-*-position(全平)
@@ -478,6 +483,8 @@ if GATE_API_KEY and GATE_API_SECRET:
exchange.apiKey = GATE_API_KEY
exchange.secret = GATE_API_SECRET
MARKETS_LOADED = False
# 鉴权失败后停止私有 API,避免坏钥反复签名;Gate 尤其易封 IP
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0,
"funding_usdt": None,
@@ -2547,6 +2554,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_exchange_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (GATE_API_KEY and GATE_API_SECRET):
@@ -2581,9 +2590,23 @@ def order_row_key_signal_type(row):
def exchange_private_api_configured():
"""仅表示已配置密钥;与是否允许下单(LIVE_TRADING_ENABLED)无关,用于只读拉仓等."""
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(GATE_API_KEY and GATE_API_SECRET)
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, GATE_API_KEY, GATE_API_SECRET
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
GATE_API_KEY = ""
GATE_API_SECRET = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启;勿反复试错以免 Gate 封 IP): {exc}"
)
def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2841,11 +2864,15 @@ def get_exchange_capitals(force=False):
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
try:
ACCOUNT_BALANCE_CACHE["funding_usdt"] = _fetch_gate_funding_usdt()
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
ACCOUNT_BALANCE_CACHE["funding_usdt"] = None
try:
ACCOUNT_BALANCE_CACHE["trading_usdt"] = _fetch_usdt_by_types(["swap", "spot"])
except Exception:
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
# 勿保留上一次成功请求的旧值:鉴权失败时否则会误以为「合约余额仍能读」
ACCOUNT_BALANCE_CACHE["trading_usdt"] = None
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
@@ -3345,7 +3372,15 @@ def calc_trend_manual_breakeven_stop(direction, entry_price, offset_pct=None):
def ensure_markets_loaded(force=False):
global MARKETS_LOADED
if force or not MARKETS_LOADED:
exchange.load_markets(reload=force)
try:
exchange.load_markets(reload=force)
except Exception as e:
# Gate 对无效签名/坏钥敏感,失败后立即改公开 markets,勿反复带钥请求
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True
+5 -4
View File
@@ -80,12 +80,13 @@ TRADING_DAY_RESET_OPEN_GUARD_ENABLED=true
# 是否开启 OKX 实盘下单(false=只做本地流程,true=真实下单)
LIVE_TRADING_ENABLED=true
# =============================================================================
# OKX 账户 API(永续 + 期权共用同一套密钥;修改后须重启 PM2)
# OKX 账户 API(永续+期权共用;仅服务器 .env 手改,前端不展示)
# 新机保持为空;填真钥后 pm2 restart --update-env(含子代理)
# 旧键 OKX_OPTIONS_API_* 已废弃:若 OKX_API_* 为空,启动时会从 OPTIONS 键回填
# =============================================================================
OKX_API_KEY=REPLACE_WITH_OKX_API_KEY
OKX_API_SECRET=REPLACE_WITH_OKX_API_SECRET
OKX_API_PASSPHRASE=REPLACE_WITH_OKX_API_PASSPHRASE
OKX_API_KEY=
OKX_API_SECRET=
OKX_API_PASSPHRASE=
# 保证金模式:cross=全仓,isolated=逐仓
OKX_TD_MODE=cross
# 持仓模式:hedge=双向持仓,net=单向净持仓
+41 -6
View File
@@ -35,6 +35,11 @@ import sys
if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
from lib.paths import common_static_dir
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
load_markets_public_fallback,
normalize_api_credential,
)
from lib.ai.ai_client import ai_generate, ai_review, ai_short_advice
from lib.ai.ai_review_lib import (
build_journal_ai_chart_path,
@@ -360,9 +365,9 @@ def _promote_legacy_options_api_keys() -> None:
_promote_legacy_options_api_keys()
OKX_API_KEY = os.getenv("OKX_API_KEY", "")
OKX_API_SECRET = os.getenv("OKX_API_SECRET", "")
OKX_API_PASSPHRASE = os.getenv("OKX_API_PASSPHRASE", "")
OKX_API_KEY = normalize_api_credential(os.getenv("OKX_API_KEY"))
OKX_API_SECRET = normalize_api_credential(os.getenv("OKX_API_SECRET"))
OKX_API_PASSPHRASE = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
OKX_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "false").lower() in ("1", "true", "yes", "on")
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
@@ -506,6 +511,7 @@ if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE:
exchange_options.password = OKX_API_PASSPHRASE
MARKETS_LOADED = False
EXCHANGE_AUTH_DISABLED_MSG = ""
ACCOUNT_BALANCE_CACHE = {
"updated_at": 0.0,
"funding_usdt": None,
@@ -2485,6 +2491,8 @@ def enrich_order_item(raw_item, current_capital):
def ensure_okx_live_ready():
if EXCHANGE_AUTH_DISABLED_MSG:
return False, EXCHANGE_AUTH_DISABLED_MSG
if not LIVE_TRADING_ENABLED:
return False, "未开启实盘下单(LIVE_TRADING_ENABLED=false)"
if not (OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE):
@@ -2517,6 +2525,23 @@ def order_row_key_signal_type(row):
return None
def _disable_private_api_after_auth_error(exc):
global EXCHANGE_AUTH_DISABLED_MSG, OKX_API_KEY, OKX_API_SECRET, OKX_API_PASSPHRASE
from lib.exchange.api_credentials_lib import strip_ccxt_credentials
strip_ccxt_credentials(exchange)
try:
strip_ccxt_credentials(exchange_options)
except Exception:
pass
OKX_API_KEY = ""
OKX_API_SECRET = ""
OKX_API_PASSPHRASE = ""
EXCHANGE_AUTH_DISABLED_MSG = (
f"API 鉴权失败,已停止私有请求(请在服务器 .env 修正密钥后重启): {exc}"
)
def _extract_usdt_total(balance):
usdt_info = balance.get("USDT", {}) if isinstance(balance, dict) else {}
total_map = balance.get("total", {}) if isinstance(balance, dict) else {}
@@ -2637,8 +2662,9 @@ def get_exchange_capitals(force=False):
ACCOUNT_BALANCE_CACHE["funding_usdt"] = funding
ACCOUNT_BALANCE_CACHE["trading_usdt"] = trading
ACCOUNT_BALANCE_CACHE["updated_at"] = now_ts
except Exception:
pass
except Exception as e:
if is_exchange_auth_error(e):
_disable_private_api_after_auth_error(e)
return ACCOUNT_BALANCE_CACHE["funding_usdt"], ACCOUNT_BALANCE_CACHE["trading_usdt"]
@@ -2869,7 +2895,14 @@ def build_okx_order_params(direction, reduce_only=False):
def ensure_markets_loaded(force=False):
global MARKETS_LOADED
if force or not MARKETS_LOADED:
exchange.load_markets(reload=force)
try:
exchange.load_markets(reload=force)
except Exception as e:
if is_exchange_auth_error(e) and (getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None)):
_disable_private_api_after_auth_error(e)
load_markets_public_fallback(exchange, reload=True)
else:
raise
MARKETS_LOADED = True
@@ -2985,6 +3018,8 @@ def _okx_place_tp_sl_orders(exchange_symbol, direction, amount, stop_loss, take_
def exchange_private_api_configured():
if EXCHANGE_AUTH_DISABLED_MSG:
return False
return bool(OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE)
+2 -2
View File
@@ -40,9 +40,9 @@ bash /opt/crypto_monitor/deploy/manage.sh
- 登录账号: **admin**
- 登录密码: **admin123**
- 浏览器配置: 各所 **env 配置**(API,风控) + 中控 **系统设置**
- 浏览器配置: 各所 **env 配置**(风控) + 中控 **系统设置**;**交易所 API 仅服务器 `.env` 手改**(新机默认为空)
**无需 SSH 编辑 `.env` 填 API**;密钥由 `bootstrap_deploy_secrets.py` 自动生成.
**无需 SSH 编辑 `.env` 填通信/登录类密钥**;交易所 API 须在服务器写入各所 `.env`(新机为空).
| 地址 | 端口 |
|------|------|
+5 -4
View File
@@ -14,6 +14,7 @@
| **前端仅中文** | 页面只显示中文标签与说明,不显示 `APP_XXX` 等变量名 |
| **账户密码不进本页** | 登录用户名/密码在 **系统设置 → 账户密码修改** 中维护 |
| **密钥自动托管** | 中控通信密钥,登录会话密钥由 **首次部署脚本自动生成并写入**(一次生成,不轮换),本页不提供编辑 |
| **交易所 API 不进本页** | `OKX/BINANCE/GATE_API_*` 仅在服务器实例目录 `.env` 配置;新机默认为空,填真钥后 `pm2 restart --update-env` |
| **AI 仅中控配置** | OpenAI / Ollama 等 AI 项已从中控 **系统设置 → AI 配置** 统一维护并同步三所,本页不再展示 |
| **保存标注** | 每项标注「保存即生效」或「需重启」;含需重启项时可用「保存并重启」 |
@@ -61,15 +62,14 @@ Binance / Gate 无期权模块时,第三列最后一格不显示或显示「本
| 中文名 | 说明 | 重启 |
|--------|------|------|
| 开启实盘下单 | 关闭时仅走本地流程,不向交易所发单 | 需重启 |
| API Key | 账户 API Key(永续+期权共用) | 需重启 |
| API Secret | 账户 API Secret | 需重启 |
| API Passphrase | 仅 OKX 显示 | 需重启 |
| 保证金模式 | 全仓 / 逐仓 | 需重启 |
| 持仓模式 | 双向 / 单向净持仓等(按所) | 需重启 |
| 仓位查询类型 | 仅 OKX:如 SWAP | 需重启 |
| 账户备注 | 企业微信推送中显示的交易所备注 | 保存即生效 |
| 显示永续资金 | 仅 OKX:关闭后顶栏隐藏 USDT 资金/交易账户,总资金仅计期权 USDC 侧 | 保存即生效 |
**交易所 API Key / Secret / Passphrase 不在本页**:请 SSH 编辑各所 `crypto_monitor_*/.env`,新机部署后应为空;配好真钥后重启对应 Flask 与子代理(`pm2 restart … --update-env`).占位符或错误密钥会导致鉴权失败,Gate 上反复请求还可能封 IP.
**本卡片不包含**:网页登录账号密码,是否关闭登录校验,中控通信密钥.
---
@@ -196,6 +196,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
- 服务:`APP_HOST`,`APP_PORT`,`APP_DEBUG`
- 数据:`DB_PATH`,`UPLOAD_DIR`
- **交易所 API**:`OKX_API_*`,`BINANCE_API_*`,`GATE_API_*`(仅 SSH;新机应为空)
- 关键位门控:全部 `KEY_*`,`KLINE_*`
- 轮询与同步:`BALANCE_REFRESH_SECONDS`,`PRICE_REFRESH_SECONDS`,`MONITOR_POLL_SECONDS`,`BREAKEVEN_*`,`RECONCILE_*`
- 代理:`OKX_SOCKS_PROXY`,`BINANCE_HTTP_PROXY`
@@ -212,7 +213,7 @@ AI 相关环境变量(`AI_PROVIDER`,`OPENAI_*`,`OLLAMA_*`,`AI_MODEL`,`AI_TIMEOUT
| 能力 | env 配置 | 系统设置 | 中控系统设置 |
|------|----------|----------|--------------|
| 登录用户名/密码 | ❌ | ✅ 账户密码修改 | ✅ 中控账户密码 |
| 交易所 API | ✅(各所自配) | ❌ | ❌ |
| 交易所 API | ❌(仅服务器 `.env`) | ❌ | ❌ |
| AI / OpenAI | ❌ | ❌ | ✅ AI 配置(同步三所) |
| 导航/区块显示 | ❌ | ✅ 导航显示 | ✅ 显示与导航 |
| 手动资金划转 | ❌ | ✅ 永续资金划转 | ❌ |
+1 -1
View File
@@ -75,7 +75,7 @@ python3 scripts/bootstrap_deploy_secrets.py
## 4. 实例 env 配置页变更
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.交易所 API,企业微信,交易执行等仍各所自配.
三所 **env 配置** 页已 **移除「AI 复盘」卡片**.企业微信交易执行等仍各所自配;**交易所 API 仅服务器 `.env`**,前端不再展示.
实例侧若通过 API 提交已移除的 AI 键,会被白名单过滤,不会写入.
+1 -1
View File
@@ -66,7 +66,7 @@
| 网页登录密码 | ✅ | 本区块 |
| 中控通信密钥 `HUB_BRIDGE_TOKEN` | ❌ | 部署时自动生成,中控与实例一致 |
| 登录会话密钥 `FLASK_SECRET_KEY` | ❌ | 部署时自动生成,三所相同 |
| 交易所 API | ❌ | **env 配置** 页(各所自配) |
| 交易所 API | ❌ | **仅服务器** 各所 `.env`(`*_API_KEY` 等;前端 env 页已移除) |
| AI 复盘 / OpenAI | ❌ | 在中控 **系统设置 → AI 配置**(同步三所) |
### 操作流程
+3 -10
View File
@@ -19,10 +19,7 @@ from lib.env.env_schema import (
# 各所「交易所与实盘」字段(顺序即页面顺序)
_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 必填"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("OKX_TD_MODE", "保证金模式", ""),
("OKX_POS_MODE", "持仓模式", ""),
("OKX_POSITION_INST_TYPE", "仓位查询类型", "如 SWAP"),
@@ -34,17 +31,13 @@ _EXCHANGE_LIVE_FIELDS: dict[str, list[tuple[str, str, str]]] = {
),
],
"binance": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("BINANCE_API_KEY", "API Key", "永续子账户"),
("BINANCE_API_SECRET", "API Secret", "永续子账户"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("BINANCE_MARGIN_MODE", "保证金模式", ""),
("BINANCE_POSITION_MODE", "持仓模式", ""),
("BINANCE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
],
"gate": [
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程,不向交易所发单"),
("GATE_API_KEY", "API Key", "永续子账户"),
("GATE_API_SECRET", "API Secret", "永续子账户"),
("LIVE_TRADING_ENABLED", "开启实盘下单", "关闭时仅走本地流程;API Key 请在服务器实例目录 .env 配置后重启"),
("GATE_TD_MODE", "保证金模式", ""),
("GATE_POS_MODE", "持仓模式", ""),
("GATE_ACCOUNT_LABEL", "账户备注", "企业微信推送中显示"),
+94
View File
@@ -0,0 +1,94 @@
"""交易所 API 凭证规范化.
新机 .env 密钥应为空;示例占位符不得注入 ccxt,否则鉴权失败且
(尤其 Gate)反复签名请求易触发 IP 封禁.
"""
from __future__ import annotations
from typing import Any, Optional
_PLACEHOLDER_EXACT = frozenset(
{
"你的密钥",
"your-api-key",
"your_api_key",
"your-api-secret",
"your_api_secret",
"todo",
"xxx",
"changeme",
}
)
def normalize_api_credential(value: Optional[str]) -> str:
"""去空白;空/占位符一律视为未配置."""
s = (value or "").strip().strip('"').strip("'")
if not s:
return ""
upper = s.upper()
if upper.startswith("REPLACE_WITH"):
return ""
if upper.startswith("CHANGE_TO"):
return ""
if s.lower() in _PLACEHOLDER_EXACT:
return ""
return s
def credentials_configured(*parts: Optional[str]) -> bool:
return all(bool(normalize_api_credential(p)) for p in parts)
def is_exchange_auth_error(exc: BaseException) -> bool:
"""鉴权/无效 Key 类错误(用于停掉后续签名请求,避免 Gate 封 IP)."""
name = type(exc).__name__
if name in ("AuthenticationError", "PermissionDenied", "InvalidNonce"):
return True
msg = str(exc)
markers = (
"Invalid Api-Key",
"Invalid API-key",
"Invalid API Key",
"INVALID_KEY",
"Invalid key",
"API key is invalid",
"api key not found",
"Signature",
"INVALID_SIGNATURE",
"401",
"-2008",
"-2014",
"-2015",
"10003", # Gate: invalid key often
"INVALID_KEY",
)
low = msg.lower()
if "api" in low and ("key" in low or "sign" in low) and (
"invalid" in low or "incorrect" in low or "not found" in low
):
return True
return any(m in msg for m in markers)
def strip_ccxt_credentials(exchange: Any) -> None:
"""内存中清空密钥,后续只走公开接口,避免继续带坏钥签名."""
try:
exchange.apiKey = ""
except Exception:
pass
try:
exchange.secret = ""
except Exception:
pass
try:
exchange.password = ""
except Exception:
pass
def load_markets_public_fallback(exchange: Any, *, reload: bool = False) -> None:
"""鉴权失败后去掉密钥再拉公开 markets(最多再请求一次)."""
strip_ccxt_credentials(exchange)
exchange.load_markets(reload=reload)
+72 -13
View File
@@ -37,6 +37,11 @@ from lib.hub.hub_position_metrics import (
parse_position_unrealized_pnl,
resolve_position_display_upnl,
)
from lib.exchange.api_credentials_lib import (
is_exchange_auth_error,
normalize_api_credential,
strip_ccxt_credentials,
)
import ccxt
from fastapi import FastAPI, Header, HTTPException, Request
@@ -86,12 +91,31 @@ GATE_POS_MODE = "hedge" if _gate_pos in ("hedge", "dual", "double") else "single
app = FastAPI(title="sub-agent", docs_url=None, redoc_url=None)
_ccxt_ex: Any = None
_markets_loaded = False
# 鉴权失败冷却:中控会轮询 /status;坏钥时若持续签名请求,Gate 易封 IP
_AUTH_FAIL_UNTIL = 0.0
_AUTH_FAIL_MSG = ""
_AUTH_COOLDOWN_SEC = 600
def _socks_proxy_url(prefix: str) -> str:
return (os.getenv(f"{prefix}_SOCKS_PROXY") or "").strip()
def _raise_if_auth_cooling() -> None:
if time.time() < _AUTH_FAIL_UNTIL and _AUTH_FAIL_MSG:
raise RuntimeError(_AUTH_FAIL_MSG)
def _mark_auth_failure(exc: BaseException) -> None:
global _AUTH_FAIL_UNTIL, _AUTH_FAIL_MSG, _ccxt_ex, _markets_loaded
_AUTH_FAIL_MSG = f"交易所鉴权失败(已暂停签名请求 {_AUTH_COOLDOWN_SEC}s,避免封 IP): {exc}"
_AUTH_FAIL_UNTIL = time.time() + _AUTH_COOLDOWN_SEC
if _ccxt_ex is not None:
strip_ccxt_credentials(_ccxt_ex)
_ccxt_ex = None
_markets_loaded = False
def _http_https_proxy(prefix: str) -> dict[str, str] | None:
http = (os.getenv(f"{prefix}_HTTP_PROXY") or "").strip()
https = (os.getenv(f"{prefix}_HTTPS_PROXY") or "").strip()
@@ -110,11 +134,12 @@ def _attach_proxies(ex: Any, prefix: str) -> None:
def _make_exchange() -> Any:
_raise_if_auth_cooling()
if EXCHANGE_KIND == "binance":
key = (os.getenv("BINANCE_API_KEY") or "").strip()
secret = (os.getenv("BINANCE_API_SECRET") or "").strip()
key = normalize_api_credential(os.getenv("BINANCE_API_KEY"))
secret = normalize_api_credential(os.getenv("BINANCE_API_SECRET"))
if not key or not secret:
raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET")
raise RuntimeError("缺少 BINANCE_API_KEY / BINANCE_API_SECRET(请在服务器 .env 配置真密钥)")
ex = ccxt.binance(
{
"apiKey": key,
@@ -133,11 +158,11 @@ def _make_exchange() -> Any:
return ex
if EXCHANGE_KIND == "okx":
key = (os.getenv("OKX_API_KEY") or "").strip()
secret = (os.getenv("OKX_API_SECRET") or "").strip()
password = (os.getenv("OKX_API_PASSPHRASE") or "").strip()
key = normalize_api_credential(os.getenv("OKX_API_KEY"))
secret = normalize_api_credential(os.getenv("OKX_API_SECRET"))
password = normalize_api_credential(os.getenv("OKX_API_PASSPHRASE"))
if not key or not secret or not password:
raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE")
raise RuntimeError("缺少 OKX_API_KEY / OKX_API_SECRET / OKX_API_PASSPHRASE(请在服务器 .env 配置真密钥)")
ex = ccxt.okx(
{
"apiKey": key,
@@ -154,10 +179,10 @@ def _make_exchange() -> Any:
return ex
# gate
key = (os.getenv("GATE_API_KEY") or "").strip()
secret = (os.getenv("GATE_API_SECRET") or "").strip()
key = normalize_api_credential(os.getenv("GATE_API_KEY"))
secret = normalize_api_credential(os.getenv("GATE_API_SECRET"))
if not key or not secret:
raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET")
raise RuntimeError("缺少 GATE_API_KEY / GATE_API_SECRET(请在服务器 .env 配置真密钥)")
from lib.exchange.gate_ccxt_lib import gate_ccxt_class
ex = gate_ccxt_class()(
@@ -177,6 +202,7 @@ def _make_exchange() -> Any:
def get_exchange() -> Any:
global _ccxt_ex
_raise_if_auth_cooling()
if _ccxt_ex is None:
_ccxt_ex = _make_exchange()
return _ccxt_ex
@@ -184,9 +210,17 @@ def get_exchange() -> Any:
def _ensure_markets() -> None:
global _markets_loaded
if not _markets_loaded:
if _markets_loaded:
return
_raise_if_auth_cooling()
try:
get_exchange().load_markets()
_markets_loaded = True
except Exception as e:
if is_exchange_auth_error(e):
_mark_auth_failure(e)
raise RuntimeError(_AUTH_FAIL_MSG) from e
raise
def _check_token(x_control_token: str | None) -> None:
@@ -572,8 +606,20 @@ def _status_inner(x_control_token: str | None) -> Any:
u = bal.get("USDT") or {}
if isinstance(u, dict) and u.get("total") is not None:
balance_usdt = _finite_or_none(u["total"])
except Exception:
pass
except Exception as e:
if is_exchange_auth_error(e):
_mark_auth_failure(e)
return JSONResponse(
{
"ok": False,
"error": _AUTH_FAIL_MSG,
"exchange": EXCHANGE_KIND,
"balance_usdt": None,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
positions_out: list[dict[str, Any]] = []
total_upnl = 0.0
@@ -587,6 +633,19 @@ def _status_inner(x_control_token: str | None) -> Any:
else:
raw = ex.fetch_positions() or []
except Exception as e:
if is_exchange_auth_error(e):
_mark_auth_failure(e)
return JSONResponse(
{
"ok": False,
"error": _AUTH_FAIL_MSG,
"exchange": EXCHANGE_KIND,
"balance_usdt": balance_usdt,
"positions": [],
"total_unrealized_pnl": None,
},
status_code=200,
)
return JSONResponse(
{
"ok": False,
+36
View File
@@ -0,0 +1,36 @@
import unittest
from lib.exchange.api_credentials_lib import (
credentials_configured,
is_exchange_auth_error,
normalize_api_credential,
)
class TestNormalizeApiCredential(unittest.TestCase):
def test_empty_and_placeholder(self):
self.assertEqual(normalize_api_credential(""), "")
self.assertEqual(normalize_api_credential(None), "")
self.assertEqual(normalize_api_credential(" "), "")
self.assertEqual(normalize_api_credential("REPLACE_WITH_GATE_API_KEY"), "")
self.assertEqual(normalize_api_credential("CHANGE_TO_LONG_RANDOM_SECRET"), "")
self.assertEqual(normalize_api_credential("你的密钥"), "")
def test_real_key_kept(self):
self.assertEqual(normalize_api_credential(" real-key-value "), "real-key-value")
self.assertTrue(credentials_configured("abc", "def"))
self.assertFalse(credentials_configured("REPLACE_WITH_X", "secret"))
class TestAuthErrorDetect(unittest.TestCase):
def test_binance_invalid_key(self):
class AuthenticationError(Exception):
pass
self.assertTrue(is_exchange_auth_error(AuthenticationError('binance {"code":-2008,"msg":"Invalid Api-Key ID."}')))
self.assertTrue(is_exchange_auth_error(Exception("gate INVALID_KEY")))
self.assertFalse(is_exchange_auth_error(Exception("rate limit exceeded")))
if __name__ == "__main__":
unittest.main()
+4 -1
View File
@@ -125,7 +125,10 @@ class TestEnvSchema(unittest.TestCase):
self.assertTrue(by_key["OKX_SHOW_PERP_FUNDS"].get("hot_reload"))
self.assertNotIn("OKX_OPTIONS_API_KEY", by_key)
self.assertNotIn("OKX_SUB_ACCOUNT_NAME", by_key)
self.assertEqual(by_key["OKX_API_KEY"]["note"], "账户 API(永续+期权共用)")
self.assertNotIn("OKX_API_KEY", by_key)
self.assertNotIn("OKX_API_SECRET", by_key)
self.assertNotIn("OKX_API_PASSPHRASE", by_key)
self.assertIn("LIVE_TRADING_ENABLED", by_key)
groups_v = [{"title": "t", "fields": [by_key["TRADE_DIRECTION"]]}]
clean, errors = validate_env_updates(groups_v, {"TRADE_DIRECTION": "long_only"})