diff --git a/.env.example b/.env.example index 7294072..374dd51 100644 --- a/.env.example +++ b/.env.example @@ -12,30 +12,36 @@ # cp .env.backup.YYYYMMDD .env # ============================================================================= +# ============================================================================= +# 默认面向「云服务器测试」:公网监听 + 必须登录 + 模拟盘 + 直连 OKX(不配代理) +# 局域网本机开发可将 APP_AUTH_DISABLED=true;弱网再开 OKX_*_PROXY +# ============================================================================= APP_ENV=production -# 服务监听地址(云服务器通常用 0.0.0.0) +# 服务监听地址(云服务器 / 宝塔反代后仍建议 0.0.0.0 或 127.0.0.1) +# 仅本机 + Nginx 反代时可改为 127.0.0.1;默认 0.0.0.0 方便直连调试 APP_HOST=0.0.0.0 -# 服务端口 +# 服务端口(宝塔反代到此端口,如 http://127.0.0.1:5010) APP_PORT=5010 -# 是否开启调试模式(生产建议 false) +# 是否开启调试模式(生产 / 云上务必 false) APP_DEBUG=false # 登录账号 APP_USERNAME=admin -# 登录密码(请改成你自己的强密码) +# 登录密码(请改成你自己的强密码;首次部署脚本也可能写成 admin123) APP_PASSWORD=admin123 -# 是否关闭登录校验(局域网可设 true;公网务必 false) -APP_AUTH_DISABLED=true +# 是否关闭登录校验(云服务器 / 公网务必 false;仅信任局域网可 true) +APP_AUTH_DISABLED=false # --- 多账户交易中控 manual_trading_hub --- # 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 -# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问;本机联调可保持 APP_AUTH_DISABLED=true +# 未设置且 APP_AUTH_DISABLED=false 时,仅网页登录后可访问 # HUB_BRIDGE_TOKEN=your-long-random-token # 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) # APP_ALLOW_HUB_EMBED=true # HUB_EMBED_PARENT_ORIGINS=https://hub.example.com # HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None +# 经宝塔 HTTPS 反代时建议打开: # APP_COOKIE_SECURE=true -# Flask 会话密钥(必须替换为长随机字符串) +# Flask 会话密钥(必须替换为长随机字符串;一键部署会自动生成) FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET # 企业微信机器人 Webhook(用于行情/风控推送) @@ -301,13 +307,9 @@ OPENAI_MODEL=gemma4:e4b OLLAMA_API=http://127.0.0.1:11434/api/generate AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest -# OKX 代理(可选,仅本地开发网络受限时用;云服务器部署请留空,直连 OKX 即可) -# 1) 先在本机建立隧道(示例): -# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes -# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名): +# OKX 代理(云服务器香港/新加坡等请留空,直连即可;不要填本机 Windows 代理地址) +# 仅当云主机也访问不了 OKX 时再启用: # OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 -# -# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用: # OKX_HTTP_PROXY=http://127.0.0.1:3128 # OKX_HTTPS_PROXY=http://127.0.0.1:3128 @@ -330,9 +332,9 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest APP_TIMEZONE=Asia/Shanghai # TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED -# --- CoinOX 模拟资金 --- +# --- CoinOX 模拟资金(云上测试默认 sim,无需 OKX API) --- # sim=本地模拟资金撮合; live=实盘账户 -# 默认模拟盘(本地钱包); 切实盘请改为 live 并配置 API +# 切实盘:填 OKX_API_* , LIVE_TRADING_ENABLED=true , SIM_DEFAULT_MODE=live , 并在 OKX 白名单加云主机出口 IP SIM_DEFAULT_MODE=sim SIM_INITIAL_EQUITY_USDT=10000 SIM_INITIAL_USDC=0 diff --git a/app.py b/app.py index cd2592d..52f2cb8 100644 --- a/app.py +++ b/app.py @@ -439,7 +439,10 @@ 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", "2")) +try: + RISK_PERCENT = float(os.getenv("RISK_PERCENT") or "2") +except (TypeError, ValueError): + RISK_PERCENT = 2.0 BREAKEVEN_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0")) BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) @@ -500,10 +503,18 @@ os.makedirs(ORDER_CHART_DIR, exist_ok=True) app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER # 同一套 OKX_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同) -exchange = ccxt.okx({ - "enableRateLimit": True, - "options": {"defaultType": "swap"}, # OKX 用 swap 表示永续 -}) +# CoinOX: 不拉全量 SPOT instruments(国内线路易超时导致行情空白),仅 swap/option(+现货桥需要 spot) +exchange = ccxt.okx( + { + "enableRateLimit": True, + "timeout": 20000, + "options": { + "defaultType": "swap", + # 仅 swap:避免 SPOT 全量 instruments 在部分线路超时导致行情空白 + "fetchMarkets": ["swap"], + }, + } +) if OKX_CCXT_PROXIES: exchange.proxies = OKX_CCXT_PROXIES if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: @@ -514,7 +525,11 @@ if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: exchange_options = ccxt.okx( { "enableRateLimit": True, - "options": {"defaultType": "option"}, + "timeout": 20000, + "options": { + "defaultType": "option", + "fetchMarkets": ["option", "swap"], + }, } ) if OKX_CCXT_PROXIES: @@ -2911,15 +2926,29 @@ def build_okx_order_params(direction, reduce_only=False): def ensure_markets_loaded(force=False): global MARKETS_LOADED if force or not MARKETS_LOADED: - 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 + last_err = None + for attempt in range(3): + try: + exchange.load_markets(reload=force or attempt > 0) + MARKETS_LOADED = True + return + except Exception as e: + last_err = e + if is_exchange_auth_error(e) and ( + getattr(exchange, "apiKey", None) or getattr(exchange, "secret", None) + ): + _disable_private_api_after_auth_error(e) + try: + load_markets_public_fallback(exchange, reload=True) + MARKETS_LOADED = True + return + except Exception as e2: + last_err = e2 + # 瞬时超时重试 + if attempt < 2: + time.sleep(0.4 * (attempt + 1)) + continue + raise last_err def _okx_algo_trigger_price_str(exchange_symbol, price): diff --git a/deploy/README.md b/deploy/README.md index c6b0a5a..907eeb4 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -37,6 +37,7 @@ bash /opt/coinox/deploy/manage.sh - 访问: `http://<服务器IP>:5010` (端口以 `.env` 的 `APP_PORT` 为准) - 默认入口: `/hedge-plan`(对冲)/ `/options`(期权);默认模拟盘 sim - 登录账号: **admin**(密码见 `.env` 的 `APP_PASSWORD`,首次脚本可写成 `admin123`) +- 云服务器默认: `APP_AUTH_DISABLED=false`、不配代理;宝塔反代见根目录 [部署文档.md](../部署文档.md) --- diff --git a/deploy/lib/common.sh b/deploy/lib/common.sh index 57f45cf..ec6fb2f 100644 --- a/deploy/lib/common.sh +++ b/deploy/lib/common.sh @@ -420,23 +420,25 @@ print_post_install_guide() { cat < 0) { + const idxLine = $("hp-index-line"); + if (idxLine && (!state.chain || !state.chain.index_px)) { + idxLine.textContent = "指数 " + fmt(mIdx, 2); + } + const ooIdx = $("hp-oo-index"); + if (ooIdx && (!state.chain || !state.chain.index_px)) { + ooIdx.textContent = "指数 " + fmt(mIdx, 2); + } + } const quoteHtml = "可用 " + fmt(d.available_usdt, 2) + diff --git a/lib/exchange/okx_options_lib.py b/lib/exchange/okx_options_lib.py index 6b84c30..051198c 100644 --- a/lib/exchange/okx_options_lib.py +++ b/lib/exchange/okx_options_lib.py @@ -723,11 +723,34 @@ def options_header_balance_pack( def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: - inst = f"{uly}" if "-" in uly else f"{uly}-USD" + """指数价:优先公共 HTTP/curl,再回退 ccxt.""" try: - rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or [] + from lib.exchange.okx_public_http_lib import fetch_index_px + + px = fetch_index_px(uly) + if px and px > 0: + return float(px) + except Exception: + pass + u = (uly or "ETH").strip().upper() or "ETH" + candidates = [u] if "-" in u else [f"{u}-USD", f"{u}-USDT"] + for inst in candidates: + try: + rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or [] + if rows: + px = _safe_float(rows[0].get("idxPx")) + if px and px > 0: + return px + except Exception: + continue + try: + rows = (ex.public_get_market_ticker({"instId": f"{u.split('-')[0]}-USDT-SWAP"}) or {}).get( + "data" + ) or [] if rows: - return _safe_float(rows[0].get("idxPx")) + px = _safe_float(rows[0].get("markPx")) or _safe_float(rows[0].get("last")) + if px and px > 0: + return px except Exception: pass return None @@ -755,6 +778,16 @@ def fetch_option_instruments( ): return list(entry["rows"]) + last_err: Exception | None = None + try: + from lib.exchange.okx_public_http_lib import fetch_option_instruments_public + + live = fetch_option_instruments_public(family) + with _OPTION_INSTRUMENTS_CACHE_LOCK: + _OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live} + return list(live) + except Exception as e: + last_err = e try: rows = ex.public_get_public_instruments( {"instType": "OPTION", "instFamily": family} @@ -764,6 +797,7 @@ def fetch_option_instruments( _OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live} return list(live) except Exception as e: + last_err = e if allow_stale: with _OPTION_INSTRUMENTS_CACHE_LOCK: entry = _OPTION_INSTRUMENTS_CACHE.get(family) @@ -771,11 +805,19 @@ def fetch_option_instruments( age = now - float(entry.get("updated_at") or 0) if age <= _OPTION_INSTRUMENTS_STALE_MAX: return list(entry["rows"]) - raise + raise last_err def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: out: dict[str, dict[str, Any]] = {} + try: + from lib.exchange.okx_public_http_lib import fetch_option_tickers_public + + got = fetch_option_tickers_public(inst_family) + if got: + return got + except Exception: + pass try: rows = ex.public_get_market_tickers( {"instType": "OPTION", "instFamily": inst_family} diff --git a/lib/exchange/okx_public_http_lib.py b/lib/exchange/okx_public_http_lib.py new file mode 100644 index 0000000..3ed0af9 --- /dev/null +++ b/lib/exchange/okx_public_http_lib.py @@ -0,0 +1,167 @@ +"""OKX 公共 REST 轻量拉取(绕开 ccxt load_markets). + +部分线路上 Python requests 偶发连 www.okx.com 超时,而 curl -4 仍可用; +对冲页标记价/指数优先走本模块,避免整页空白. +""" +from __future__ import annotations + +import json +import subprocess +import urllib.parse +import urllib.request +from typing import Any, Optional + + +OKX_BASE = "https://www.okx.com" + + +def _curl_get_json(url: str, *, timeout_sec: float = 8.0) -> dict[str, Any]: + proc = subprocess.run( + [ + "curl", + "-4", + "-fsS", + "--connect-timeout", + str(max(1, int(timeout_sec))), + "--max-time", + str(max(2, int(timeout_sec + 2))), + url, + ], + capture_output=True, + text=True, + timeout=max(3.0, timeout_sec + 3.0), + check=False, + ) + if proc.returncode != 0: + raise RuntimeError((proc.stderr or proc.stdout or "curl failed").strip()[:240]) + return json.loads(proc.stdout or "{}") + + +def _urllib_get_json(url: str, *, timeout_sec: float = 8.0) -> dict[str, Any]: + req = urllib.request.Request(url, headers={"User-Agent": "CoinOX/1.0"}) + with urllib.request.urlopen(req, timeout=timeout_sec) as resp: + raw = resp.read().decode("utf-8", errors="replace") + return json.loads(raw or "{}") + + +def okx_public_get( + path: str, + params: Optional[dict[str, Any]] = None, + *, + timeout_sec: float = 8.0, +) -> dict[str, Any]: + q = urllib.parse.urlencode({k: v for k, v in (params or {}).items() if v is not None}) + url = f"{OKX_BASE}{path}" + if q: + url = f"{url}?{q}" + last_err: Exception | None = None + for getter in (_curl_get_json, _urllib_get_json): + try: + data = getter(url, timeout_sec=timeout_sec) + if isinstance(data, dict): + return data + except Exception as e: + last_err = e + continue + raise RuntimeError(str(last_err or "okx public get failed")) + + +def _sf(v: Any) -> Optional[float]: + if v is None or v == "": + return None + try: + x = float(v) + except (TypeError, ValueError): + return None + return x if x > 0 else None + + +def fetch_swap_mark_px(base: str) -> Optional[float]: + u = (base or "ETH").strip().upper() or "ETH" + try: + payload = okx_public_get( + "/api/v5/public/mark-price", + {"instId": f"{u}-USDT-SWAP"}, + timeout_sec=8.0, + ) + rows = payload.get("data") or [] + if rows: + return _sf(rows[0].get("markPx")) + except Exception: + pass + return None + + +def fetch_swap_ticker(base: str) -> dict[str, Any]: + """返回 ETH/BTC USDT 永续公共 ticker 行(补齐 markPx).""" + u = (base or "ETH").strip().upper() or "ETH" + payload = okx_public_get( + "/api/v5/market/ticker", + {"instId": f"{u}-USDT-SWAP"}, + timeout_sec=8.0, + ) + rows = payload.get("data") or [] + if not rows: + raise RuntimeError(payload.get("msg") or "ticker empty") + row = dict(rows[0]) + if not _sf(row.get("markPx")): + mp = fetch_swap_mark_px(u) + if mp: + row["markPx"] = str(mp) + elif _sf(row.get("last")): + row["markPx"] = row.get("last") + return row + + +def fetch_index_px(uly: str) -> Optional[float]: + u = (uly or "ETH").strip().upper() or "ETH" + candidates = [u] if "-" in u else [f"{u}-USD", f"{u}-USDT"] + for inst in candidates: + try: + payload = okx_public_get( + "/api/v5/market/index-tickers", + {"instId": inst}, + timeout_sec=8.0, + ) + rows = payload.get("data") or [] + if not rows: + continue + v = _sf(rows[0].get("idxPx")) + if v: + return v + except Exception: + continue + try: + row = fetch_swap_ticker(u.split("-")[0]) + return _sf(row.get("markPx")) or _sf(row.get("last")) + except Exception: + return None + + +def fetch_option_instruments_public(inst_family: str) -> list[dict[str, Any]]: + family = str(inst_family or "").strip() + if not family: + return [] + payload = okx_public_get( + "/api/v5/public/instruments", + {"instType": "OPTION", "instFamily": family}, + timeout_sec=12.0, + ) + rows = payload.get("data") or [] + return [r for r in rows if isinstance(r, dict) and r.get("state") == "live"] + + +def fetch_option_tickers_public(inst_family: str) -> dict[str, dict[str, Any]]: + family = str(inst_family or "").strip() + if not family: + return {} + payload = okx_public_get( + "/api/v5/market/tickers", + {"instType": "OPTION", "instFamily": family}, + timeout_sec=12.0, + ) + out: dict[str, dict[str, Any]] = {} + for r in payload.get("data") or []: + if isinstance(r, dict) and r.get("instId"): + out[str(r["instId"])] = r + return out diff --git a/lib/hedge_plan/hedge_plan_register.py b/lib/hedge_plan/hedge_plan_register.py index 8d1f8a2..edef314 100644 --- a/lib/hedge_plan/hedge_plan_register.py +++ b/lib/hedge_plan/hedge_plan_register.py @@ -2,6 +2,7 @@ from __future__ import annotations import os +import time from typing import Any, Optional from flask import Flask, jsonify, request @@ -10,16 +11,10 @@ from jinja2 import ChoiceLoader, FileSystemLoader from lib.hedge_plan.hedge_plan_calc_lib import ( build_options_options_preview, build_perp_options_preview, - floor_contracts_to_precision, gate_status, option_premium_total, - suggest_contracts_from_notional, -) -from lib.hub.hub_calculator_market_lib import amount_decimals_from_exchange -from lib.trade.position_sizing_lib import ( - compute_full_margin_sizing, - load_position_sizing_mode, ) +from lib.trade.position_sizing_lib import load_position_sizing_mode def _env_bool(key: str, default: bool = False) -> bool: @@ -1311,15 +1306,8 @@ def _preview_oo(body: dict[str, Any]) -> dict[str, Any]: def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], str | None]: + """对冲页行情:只走公共 HTTP/curl,绝不触发 ccxt load_markets(弱网会卡死整页).""" ex = cfg.get("exchange") - if ex is None: - return {}, "永续交易所未初始化" - ensure = cfg.get("ensure_markets_loaded") - if callable(ensure): - try: - ensure() - except Exception as e: - return {}, f"加载市场失败: {e}" norm = cfg.get("normalize_exchange_symbol") sym = f"{base}/USDT:USDT" if callable(norm): @@ -1327,67 +1315,43 @@ def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], sym = norm(f"{base}/USDT") except Exception: sym = f"{base}/USDT:USDT" - mark = bid = ask = last = None + + mark = bid = ask = last = index_px = None + last_err: Exception | None = None try: - t = ex.fetch_ticker(sym) - last = _sf(t.get("last")) - mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None - if mark is None: - mark = _sf(t.get("mark")) or last - bid = _sf(t.get("bid")) - ask = _sf(t.get("ask")) + from lib.exchange.okx_public_http_lib import fetch_index_px, fetch_swap_ticker + + row = fetch_swap_ticker(base) + last = _sf(row.get("last")) + mark = _sf(row.get("markPx")) or last + bid = _sf(row.get("bidPx")) + ask = _sf(row.get("askPx")) + index_px = fetch_index_px(base) except Exception as e: - return {}, f"拉永续行情失败: {e}" - - cs = 0.01 - get_cs = cfg.get("get_contract_size") - if callable(get_cs): - try: - cs = float(get_cs(sym) or 0.01) - except Exception: - cs = 0.01 + last_err = e + if ex is not None: + try: + inst = f"{base}-USDT-SWAP" + rows = (ex.public_get_market_ticker({"instId": inst}) or {}).get("data") or [] + row = rows[0] if rows else {} + last = _sf(row.get("last")) + mark = _sf(row.get("markPx")) or last + bid = _sf(row.get("bidPx")) + ask = _sf(row.get("askPx")) + except Exception as e2: + last_err = e2 + if last is None and mark is None: + return {}, f"拉永续行情失败: {last_err or '无报价'}" + if index_px is None: + index_px = mark or last + # 面值/可用/精度:展示用默认值,避免 get_contract_size→load_markets 阻塞 + cs = 0.1 if base == "ETH" else 0.01 available = None - get_av = cfg.get("get_available_trading_usdt") - if callable(get_av): - try: - available = get_av() - except Exception: - available = None - - entry = float(mark or last or 0) + amount_precision = 2 if base == "ETH" else 4 sizing = None suggest_contracts = None - amount_precision = 4 - try: - amount_precision = int(amount_decimals_from_exchange(ex, sym)) - except Exception: - amount_precision = 4 - if available is not None and entry > 0: - sizing, _serr = compute_full_margin_sizing( - symbol=sym, - available_usdt=float(available), - capital_base=float(available), - buffer_ratio=float(cfg.get("full_margin_buffer") or 0.98), - btc_leverage=int(cfg.get("btc_leverage") or 10), - alt_leverage=int(cfg.get("alt_leverage") or 5), - funds_decimals=int(cfg.get("funds_decimals") or 2), - ) - if sizing: - raw_contracts = suggest_contracts_from_notional( - notional=float(sizing["notional_value"]), - entry=entry, - contract_size=cs, - ) - # 优先走交易所 amount_to_precision;失败则按精度位数向下取整 - suggest_contracts = None - try: - precise = float(ex.amount_to_precision(sym, raw_contracts)) - if precise > raw_contracts + 1e-12: - precise = floor_contracts_to_precision(raw_contracts, amount_precision) - suggest_contracts = precise - except Exception: - suggest_contracts = floor_contracts_to_precision(raw_contracts, amount_precision) + entry = float(mark or last or 0) return { "exchange_symbol": sym, @@ -1395,6 +1359,7 @@ def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any], "last": last, "bid": bid, "ask": ask, + "index_px": index_px, "contract_size": cs, "available_usdt": available, "full_margin_sizing": sizing, diff --git a/scripts/bootstrap_deploy_secrets.py b/scripts/bootstrap_deploy_secrets.py index c5914a8..afc1857 100644 --- a/scripts/bootstrap_deploy_secrets.py +++ b/scripts/bootstrap_deploy_secrets.py @@ -4,7 +4,7 @@ - FLASK_SECRET_KEY - APP_USERNAME=admin / APP_PASSWORD=admin123(仅空时) - HUB_BRIDGE_TOKEN(仅空时,供后期中控) -- 强制默认模拟盘相关键(仅空/占位时) +- 云服务器测试默认:监听/鉴权/模拟盘(仅空或占位时) """ from __future__ import annotations @@ -57,7 +57,15 @@ def main() -> int: if not (env_get(lines, "HUB_BRIDGE_TOKEN") or "").strip(): updates["HUB_BRIDGE_TOKEN"] = secrets.token_urlsafe(32) - # CoinOX: 默认模拟盘(仅当未配置或空时写入) + # 云服务器测试默认(仅空时写入;不覆盖用户已改配置) + if not (env_get(lines, "APP_HOST") or "").strip(): + updates["APP_HOST"] = "0.0.0.0" + if not (env_get(lines, "APP_PORT") or "").strip(): + updates["APP_PORT"] = "5010" + if not (env_get(lines, "APP_DEBUG") or "").strip(): + updates["APP_DEBUG"] = "false" + if not (env_get(lines, "APP_AUTH_DISABLED") or "").strip(): + updates["APP_AUTH_DISABLED"] = "false" if not (env_get(lines, "SIM_DEFAULT_MODE") or "").strip(): updates["SIM_DEFAULT_MODE"] = "sim" if not (env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "").strip(): @@ -66,6 +74,8 @@ def main() -> int: updates["EXCHANGE_DISPLAY_NAME"] = "CoinOX" if not (env_get(lines, "OKX_OPTIONS_ENABLED") or "").strip(): updates["OKX_OPTIONS_ENABLED"] = "true" + if not (env_get(lines, "OKX_TRADE_MODE") or "").strip(): + updates["OKX_TRADE_MODE"] = "perp_options" live = (env_get(lines, "LIVE_TRADING_ENABLED") or "").strip().lower() if live in ("",): updates["LIVE_TRADING_ENABLED"] = "false" @@ -86,7 +96,7 @@ def main() -> int: return 0 apply_env_updates(env_path, updates) - print(" 已写入初始密钥/账号与模拟盘默认(请尽快修改 APP_PASSWORD)") + print(" 已写入云服务器测试默认(请尽快修改 APP_PASSWORD;公网保持 APP_AUTH_DISABLED=false)") return 0 diff --git a/部署文档.md b/部署文档.md index eb13273..d210e49 100644 --- a/部署文档.md +++ b/部署文档.md @@ -39,9 +39,52 @@ bash /opt/coinox/deploy/manage.sh - 访问:`http://<服务器IP>:5010` - 默认入口:`/hedge-plan`(对冲)、`/options`(期权) - 登录:`admin` / `.env` 中 `APP_PASSWORD`(首次多为 `admin123`) -- **默认模拟盘**:本地钱包撮合,不打真实私有下单 +- **默认模拟盘**:本地钱包撮合,不打真实私有下单;**公网默认开启登录**(`APP_AUTH_DISABLED=false`) +- **云服务器无需代理**:`.env` 里不要填 `OKX_HTTP_PROXY` / `OKX_SOCKS_PROXY`(香港/新加坡直连 OKX) -切实盘前请在 env / 系统设置中配置 `OKX_API_*`,并将交易模式切为 `live`,同时确认 `LIVE_TRADING_ENABLED`。 +切实盘前请在 env / 系统设置中配置 `OKX_API_*`,并将交易模式切为 `live`,同时确认 `LIVE_TRADING_ENABLED`,并在 OKX API 白名单加入云主机出口 IP。 + +--- + +## 云服务器 + 宝塔 Nginx 反代(推荐测试形态) + +适合:1G 内存云主机,宝塔**只装 Nginx**,CoinOX 用 PM2 监听 `127.0.0.1:5010` 或 `0.0.0.0:5010`。 + +1. 按上文一键部署 CoinOX(`/opt/coinox`,端口 `5010`) +2. 宝塔 → 网站 → 添加站点(域名或服务器 IP) +3. 设置 → 反向代理: + - 目标 URL:`http://127.0.0.1:5010` + - 发送域名:`$host` +4. (可选)申请 SSL,开启强制 HTTPS;此时建议在 `.env` 设 `APP_COOKIE_SECURE=true` 后 `pm2 restart coinox --update-env` +5. 安全组/防火墙:对外只放行 **80/443**(不必对公网暴露 5010) +6. 登录后改掉 `APP_PASSWORD`;保持 `APP_AUTH_DISABLED=false` + +Nginx 反代示例(宝塔「配置文件」里亦可手改): + +```nginx +location / { + proxy_pass http://127.0.0.1:5010; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 300s; +} +``` + +云上测试最小 `.env` 要点: + +| 键 | 建议值 | +|----|--------| +| `APP_HOST` | `0.0.0.0`(或仅反代时用 `127.0.0.1`) | +| `APP_PORT` | `5010` | +| `APP_AUTH_DISABLED` | `false` | +| `SIM_DEFAULT_MODE` | `sim` | +| `LIVE_TRADING_ENABLED` | `false` | +| `OKX_*_PROXY` | 留空 | ---