From 6215d0975d0a40172a7d073ea5668bd6008666f9 Mon Sep 17 00:00:00 2001 From: dekun Date: Fri, 21 Aug 2026 18:04:19 +0800 Subject: [PATCH] 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 --- .env.example | 4 +- app.py | 106 ++++++++++++++++----------- lib/env/env_schema.py | 54 ++++++++++++++ lib/env/env_ui_manifest.py | 6 +- lib/hedge_plan/okx_trade_mode_lib.py | 17 +---- lib/options/options_register.py | 4 +- 6 files changed, 129 insertions(+), 62 deletions(-) diff --git a/.env.example b/.env.example index 3142e53..e62cf9f 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app.py b/app.py index 7f56cb8..6f17608 100644 --- a/app.py +++ b/app.py @@ -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", diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py index 7812ae4..2d2b56d 100644 --- a/lib/env/env_schema.py +++ b/lib/env/env_schema.py @@ -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 diff --git a/lib/env/env_ui_manifest.py b/lib/env/env_ui_manifest.py index 9804e56..18cc3c5 100644 --- a/lib/env/env_ui_manifest.py +++ b/lib/env/env_ui_manifest.py @@ -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", diff --git a/lib/hedge_plan/okx_trade_mode_lib.py b/lib/hedge_plan/okx_trade_mode_lib.py index 2467134..0ce16c9 100644 --- a/lib/hedge_plan/okx_trade_mode_lib.py +++ b/lib/hedge_plan/okx_trade_mode_lib.py @@ -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 diff --git a/lib/options/options_register.py b/lib/options/options_register.py index cd31203..131628f 100644 --- a/lib/options/options_register.py +++ b/lib/options/options_register.py @@ -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: