From c6e8f6fe1e0d3ded96b4a4830fc79828919d664e Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 29 Jul 2026 19:57:09 +0800 Subject: [PATCH] Support LIVE exchange funds and perp margin mode. Hide sim equity when LIVE is selected; OKX funds bar uses exchange balances; add cross/isolated for perp only (options stay cash). Co-authored-by: Cursor --- backend/app/api/funds.py | 22 ++++++++++------ backend/app/api/settings.py | 16 ++++++++++++ backend/app/config.py | 2 ++ backend/app/live/executor.py | 19 +++++++++++--- backend/app/models/db.py | 1 + backend/app/strategy/engine.py | 8 ++++++ docs/OKX实盘策略说明.md | 6 +++-- docs/更新说明.md | 10 ++++++++ docs/策略说明.md | 3 ++- frontend/src/api/client.ts | 2 ++ frontend/src/components/FundsBar.tsx | 31 +++++++++++++++++++---- frontend/src/pages/Settings.tsx | 38 +++++++++++++++++++++++++--- 12 files changed, 134 insertions(+), 24 deletions(-) diff --git a/backend/app/api/funds.py b/backend/app/api/funds.py index fac0f20..b17d322 100644 --- a/backend/app/api/funds.py +++ b/backend/app/api/funds.py @@ -115,9 +115,13 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st trading_usdt = bal.get("trading_usdt") funding_usdc = bal.get("funding_usdc") trading_usdc = bal.get("trading_usdc") - parts = [funding_usdt, trading_usdt, funding_usdc, trading_usdc] - vals = [float(x) for x in parts if x is not None] - total = round(sum(vals), 2) if vals else None + r = float(rate) if rate and rate > 0 else 1.0 + total = round( + (funding_usdt or 0.0) + + (trading_usdt or 0.0) + + ((funding_usdc or 0.0) + (trading_usdc or 0.0)) * r, + 2, + ) except Exception as e: return { "ok": False, @@ -128,11 +132,13 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st finally: client.close() else: - # 非 OKX LIVE:回退本地账本 - led = Ledger(db).snapshot() - trading_usdt = float(led["equity"]) - total = trading_usdt - + # 非 OKX LIVE:暂无统一资金接口,不回退模拟账本(避免实盘显示假资金) + return { + "ok": False, + "mode": mode, + "exchange": exchange, + "detail": f"{exchange} 实盘资金摘要暂未接入,请用 OKX 或交易所 App 查看", + } return { "ok": True, "mode": mode, diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 2cb16ef..2363587 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -37,6 +37,7 @@ KEYS = ( "skip_weekends", "initial_equity", "leverage", + "perp_margin_mode", "min_option_hours", "min_option_leverage", "atm_open_offset_enabled", @@ -61,6 +62,7 @@ class StrategySettingsBody(BaseModel): skip_weekends: bool | None = None initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000) leverage: float | None = Field(default=None, ge=1, le=125) + perp_margin_mode: str | None = Field(default=None, pattern="^(cross|isolated)$") min_option_hours: float | None = Field(default=None, ge=1, le=720) min_option_leverage: float | None = Field(default=None, ge=1, le=10000) atm_open_offset_enabled: bool | None = None @@ -117,6 +119,20 @@ def _read_settings() -> dict: db.get_setting("initial_equity", str(s.initial_equity)) or s.initial_equity ), "leverage": float(db.get_setting("leverage", str(s.leverage)) or s.leverage), + "perp_margin_mode": ( + mm + if ( + mm := str( + db.get_setting("perp_margin_mode", s.perp_margin_mode) + or s.perp_margin_mode + or "cross" + ) + .strip() + .lower() + ) + in ("cross", "isolated") + else "cross" + ), "min_option_hours": float( db.get_setting("min_option_hours", str(s.min_option_hours)) or s.min_option_hours diff --git a/backend/app/config.py b/backend/app/config.py index 242df7b..9be1440 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -68,6 +68,8 @@ class Settings(BaseSettings): live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒) skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平) leverage: float = 3.0 # 永续杠杆 + # 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cash(OKX 逐仓/现金) + perp_margin_mode: str = "cross" min_option_hours: float = 12.0 # 期权最小剩余小时 min_option_leverage: float = 100.0 # 现价/卖一权利金 下限 atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关) diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py index 0b67834..598d879 100644 --- a/backend/app/live/executor.py +++ b/backend/app/live/executor.py @@ -37,6 +37,16 @@ class OkxLiveExecutor(Matcher): self._trade = OkxTradeClient() return self._trade + def _perp_margin_mode(self) -> str: + """永续全仓/逐仓;期权始终 cash,不受此设置影响。""" + s = get_settings() + raw = str( + self.ledger.get_setting_str("perp_margin_mode", s.perp_margin_mode) + or s.perp_margin_mode + or "cross" + ).strip().lower() + return "isolated" if raw == "isolated" else "cross" + def _guard_live(self) -> str | None: ok, reason = live_ready() if not ok: @@ -145,9 +155,10 @@ class OkxLiveExecutor(Matcher): else: side, pos_side = "sell", "short" leverage = self.ledger.get_setting_float("leverage", s.leverage) + mgn = self._perp_margin_mode() try: client.set_leverage( - perp_inst, leverage, mgn_mode="cross", pos_side=pos_side + perp_inst, leverage, mgn_mode=mgn, pos_side=pos_side ) except Exception as e_lev: logger.warning("okx set_leverage failed: %s", e_lev) @@ -155,7 +166,7 @@ class OkxLiveExecutor(Matcher): inst_id=perp_inst, side=side, sz=str(perp_sz), - td_mode="cross", + td_mode=mgn, pos_side=pos_side, ) except Exception as e: @@ -642,7 +653,7 @@ class OkxLiveExecutor(Matcher): inst_id=perp_inst, side=side, sz=str(perp_sz), - td_mode="cross", + td_mode=self._perp_margin_mode(), pos_side=pos_side, reduce_only=True, ) @@ -927,7 +938,7 @@ class OkxLiveExecutor(Matcher): inst_id=perp_inst, side=side, sz=str(perp_sz), - td_mode="cross", + td_mode=self._perp_margin_mode(), pos_side=pos_side, reduce_only=True, ) diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 77d2d0f..df2a735 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -228,6 +228,7 @@ class Database: "skip_weekends": str(s.skip_weekends), "max_rounds": str(s.max_rounds), "leverage": str(s.leverage), + "perp_margin_mode": str(s.perp_margin_mode), "min_option_hours": str(s.min_option_hours), "min_option_leverage": str(s.min_option_leverage), "atm_open_offset_enabled": str(s.atm_open_offset_enabled), diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index a96b4e2..88a147f 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -83,6 +83,13 @@ class StrategyEngine: rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds) skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends) leverage = self.ledger.get_setting_float("leverage", s.leverage) + perp_mm = str( + self.ledger.get_setting_str("perp_margin_mode", s.perp_margin_mode) + or s.perp_margin_mode + or "cross" + ).strip().lower() + if perp_mm not in ("cross", "isolated"): + perp_mm = "cross" min_hours = self.ledger.get_setting_float("min_option_hours", s.min_option_hours) min_opt_lev = self.ledger.get_setting_float( "min_option_leverage", s.min_option_leverage @@ -140,6 +147,7 @@ class StrategyEngine: "premium_exit_multiple": prem_mult, "exit_target_usdt": exit_amt, "leverage": leverage, + "perp_margin_mode": perp_mm, "perp_qty_eth": perp_qty, "option_qty_eth": opt_qty, "min_option_hours": min_hours, diff --git a/docs/OKX实盘策略说明.md b/docs/OKX实盘策略说明.md index 0561070..f4f991c 100644 --- a/docs/OKX实盘策略说明.md +++ b/docs/OKX实盘策略说明.md @@ -32,10 +32,12 @@ ATM **期权买方** + **反向永续**;净利达标兑现,未达标拖到 | 项 | 建议 | |----|------| -| 永续 | 全仓 `cross`;双向持仓(hedge)与软件 `posSide` 对齐 | -| 期权 | **买卖模式 / cash**(买方付权利金);尽量 **逐仓/独立**,便于远虚残留不挡下一组 | +| 永续 | 设置页可选 **全仓 `cross`(默认)/ 逐仓 `isolated`**;双向持仓(hedge)与软件 `posSide` 对齐 | +| 期权 | 固定 **cash**(买方付权利金),**不受**永续保证金模式选项影响 | | API | Key + Secret + **Passphrase**;IP 白名单;交易权限最小化 | +实盘顶部资金条(总资金 / 资金账户 / 交易账户)直接读 OKX;SIM 才显示本地模拟资金。 + --- ## 3. 标准仓与缩放 diff --git a/docs/更新说明.md b/docs/更新说明.md index 62a2862..e590415 100644 --- a/docs/更新说明.md +++ b/docs/更新说明.md @@ -5,6 +5,16 @@ --- +## 2026-07-29 — 实盘资金读交易所;永续全仓/逐仓 + +### 变更 + +1. 选择 LIVE 时立即隐藏「模拟资金」(跟表单模式,不必等保存后的 runtime)。 +2. LIVE 资金条总资金/资金账户/交易账户读 OKX;总资金按 USDT 等值(USDC×汇率);划转/兑换后立即刷新顶栏。 +3. 策略仓位增加「永续保证金模式」:全仓(默认)/ 逐仓;仅永续;OKX 期权仍固定 cash。 + +--- + ## 2026-07-29 — 顶栏名称/导航去背景 ### 变更 diff --git a/docs/策略说明.md b/docs/策略说明.md index 713dcd3..6945d0d 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -37,7 +37,7 @@ **平仓顺序**:同样先平期权(买一薄)→ 再瞬时平永续。平期权期间永续对冲先留着;永续盘口失败则回滚期权入账。 -永续杠杆默认 **3×**(可配)。同时最多 **1 组**仓,禁止叠仓开下一组。 +永续杠杆默认 **3×**(可配)。永续保证金模式默认 **全仓**(可改逐仓);期权保证金模式固定买方现金/逐仓意图。同时最多 **1 组**仓,禁止叠仓开下一组。 ### 2.1 开仓方向 @@ -331,6 +331,7 @@ | `perp_qty_eth` | 1 | 仓位 | | `option_qty_eth` | 2 | 仓位 | | `leverage` | 3 | 永续 | +| `perp_margin_mode` | cross | 永续全仓/逐仓(期权不受影响) | | `fee_rate` | 0.0005 | 成本 | | `exit_mode` | fixed_usdt | 出场 | | `net_profit_target` | 15 | 出场 | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c6d71f6..6500597 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -228,6 +228,7 @@ export type PlanState = { premium_exit_multiple: number; exit_target_usdt: number; leverage: number; + perp_margin_mode?: "cross" | "isolated"; perp_qty_eth?: number; option_qty_eth?: number; min_option_hours: number; @@ -311,6 +312,7 @@ export type StrategySettings = { skip_weekends?: boolean; initial_equity?: number; leverage?: number; + perp_margin_mode?: "cross" | "isolated"; min_option_hours?: number; min_option_leverage?: number; atm_open_offset_enabled?: boolean; diff --git a/frontend/src/components/FundsBar.tsx b/frontend/src/components/FundsBar.tsx index 49620ee..b246fde 100644 --- a/frontend/src/components/FundsBar.tsx +++ b/frontend/src/components/FundsBar.tsx @@ -4,6 +4,7 @@ import { apiFetch } from "../api/client"; export type FundsSummary = { ok: boolean; exchange: string; + mode?: string; trading_day: string; total_trades: number; win_rate: number; @@ -53,22 +54,42 @@ function DualCcy({ export default function FundsBar() { const [s, setS] = useState(null); + const [err, setErr] = useState(""); const load = useCallback(() => { apiFetch("/api/funds/summary") - .then(setS) - .catch(() => { - /* 顶栏静默失败,避免刷屏 */ + .then((r) => { + setS(r); + setErr(r.ok ? "" : r.detail || "资金摘要不可用"); + }) + .catch((e) => { + setErr(e instanceof Error ? e.message : String(e)); }); }, []); useEffect(() => { load(); const t = window.setInterval(load, 5000); - return () => window.clearInterval(t); + const onRefresh = () => load(); + window.addEventListener("funds-refresh", onRefresh); + return () => { + window.clearInterval(t); + window.removeEventListener("funds-refresh", onRefresh); + }; }, [load]); - if (!s?.ok) return null; + if (!s?.ok) { + if (!err) return null; + return ( +
+
+
+ {err} +
+
+
+ ); + } return (
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d4a28b6..21ef82d 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -71,6 +71,9 @@ export default function SettingsPage() { const [orderInterval, setOrderInterval] = useState(1); const [skipWeekends, setSkipWeekends] = useState(true); const [leverage, setLeverage] = useState(3); + const [perpMarginMode, setPerpMarginMode] = useState<"cross" | "isolated">( + "cross", + ); const [minHours, setMinHours] = useState(12); const [minOptLev, setMinOptLev] = useState(100); const [atmOffOn, setAtmOffOn] = useState(false); @@ -157,6 +160,9 @@ export default function SettingsPage() { setOrderInterval(s.live_order_interval_sec ?? 1); setSkipWeekends(s.skip_weekends !== false); setLeverage(s.leverage ?? 3); + setPerpMarginMode( + s.perp_margin_mode === "isolated" ? "isolated" : "cross", + ); setMinHours(s.min_option_hours ?? 12); setMinOptLev(s.min_option_leverage ?? 100); setAtmOffOn(s.atm_open_offset_enabled === true); @@ -292,7 +298,7 @@ export default function SettingsPage() { e.preventDefault(); setStratOk(""); setErr(""); - const isLive = (runtime?.mode ?? mode) === "LIVE"; + const isLive = mode === "LIVE"; try { const body: Record = { fee_rate: fee, @@ -303,6 +309,7 @@ export default function SettingsPage() { live_order_interval_sec: orderInterval, skip_weekends: skipWeekends, leverage, + perp_margin_mode: perpMarginMode, min_option_hours: minHours, min_option_leverage: minOptLev, atm_open_offset_enabled: atmOffOn, @@ -375,6 +382,7 @@ export default function SettingsPage() { : `已切 LIVE,但未就绪:${r.live_ready_reason}` : "已切换 SIM,配置已写入 .env", ); + window.dispatchEvent(new Event("funds-refresh")); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { @@ -531,7 +539,7 @@ export default function SettingsPage() {
- {(runtime?.mode ?? mode) !== "LIVE" ? ( + {mode !== "LIVE" ? (
setLeverage(Number(e.target.value))} />
+
+ + +
  • - {(runtime?.mode ?? mode) === "LIVE" + {mode === "LIVE" ? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。" : "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"}
  • {stratSub === "position" ? ( <>
  • 交易所:有持仓时不可切换。切换后套用该所合约并重连行情。
  • - {(runtime?.mode ?? mode) !== "LIVE" ? ( + {mode !== "LIVE" ? (
  • 模拟资金:仅 SIM 显示。保存后重置权益(须无持仓)。默认 10000。
  • ) : null} +
  • + 永续保证金模式:仅作用于永续(全仓/逐仓,默认全仓)。OKX + 期权始终为现金/逐仓(cash),不受此选项影响。 +
  • ) : null} {stratSub === "select" ? ( @@ -1168,6 +1196,7 @@ export default function SettingsPage() { }), }); setFundsOk("兑换成功,顶部资金条将刷新"); + window.dispatchEvent(new Event("funds-refresh")); } catch (e) { setFundsErr(e instanceof Error ? e.message : String(e)); } finally { @@ -1257,6 +1286,7 @@ export default function SettingsPage() { }), }); setFundsOk("划转成功,顶部资金条将刷新"); + window.dispatchEvent(new Event("funds-refresh")); } catch (e) { setFundsErr(e instanceof Error ? e.message : String(e)); } finally {