Fix hedge market quotes via public HTTP and default cloud-server deploy settings.

Avoid load_markets hangs on weak networks; enable login-required sim defaults and document Baota Nginx reverse proxy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-14 10:13:39 +08:00
parent b4d62ea0a6
commit 135cdc13ba
11 changed files with 392 additions and 118 deletions
+18 -16
View File
@@ -12,30 +12,36 @@
# cp .env.backup.YYYYMMDD .env # cp .env.backup.YYYYMMDD .env
# ============================================================================= # =============================================================================
# =============================================================================
# 默认面向「云服务器测试」:公网监听 + 必须登录 + 模拟盘 + 直连 OKX(不配代理)
# 局域网本机开发可将 APP_AUTH_DISABLED=true;弱网再开 OKX_*_PROXY
# =============================================================================
APP_ENV=production 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 APP_HOST=0.0.0.0
# 服务端口 # 服务端口(宝塔反代到此端口,如 http://127.0.0.1:5010)
APP_PORT=5010 APP_PORT=5010
# 是否开启调试模式(生产建议 false) # 是否开启调试模式(生产 / 云上务必 false)
APP_DEBUG=false APP_DEBUG=false
# 登录账号 # 登录账号
APP_USERNAME=admin APP_USERNAME=admin
# 登录密码(请改成你自己的强密码) # 登录密码(请改成你自己的强密码;首次部署脚本也可能写成 admin123)
APP_PASSWORD=admin123 APP_PASSWORD=admin123
# 是否关闭登录校验(局域网可设 true;公网务必 false) # 是否关闭登录校验(云服务器 / 公网务必 false;仅信任局域网可 true)
APP_AUTH_DISABLED=true APP_AUTH_DISABLED=false
# --- 多账户交易中控 manual_trading_hub --- # --- 多账户交易中控 manual_trading_hub ---
# 中控请求本实例 /api/hub/* 时携带请求头 X-Hub-Token,须与中控启动环境变量 HUB_BRIDGE_TOKEN 一致 # 中控请求本实例 /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 # HUB_BRIDGE_TOKEN=your-long-random-token
# 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启) # 允许复盘中控 iframe 内嵌本实例(与 hub 域名一致;默认已开启)
# APP_ALLOW_HUB_EMBED=true # APP_ALLOW_HUB_EMBED=true
# HUB_EMBED_PARENT_ORIGINS=https://hub.example.com # HUB_EMBED_PARENT_ORIGINS=https://hub.example.com
# HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None # HTTPS 且经 iframe 打开时建议 true;不设则 hub-sso 在 HTTPS 下也会自动尝试 SameSite=None
# 经宝塔 HTTPS 反代时建议打开:
# APP_COOKIE_SECURE=true # APP_COOKIE_SECURE=true
# Flask 会话密钥(必须替换为长随机字符串) # Flask 会话密钥(必须替换为长随机字符串;一键部署会自动生成)
FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET FLASK_SECRET_KEY=CHANGE_TO_LONG_RANDOM_SECRET
# 企业微信机器人 Webhook(用于行情/风控推送) # 企业微信机器人 Webhook(用于行情/风控推送)
@@ -301,13 +307,9 @@ OPENAI_MODEL=gemma4:e4b
OLLAMA_API=http://127.0.0.1:11434/api/generate OLLAMA_API=http://127.0.0.1:11434/api/generate
AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
# OKX 代理(可选,仅本地开发网络受限时用;云服务器部署请留空,直连 OKX 即可) # OKX 代理(云服务器香港/新加坡等请留空,直连即可;不要填本机 Windows 代理地址)
# 1) 先在本机建立隧道(示例): # 仅当云主机也访问不了 OKX 时再启用:
# ssh -N -D 127.0.0.1:1080 root@你的VPS_IP -o ServerAliveInterval=30 -o ExitOnForwardFailure=yes
# 2) 再启用下面这一行(推荐 socks5h,让远端解析域名):
# OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080 # OKX_SOCKS_PROXY=socks5h://127.0.0.1:1080
#
# 如你更偏向 HTTP 代理(VPS 上跑 tinyproxy 之类),可用:
# OKX_HTTP_PROXY=http://127.0.0.1:3128 # OKX_HTTP_PROXY=http://127.0.0.1:3128
# OKX_HTTPS_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 APP_TIMEZONE=Asia/Shanghai
# TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED # TRADING_DAY_RESET_HOUR 现在表示「北京时间」整点,默认 8 点起算新交易日;开仓整点限制见 TRADING_DAY_RESET_OPEN_GUARD_ENABLED
# --- CoinOX 模拟资金 --- # --- CoinOX 模拟资金(云上测试默认 sim,无需 OKX API) ---
# sim=本地模拟资金撮合; live=实盘账户 # sim=本地模拟资金撮合; live=实盘账户
# 默认模拟盘(本地钱包); 切实盘请改为 live 并配置 API # 切实盘:填 OKX_API_* , LIVE_TRADING_ENABLED=true , SIM_DEFAULT_MODE=live , 并在 OKX 白名单加云主机出口 IP
SIM_DEFAULT_MODE=sim SIM_DEFAULT_MODE=sim
SIM_INITIAL_EQUITY_USDT=10000 SIM_INITIAL_EQUITY_USDT=10000
SIM_INITIAL_USDC=0 SIM_INITIAL_USDC=0
+41 -12
View File
@@ -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() 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_RR_TRIGGER = float(os.getenv("BREAKEVEN_RR_TRIGGER", "1.0"))
BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02")) BREAKEVEN_OFFSET_PCT = float(os.getenv("BREAKEVEN_OFFSET_PCT", "0.02"))
BREAKEVEN_STEP_R = float(os.getenv("BREAKEVEN_STEP_R", "1.0")) 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 app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER
# 同一套 OKX_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同) # 同一套 OKX_API_*:swap 客户端跑永续,option 客户端跑期权(身份相同,defaultType 不同)
exchange = ccxt.okx({ # CoinOX: 不拉全量 SPOT instruments(国内线路易超时导致行情空白),仅 swap/option(+现货桥需要 spot)
exchange = ccxt.okx(
{
"enableRateLimit": True, "enableRateLimit": True,
"options": {"defaultType": "swap"}, # OKX 用 swap 表示永续 "timeout": 20000,
}) "options": {
"defaultType": "swap",
# 仅 swap:避免 SPOT 全量 instruments 在部分线路超时导致行情空白
"fetchMarkets": ["swap"],
},
}
)
if OKX_CCXT_PROXIES: if OKX_CCXT_PROXIES:
exchange.proxies = OKX_CCXT_PROXIES exchange.proxies = OKX_CCXT_PROXIES
if OKX_API_KEY and OKX_API_SECRET and OKX_API_PASSPHRASE: 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( exchange_options = ccxt.okx(
{ {
"enableRateLimit": True, "enableRateLimit": True,
"options": {"defaultType": "option"}, "timeout": 20000,
"options": {
"defaultType": "option",
"fetchMarkets": ["option", "swap"],
},
} }
) )
if OKX_CCXT_PROXIES: if OKX_CCXT_PROXIES:
@@ -2911,15 +2926,29 @@ def build_okx_order_params(direction, reduce_only=False):
def ensure_markets_loaded(force=False): def ensure_markets_loaded(force=False):
global MARKETS_LOADED global MARKETS_LOADED
if force or not MARKETS_LOADED: if force or not MARKETS_LOADED:
last_err = None
for attempt in range(3):
try: try:
exchange.load_markets(reload=force) exchange.load_markets(reload=force or attempt > 0)
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 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): def _okx_algo_trigger_price_str(exchange_symbol, price):
+1
View File
@@ -37,6 +37,7 @@ bash /opt/coinox/deploy/manage.sh
- 访问: `http://<服务器IP>:5010` (端口以 `.env` 的 `APP_PORT` 为准) - 访问: `http://<服务器IP>:5010` (端口以 `.env` 的 `APP_PORT` 为准)
- 默认入口: `/hedge-plan`(对冲)/ `/options`(期权);默认模拟盘 sim - 默认入口: `/hedge-plan`(对冲)/ `/options`(期权);默认模拟盘 sim
- 登录账号: **admin**(密码见 `.env` 的 `APP_PASSWORD`,首次脚本可写成 `admin123`) - 登录账号: **admin**(密码见 `.env` 的 `APP_PASSWORD`,首次脚本可写成 `admin123`)
- 云服务器默认: `APP_AUTH_DISABLED=false`、不配代理;宝塔反代见根目录 [部署文档.md](../部署文档.md)
--- ---
+9 -7
View File
@@ -420,23 +420,25 @@ print_post_install_guide() {
cat <<EOF cat <<EOF
═══════════════════════════════════════════ ═══════════════════════════════════════════
✅ CoinOX 部署完成(默认模拟盘 sim) ✅ CoinOX 部署完成(云服务器测试默认)
访问: http://${ip}:${port} 访问: http://${ip}:${port}
入口: /hedge-plan(对冲) /options(期权) 入口: /hedge-plan(对冲) /options(期权)
登录账号: admin 登录账号: admin
登录密码: 见 .env 中 APP_PASSWORD(首次多为 admin123) 登录密码: 见 .env 中 APP_PASSWORD(首次多为 admin123)
(请尽快在系统设置 / env 配置中修改) 鉴权: APP_AUTH_DISABLED=false(公网必须登录)
模式说明: 模式说明:
- SIM_DEFAULT_MODE=sim(本地模拟资金,不打真实私有下单) - SIM_DEFAULT_MODE=sim(模拟资金,无需 OKX API)
- 实盘: 在页面/env 切 live,并配置 OKX_API_* 与 LIVE_TRADING_ENABLED - 云主机请勿配置 OKX_*_PROXY(直连 OKX)
- 宝塔反代: 目标 http://127.0.0.1:${port},对外只开 80/443
- 实盘: 填 OKX_API_* + LIVE_TRADING_ENABLED=true + 切 live,并白名单出口 IP
下一步: 下一步:
1. 浏览器打开上方地址 1. 浏览器打开上方地址并登录
2. 模拟盘直接测期权 / 对冲计划 2. 模拟盘测期权 / 对冲计划
3. 需要实盘时再填 API / 代理并切 live 3. 可选: 宝塔 Nginx 反代域名 + SSL
管理: 管理:
bash ${INSTALL_ROOT}/deploy/manage.sh bash ${INSTALL_ROOT}/deploy/manage.sh
+2 -1
View File
@@ -207,7 +207,8 @@ fi
echo "" echo ""
echo "部署完成.下一步:" echo "部署完成.下一步:"
echo " 1. 编辑 ${REPO_ROOT}/.env(默认已是模拟盘 sim;实盘再填 OKX API / 代理 / 密码)" echo " 1. 编辑 ${REPO_ROOT}/.env(云上默认: 模拟盘 sim + 必须登录 + 无代理;改 APP_PASSWORD)"
echo " 2. pm2 start ecosystem.config.cjs" echo " 2. pm2 start ecosystem.config.cjs"
echo " 3. 可选: 宝塔 Nginx 反代到 http://127.0.0.1:5010(详见 部署文档.md)"
echo " 或: bash deploy/manage.sh # 选 1" echo " 或: bash deploy/manage.sh # 选 1"
echo "" echo ""
+12
View File
@@ -990,6 +990,18 @@
const amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4; const amtPrec = d.amount_precision != null ? Number(d.amount_precision) : 4;
const markEl = $("hp-po-mark"); const markEl = $("hp-po-mark");
if (markEl) markEl.textContent = "标记 " + fmt(d.mark, 2); if (markEl) markEl.textContent = "标记 " + fmt(d.mark, 2);
// 指数优先用行情接口,期权链慢/失败时也不再显示 --
const mIdx = d.index_px != null ? Number(d.index_px) : NaN;
if (Number.isFinite(mIdx) && mIdx > 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 = const quoteHtml =
"可用 <strong>" + "可用 <strong>" +
fmt(d.available_usdt, 2) + fmt(d.available_usdt, 2) +
+45 -3
View File
@@ -723,11 +723,34 @@ def options_header_balance_pack(
def fetch_index_price(ex: ccxt.okx, uly: str) -> float | None: 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:
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: try:
rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or [] rows = ex.public_get_market_index_tickers({"instId": inst}).get("data") or []
if rows: if rows:
return _safe_float(rows[0].get("idxPx")) 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:
px = _safe_float(rows[0].get("markPx")) or _safe_float(rows[0].get("last"))
if px and px > 0:
return px
except Exception: except Exception:
pass pass
return None return None
@@ -755,6 +778,16 @@ def fetch_option_instruments(
): ):
return list(entry["rows"]) 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: try:
rows = ex.public_get_public_instruments( rows = ex.public_get_public_instruments(
{"instType": "OPTION", "instFamily": family} {"instType": "OPTION", "instFamily": family}
@@ -764,6 +797,7 @@ def fetch_option_instruments(
_OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live} _OPTION_INSTRUMENTS_CACHE[family] = {"updated_at": now, "rows": live}
return list(live) return list(live)
except Exception as e: except Exception as e:
last_err = e
if allow_stale: if allow_stale:
with _OPTION_INSTRUMENTS_CACHE_LOCK: with _OPTION_INSTRUMENTS_CACHE_LOCK:
entry = _OPTION_INSTRUMENTS_CACHE.get(family) entry = _OPTION_INSTRUMENTS_CACHE.get(family)
@@ -771,11 +805,19 @@ def fetch_option_instruments(
age = now - float(entry.get("updated_at") or 0) age = now - float(entry.get("updated_at") or 0)
if age <= _OPTION_INSTRUMENTS_STALE_MAX: if age <= _OPTION_INSTRUMENTS_STALE_MAX:
return list(entry["rows"]) return list(entry["rows"])
raise raise last_err
def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]: def fetch_option_tickers(ex: ccxt.okx, inst_family: str) -> dict[str, dict[str, Any]]:
out: 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: try:
rows = ex.public_get_market_tickers( rows = ex.public_get_market_tickers(
{"instType": "OPTION", "instFamily": inst_family} {"instType": "OPTION", "instFamily": inst_family}
+167
View File
@@ -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
+34 -69
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import os import os
import time
from typing import Any, Optional from typing import Any, Optional
from flask import Flask, jsonify, request from flask import Flask, jsonify, request
@@ -10,16 +11,10 @@ from jinja2 import ChoiceLoader, FileSystemLoader
from lib.hedge_plan.hedge_plan_calc_lib import ( from lib.hedge_plan.hedge_plan_calc_lib import (
build_options_options_preview, build_options_options_preview,
build_perp_options_preview, build_perp_options_preview,
floor_contracts_to_precision,
gate_status, gate_status,
option_premium_total, 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: 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]: 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") 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") norm = cfg.get("normalize_exchange_symbol")
sym = f"{base}/USDT:USDT" sym = f"{base}/USDT:USDT"
if callable(norm): 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") sym = norm(f"{base}/USDT")
except Exception: except Exception:
sym = f"{base}/USDT:USDT" sym = f"{base}/USDT:USDT"
mark = bid = ask = last = None
mark = bid = ask = last = index_px = None
last_err: Exception | None = None
try: try:
t = ex.fetch_ticker(sym) from lib.exchange.okx_public_http_lib import fetch_index_px, fetch_swap_ticker
last = _sf(t.get("last"))
mark = _sf(t.get("info", {}).get("markPx")) if isinstance(t.get("info"), dict) else None row = fetch_swap_ticker(base)
if mark is None: last = _sf(row.get("last"))
mark = _sf(t.get("mark")) or last mark = _sf(row.get("markPx")) or last
bid = _sf(t.get("bid")) bid = _sf(row.get("bidPx"))
ask = _sf(t.get("ask")) ask = _sf(row.get("askPx"))
index_px = fetch_index_px(base)
except Exception as e: except Exception as e:
return {}, f"拉永续行情失败: {e}" last_err = e
if ex is not None:
cs = 0.01
get_cs = cfg.get("get_contract_size")
if callable(get_cs):
try: try:
cs = float(get_cs(sym) or 0.01) inst = f"{base}-USDT-SWAP"
except Exception: rows = (ex.public_get_market_ticker({"instId": inst}) or {}).get("data") or []
cs = 0.01 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 available = None
get_av = cfg.get("get_available_trading_usdt") amount_precision = 2 if base == "ETH" else 4
if callable(get_av):
try:
available = get_av()
except Exception:
available = None
entry = float(mark or last or 0)
sizing = None sizing = None
suggest_contracts = None suggest_contracts = None
amount_precision = 4 entry = float(mark or last or 0)
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)
return { return {
"exchange_symbol": sym, "exchange_symbol": sym,
@@ -1395,6 +1359,7 @@ def _fetch_perp_market(cfg: dict[str, Any], base: str) -> tuple[dict[str, Any],
"last": last, "last": last,
"bid": bid, "bid": bid,
"ask": ask, "ask": ask,
"index_px": index_px,
"contract_size": cs, "contract_size": cs,
"available_usdt": available, "available_usdt": available,
"full_margin_sizing": sizing, "full_margin_sizing": sizing,
+13 -3
View File
@@ -4,7 +4,7 @@
- FLASK_SECRET_KEY - FLASK_SECRET_KEY
- APP_USERNAME=admin / APP_PASSWORD=admin123(仅空时) - APP_USERNAME=admin / APP_PASSWORD=admin123(仅空时)
- HUB_BRIDGE_TOKEN(仅空时,供后期中控) - HUB_BRIDGE_TOKEN(仅空时,供后期中控)
- 强制默认模拟盘相关键(仅空/占位时) - 云服务器测试默认:监听/鉴权/模拟盘(仅空或占位时)
""" """
from __future__ import annotations from __future__ import annotations
@@ -57,7 +57,15 @@ def main() -> int:
if not (env_get(lines, "HUB_BRIDGE_TOKEN") or "").strip(): if not (env_get(lines, "HUB_BRIDGE_TOKEN") or "").strip():
updates["HUB_BRIDGE_TOKEN"] = secrets.token_urlsafe(32) 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(): if not (env_get(lines, "SIM_DEFAULT_MODE") or "").strip():
updates["SIM_DEFAULT_MODE"] = "sim" updates["SIM_DEFAULT_MODE"] = "sim"
if not (env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "").strip(): if not (env_get(lines, "OKX_OPTIONS_MARGIN_MODE") or "").strip():
@@ -66,6 +74,8 @@ def main() -> int:
updates["EXCHANGE_DISPLAY_NAME"] = "CoinOX" updates["EXCHANGE_DISPLAY_NAME"] = "CoinOX"
if not (env_get(lines, "OKX_OPTIONS_ENABLED") or "").strip(): if not (env_get(lines, "OKX_OPTIONS_ENABLED") or "").strip():
updates["OKX_OPTIONS_ENABLED"] = "true" 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() live = (env_get(lines, "LIVE_TRADING_ENABLED") or "").strip().lower()
if live in ("",): if live in ("",):
updates["LIVE_TRADING_ENABLED"] = "false" updates["LIVE_TRADING_ENABLED"] = "false"
@@ -86,7 +96,7 @@ def main() -> int:
return 0 return 0
apply_env_updates(env_path, updates) apply_env_updates(env_path, updates)
print(" 已写入初始密钥/账号与模拟盘默认(请尽快修改 APP_PASSWORD)") print(" 已写入云服务器测试默认(请尽快修改 APP_PASSWORD;公网保持 APP_AUTH_DISABLED=false)")
return 0 return 0
+45 -2
View File
@@ -39,9 +39,52 @@ bash /opt/coinox/deploy/manage.sh
- 访问:`http://<服务器IP>:5010` - 访问:`http://<服务器IP>:5010`
- 默认入口:`/hedge-plan`(对冲)、`/options`(期权) - 默认入口:`/hedge-plan`(对冲)、`/options`(期权)
- 登录:`admin` / `.env` 中 `APP_PASSWORD`(首次多为 `admin123`) - 登录:`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` | 留空 |
--- ---