Harden env save against empty numerics; default coin margin and hedge off.

Reject blank float/int/select on save and parse env with safe helpers so options config edits cannot crash into 502; default OKX_TRADE_MODE=options and OKX_OPTIONS_MARGIN_MODE=coin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-21 18:04:19 +08:00
parent d1e6d841c9
commit 6215d0975d
6 changed files with 129 additions and 62 deletions
+2 -2
View File
@@ -156,8 +156,8 @@ OKX_OPTIONS_OPEN_FILL_TIMEOUT_SEC=12
# =============================================================================
# 以下三项已由 OKX_TRADE_MODE 取代,保留兼容旧部署(未配置 TRADE_MODE 时仍可读)
HEDGE_PLAN_ENABLED=false
HEDGE_PLAN_SHOW_PERP_OPTIONS=true
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=true
HEDGE_PLAN_SHOW_PERP_OPTIONS=false
HEDGE_PLAN_SHOW_OPTIONS_OPTIONS=false
HEDGE_PLAN_LIVE_ORDER=false
# 永期子模式:true=以期权为主;false=保险模式(页面标题前标识,不可页内切换)
HEDGE_PLAN_OPTION_PRIMARY=true
+63 -43
View File
@@ -258,19 +258,41 @@ if not AUTH_DISABLED and (not USERNAME or not PASSWORD):
WECHAT_WEBHOOK = os.getenv("WECHAT_WEBHOOK", "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=replace-me")
SYSTEM_TYPE = "CRYPTO"
HOST = os.getenv("APP_HOST", "0.0.0.0")
PORT = int(os.getenv("APP_PORT", "5000"))
def _env_float(name: str, default: float) -> float:
try:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return float(default)
return float(raw)
except (TypeError, ValueError):
return float(default)
def _env_int(name: str, default: int) -> int:
try:
raw = os.getenv(name)
if raw is None or str(raw).strip() == "":
return int(default)
return int(float(raw))
except (TypeError, ValueError):
return int(default)
PORT = _env_int("APP_PORT", 5000)
DEBUG = os.getenv("APP_DEBUG", "false").lower() == "true"
DB_PATH = resolve_path(os.getenv("DB_PATH", "crypto.db"))
# 训练参数(可由 .env 覆盖)
TOTAL_CAPITAL = float(os.getenv("TOTAL_CAPITAL", "100"))
DAILY_START_CAPITAL = float(os.getenv("DAILY_START_CAPITAL", "30"))
DAILY_LOSS_CAPITAL = float(os.getenv("DAILY_LOSS_CAPITAL", "20"))
DAILY_PROFIT_CAPITAL = float(os.getenv("DAILY_PROFIT_CAPITAL", "50"))
BTC_LEVERAGE = int(os.getenv("BTC_LEVERAGE", "10"))
ALT_LEVERAGE = int(os.getenv("ALT_LEVERAGE", "5"))
TOTAL_CAPITAL = _env_float("TOTAL_CAPITAL", 100)
DAILY_START_CAPITAL = _env_float("DAILY_START_CAPITAL", 30)
DAILY_LOSS_CAPITAL = _env_float("DAILY_LOSS_CAPITAL", 20)
DAILY_PROFIT_CAPITAL = _env_float("DAILY_PROFIT_CAPITAL", 50)
BTC_LEVERAGE = _env_int("BTC_LEVERAGE", 10)
ALT_LEVERAGE = _env_int("ALT_LEVERAGE", 5)
# 交易日滚动与「可开仓」整点:按应用本地时区 wall clock(默认北京时间 UTC+8)
TRADING_DAY_RESET_HOUR = int(os.getenv("TRADING_DAY_RESET_HOUR", "8"))
TRADING_DAY_RESET_HOUR = _env_int("TRADING_DAY_RESET_HOUR", 8)
TRADING_DAY_RESET_OPEN_GUARD_ENABLED = os.getenv(
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED", "true"
).lower() in ("1", "true", "yes", "on")
@@ -310,47 +332,45 @@ 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_OPTIONS_ENABLED = os.getenv("OKX_OPTIONS_ENABLED", "true").lower() in ("1", "true", "yes", "on")
OKX_OPTIONS_TRADE_BUDGET_USDC = float(os.getenv("OKX_OPTIONS_TRADE_BUDGET_USDC", "10"))
OKX_OPTIONS_TRADE_BUDGET_USDC = _env_float("OKX_OPTIONS_TRADE_BUDGET_USDC", 10)
OKX_OPTIONS_DEFAULT_UNDERLY = (os.getenv("OKX_OPTIONS_DEFAULT_UNDERLY") or "ETH").strip().upper()
OKX_TD_MODE = os.getenv("OKX_TD_MODE", "cross")
OKX_POS_MODE = os.getenv("OKX_POS_MODE", "hedge")
EXCHANGE_DISPLAY_NAME = (os.getenv("EXCHANGE_DISPLAY_NAME") or "OKX").strip() or "OKX"
BALANCE_REFRESH_SECONDS = int(os.getenv("BALANCE_REFRESH_SECONDS", "60"))
PRICE_REFRESH_SECONDS = int(os.getenv("PRICE_REFRESH_SECONDS", "5"))
KEY_ALERT_MAX_TIMES = int(os.getenv("KEY_ALERT_MAX_TIMES", "3"))
KEY_ALERT_INTERVAL_MINUTES = int(os.getenv("KEY_ALERT_INTERVAL_MINUTES", "5"))
KEY_BREAKOUT_LIMIT_PCT = float(os.getenv("KEY_BREAKOUT_LIMIT_PCT", "1.5"))
BALANCE_REFRESH_SECONDS = _env_int("BALANCE_REFRESH_SECONDS", 60)
PRICE_REFRESH_SECONDS = _env_int("PRICE_REFRESH_SECONDS", 5)
KEY_ALERT_MAX_TIMES = _env_int("KEY_ALERT_MAX_TIMES", 3)
KEY_ALERT_INTERVAL_MINUTES = _env_int("KEY_ALERT_INTERVAL_MINUTES", 5)
KEY_BREAKOUT_LIMIT_PCT = _env_float("KEY_BREAKOUT_LIMIT_PCT", 1.5)
AUTO_TRANSFER_ENABLED = os.getenv("AUTO_TRANSFER_ENABLED", "false").lower() == "true"
AUTO_TRANSFER_AMOUNT = float(os.getenv("AUTO_TRANSFER_AMOUNT", "30"))
AUTO_TRANSFER_AMOUNT = _env_float("AUTO_TRANSFER_AMOUNT", 30)
AUTO_TRANSFER_FROM = os.getenv("AUTO_TRANSFER_FROM", "funding")
AUTO_TRANSFER_TO = os.getenv("AUTO_TRANSFER_TO", "swap")
FORCE_CLOSE_ENABLED = os.getenv("FORCE_CLOSE_ENABLED", "false").lower() == "true"
FORCE_CLOSE_BJ_HOUR = int(os.getenv("FORCE_CLOSE_BJ_HOUR", "0"))
FORCE_CLOSE_BJ_HOUR = _env_int("FORCE_CLOSE_BJ_HOUR", 0)
# 自动划转:仅在北京时间该整点「小时」内尝试;transfer_logs.transfer_day 存 UTC 自然日(与 OKX 日界一致便于对账)
AUTO_TRANSFER_BJ_HOUR = int(os.getenv("AUTO_TRANSFER_BJ_HOUR", "8"))
AUTO_TRANSFER_BJ_HOUR = _env_int("AUTO_TRANSFER_BJ_HOUR", 8)
POSITION_SIZING_MODE = load_position_sizing_mode()
TRADE_POLICY = load_trade_policy()
WECHAT_TIMEOUT_SECONDS = int(os.getenv("WECHAT_TIMEOUT_SECONDS", "10"))
MONITOR_POLL_SECONDS = int(os.getenv("MONITOR_POLL_SECONDS", "3"))
RECONCILE_STARTUP_GRACE_SEC = int(os.getenv("RECONCILE_STARTUP_GRACE_SEC", "90"))
RECONCILE_FLAT_CONFIRM_POLLS = max(1, int(os.getenv("RECONCILE_FLAT_CONFIRM_POLLS", "3")))
WECHAT_TIMEOUT_SECONDS = _env_int("WECHAT_TIMEOUT_SECONDS", 10)
MONITOR_POLL_SECONDS = _env_int("MONITOR_POLL_SECONDS", 3)
RECONCILE_STARTUP_GRACE_SEC = _env_int("RECONCILE_STARTUP_GRACE_SEC", 90)
RECONCILE_FLAT_CONFIRM_POLLS = max(1, _env_int("RECONCILE_FLAT_CONFIRM_POLLS", 3))
_APP_STARTED_AT = time.time()
_RECONCILE_FLAT_STREAK = {}
BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max(
15, int(os.getenv("BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC", "60"))
)
BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC = max(15, _env_int("BREAKEVEN_EXCHANGE_MIN_INTERVAL_SEC", 60))
_BREAKEVEN_LAST_EX_SYNC: dict[int, float] = {}
KLINE_TIMEFRAME = os.getenv("KLINE_TIMEFRAME", "5m")
FULL_MARGIN_BUFFER_RATIO = float(os.getenv("FULL_MARGIN_BUFFER_RATIO") or "0.98")
FULL_MARGIN_BUFFER_RATIO = _env_float("FULL_MARGIN_BUFFER_RATIO", 0.98)
TRANSFER_CCY = (os.getenv("TRANSFER_CCY", "USDT") or "USDT").strip().upper() or "USDT"
OKX_POSITION_INST_TYPE = os.getenv("OKX_POSITION_INST_TYPE", "SWAP")
EXCHANGE_POSITION_SYNC_FROM_BJ = (os.getenv("EXCHANGE_POSITION_SYNC_FROM_BJ") or "").strip()
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, int(os.getenv("EXCHANGE_POSITION_HISTORY_LIMIT", "200"))))
EXCHANGE_POSITION_HISTORY_LIMIT = max(50, min(1000, _env_int("EXCHANGE_POSITION_HISTORY_LIMIT", 200)))
_LAST_EXCHANGE_PNL_SYNC_AT = 0.0
UPLOAD_FOLDER = resolve_path(os.getenv("UPLOAD_DIR", "static/images"))
ORDER_CHART_ENABLED = os.getenv("ORDER_CHART_ENABLED", "true").lower() == "true"
ORDER_CHART_TFS = [x.strip() for x in (os.getenv("ORDER_CHART_TFS", "4h,1h,15m,5m") or "").split(",") if x.strip()]
ORDER_CHART_LIMIT = int(os.getenv("ORDER_CHART_LIMIT", "100"))
ORDER_CHART_LIMIT = _env_int("ORDER_CHART_LIMIT", 100)
ORDER_CHART_DIR = resolve_path(os.getenv("ORDER_CHART_DIR", "static/images/order_charts"))
from lib.trade.daily_open_limit_lib import (
build_daily_open_alert_prompt,
@@ -364,25 +384,25 @@ from lib.trade.daily_open_limit_lib import (
)
DAILY_OPEN_ALERT_THRESHOLD, DAILY_OPEN_HARD_LIMIT = load_daily_open_limits_from_env()
RISK_PERCENT = float(os.getenv("RISK_PERCENT") or "2")
BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER") or "1.0")
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT") or "0.02")
BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R") or "1.0")
RISK_PERCENT = _env_float("RISK_PERCENT", 2)
BREAKEVEN_RR_TRIGGER = _env_float("BREAKEVEN_RR_TRIGGER", 1.0)
BREAKEVEN_OFFSET_PCT = _env_float("BREAKEVEN_OFFSET_PCT", 0.02)
BREAKEVEN_STEP_R = _env_float("BREAKEVEN_STEP_R", 1.0)
ORDER_MONITOR_TYPE_MANUAL = "下单监控"
ORDER_MONITOR_TYPE_KEY_AUTO = "关键位监控"
KEY_AUTO_MIN_PLANNED_RR = float(os.getenv("KEY_AUTO_MIN_PLANNED_RR") or "1.5")
KEY_STOP_OUTSIDE_BREAKOUT_PCT = float(os.getenv("KEY_STOP_OUTSIDE_BREAKOUT_PCT") or "0.5")
KEY_TREND_STOP_OUTSIDE_PCT = float(os.getenv("KEY_TREND_STOP_OUTSIDE_PCT") or "1")
KEY_DAILY_VOLUME_RANK_MAX = max(1, int(os.getenv("KEY_DAILY_VOLUME_RANK_MAX") or "30"))
KEY_AUTO_MIN_PLANNED_RR = _env_float("KEY_AUTO_MIN_PLANNED_RR", 1.5)
KEY_STOP_OUTSIDE_BREAKOUT_PCT = _env_float("KEY_STOP_OUTSIDE_BREAKOUT_PCT", 0.5)
KEY_TREND_STOP_OUTSIDE_PCT = _env_float("KEY_TREND_STOP_OUTSIDE_PCT", 1)
KEY_DAILY_VOLUME_RANK_MAX = max(1, _env_int("KEY_DAILY_VOLUME_RANK_MAX", 30))
MANUAL_MIN_PLANNED_RR = float(os.getenv("MANUAL_MIN_PLANNED_RR") or "1.4")
MAX_ACTIVE_POSITIONS = max(1, int(os.getenv("MAX_ACTIVE_POSITIONS") or "1"))
KEY_VOLUME_MA_BARS = max(1, int(os.getenv("KEY_VOLUME_MA_BARS") or "20"))
KEY_VOLUME_RATIO_MIN = float(os.getenv("KEY_VOLUME_RATIO_MIN") or "1.3")
KEY_BREAKOUT_AMP_MIN_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MIN_PCT") or "0.03")
KEY_BREAKOUT_AMP_MAX_PCT = float(os.getenv("KEY_BREAKOUT_AMP_MAX_PCT") or "0.5")
KEY_CONFIRM_BREAKOUT_BAR = int(os.getenv("KEY_CONFIRM_BREAKOUT_BAR") or "-2")
KEY_CONFIRM_BAR = int(os.getenv("KEY_CONFIRM_BAR") or "-1")
MANUAL_MIN_PLANNED_RR = _env_float("MANUAL_MIN_PLANNED_RR", 1.4)
MAX_ACTIVE_POSITIONS = max(1, _env_int("MAX_ACTIVE_POSITIONS", 1))
KEY_VOLUME_MA_BARS = max(1, _env_int("KEY_VOLUME_MA_BARS", 20))
KEY_VOLUME_RATIO_MIN = _env_float("KEY_VOLUME_RATIO_MIN", 1.3)
KEY_BREAKOUT_AMP_MIN_PCT = _env_float("KEY_BREAKOUT_AMP_MIN_PCT", 0.03)
KEY_BREAKOUT_AMP_MAX_PCT = _env_float("KEY_BREAKOUT_AMP_MAX_PCT", 0.5)
KEY_CONFIRM_BREAKOUT_BAR = _env_int("KEY_CONFIRM_BREAKOUT_BAR", -2)
KEY_CONFIRM_BAR = _env_int("KEY_CONFIRM_BAR", -1)
KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT = os.getenv("KEY_SIZING_USE_ZERO_POSITION_SNAPSHOT", "true").lower() in (
"1",
"true",
+54
View File
@@ -378,6 +378,32 @@ def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[d
allowed[field["key"]] = field
clean: dict[str, str] = {}
errors: list[str] = []
def _looks_numeric_key(k: str) -> bool:
u = (k or "").upper()
return any(
s in u
for s in (
"_BUFFER",
"_RATIO",
"_PERCENT",
"_SECONDS",
"_HOURS",
"_MINUTES",
"_LIMIT",
"_AMOUNT",
"_BUDGET",
"_CAP_",
"_MAX_",
"_MIN_",
"LEVERAGE",
"_DTE_",
"_USDT",
"_USDC",
"RISK_PERCENT",
)
)
for key, value in (updates or {}).items():
if key not in allowed:
errors.append(f"未知配置项: {key}")
@@ -392,6 +418,17 @@ def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[d
errors.append(f"{key} 长度异常,疑似自动填充;留空则不修改已有密钥")
continue
ftype = allowed[key].get("type")
if val == "":
# 空值写入会导致 float('') / int('') 启动崩溃(曾出现 502)
if ftype in ("float", "int") or _looks_numeric_key(key):
errors.append(f"{key} 不能为空,请填写数字")
continue
if ftype == "select" or key in SELECT_OPTIONS:
errors.append(f"{key} 不能为空,请从下拉选择")
continue
if ftype == "bool":
errors.append(f"{key} 不能为空,请选择 true/false")
continue
if ftype == "bool":
low = val.lower()
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
@@ -412,6 +449,23 @@ def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[d
errors.append(f"{key} 须为: {labels}")
continue
val = norm
elif ftype == "float" or (ftype != "int" and _looks_numeric_key(key) and "." in val):
try:
float(val)
except ValueError:
errors.append(f"{key} 须为数字")
continue
ftype = "float"
elif ftype == "int" or _looks_numeric_key(key):
try:
# 允许 0.98 类缓冲写成 float 键名但值是 int 也可
if "." in val:
float(val)
else:
int(val)
except ValueError:
errors.append(f"{key} 须为数字")
continue
clean[key] = val
return clean, errors
+4 -2
View File
@@ -251,14 +251,16 @@ _RUNTIME_ENV_DEFAULTS: dict[str, str] = {
"AUTO_TRANSFER_FROM": "funding",
"AUTO_TRANSFER_TO": "swap",
"TRANSFER_CCY": "USDT",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "true",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "true",
"HEDGE_PLAN_SHOW_PERP_OPTIONS": "false",
"HEDGE_PLAN_SHOW_OPTIONS_OPTIONS": "false",
"OKX_SHOW_PERP_FUNDS": "false",
"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_OPTIONS_MARGIN_MODE": "coin",
"OKX_TRADE_MODE": "options",
"HEDGE_PLAN_ENABLED": "false",
"MAX_ACTIVE_HEDGE_PLANS": "1",
"HEDGE_PLAN_OO_CLOSE_MODE_ENABLED": "true",
"HEDGE_PLAN_OO_BIAS_SPLIT_BY": "budget",
+4 -13
View File
@@ -48,19 +48,10 @@ def normalize_okx_trade_mode(raw: Optional[str]) -> str:
def legacy_infer_okx_trade_mode() -> str:
"""未配置 OKX_TRADE_MODE 时,按旧开关推断,避免已有部署行为突变."""
if not _env_bool("HEDGE_PLAN_ENABLED", False):
return MODE_OPTIONS
show_po = _env_bool("HEDGE_PLAN_SHOW_PERP_OPTIONS", True)
show_oo = _env_bool("HEDGE_PLAN_SHOW_OPTIONS_OPTIONS", True)
if show_po and not show_oo:
return MODE_PERP
if show_oo and not show_po:
return MODE_OO
if show_po:
return MODE_PERP
if show_oo:
return MODE_OO
"""未配置 OKX_TRADE_MODE 时默认单独期权(对冲关闭).
旧 HEDGE_PLAN_* 开关不再自动打开对冲,避免误开;需显式设 OKX_TRADE_MODE=options_options.
"""
return MODE_OPTIONS
+2 -2
View File
@@ -34,9 +34,9 @@ def _env_bool(key: str, default: bool = False) -> bool:
def _env_float(key: str, default: float) -> float:
try:
return float(os.getenv(key, str(default)))
return float(os.getenv(key) or default)
except (TypeError, ValueError):
return default
return float(default)
def attach_options_templates(app: Flask, repo_root: str) -> None: