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 <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-29 19:57:09 +08:00
parent 50a4540aaf
commit c6e8f6fe1e
12 changed files with 134 additions and 24 deletions
+14 -8
View File
@@ -115,9 +115,13 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st
trading_usdt = bal.get("trading_usdt") trading_usdt = bal.get("trading_usdt")
funding_usdc = bal.get("funding_usdc") funding_usdc = bal.get("funding_usdc")
trading_usdc = bal.get("trading_usdc") trading_usdc = bal.get("trading_usdc")
parts = [funding_usdt, trading_usdt, funding_usdc, trading_usdc] r = float(rate) if rate and rate > 0 else 1.0
vals = [float(x) for x in parts if x is not None] total = round(
total = round(sum(vals), 2) if vals else None (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: except Exception as e:
return { return {
"ok": False, "ok": False,
@@ -128,11 +132,13 @@ async def funds_summary(_user: Annotated[str, Depends(require_user)]) -> dict[st
finally: finally:
client.close() client.close()
else: else:
# 非 OKX LIVE回退本地账本 # 非 OKX LIVE暂无统一资金接口,不回退模拟账本(避免实盘显示假资金)
led = Ledger(db).snapshot() return {
trading_usdt = float(led["equity"]) "ok": False,
total = trading_usdt "mode": mode,
"exchange": exchange,
"detail": f"{exchange} 实盘资金摘要暂未接入,请用 OKX 或交易所 App 查看",
}
return { return {
"ok": True, "ok": True,
"mode": mode, "mode": mode,
+16
View File
@@ -37,6 +37,7 @@ KEYS = (
"skip_weekends", "skip_weekends",
"initial_equity", "initial_equity",
"leverage", "leverage",
"perp_margin_mode",
"min_option_hours", "min_option_hours",
"min_option_leverage", "min_option_leverage",
"atm_open_offset_enabled", "atm_open_offset_enabled",
@@ -61,6 +62,7 @@ class StrategySettingsBody(BaseModel):
skip_weekends: bool | None = None skip_weekends: bool | None = None
initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000) initial_equity: float | None = Field(default=None, ge=1000, le=10_000_000)
leverage: float | None = Field(default=None, ge=1, le=125) 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_hours: float | None = Field(default=None, ge=1, le=720)
min_option_leverage: float | None = Field(default=None, ge=1, le=10000) min_option_leverage: float | None = Field(default=None, ge=1, le=10000)
atm_open_offset_enabled: bool | None = None 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 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), "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( "min_option_hours": float(
db.get_setting("min_option_hours", str(s.min_option_hours)) db.get_setting("min_option_hours", str(s.min_option_hours))
or s.min_option_hours or s.min_option_hours
+2
View File
@@ -68,6 +68,8 @@ class Settings(BaseSettings):
live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒) live_order_interval_sec: float = 1.0 # LIVE 私有下单/查单最小间隔(秒)
skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平) skip_weekends: bool = True # 上海时区周六日禁止新开仓(已有仓仍可平)
leverage: float = 3.0 # 永续杠杆 leverage: float = 3.0 # 永续杠杆
# 永续保证金模式:cross=全仓(默认)| isolated=逐仓;期权仍固定 cashOKX 逐仓/现金)
perp_margin_mode: str = "cross"
min_option_hours: float = 12.0 # 期权最小剩余小时 min_option_hours: float = 12.0 # 期权最小剩余小时
min_option_leverage: float = 100.0 # 现价/卖一权利金 下限 min_option_leverage: float = 100.0 # 现价/卖一权利金 下限
atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关) atm_open_offset_enabled: bool = False # 开仓 ATM 偏差限制开关(默认关)
+15 -4
View File
@@ -37,6 +37,16 @@ class OkxLiveExecutor(Matcher):
self._trade = OkxTradeClient() self._trade = OkxTradeClient()
return self._trade 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: def _guard_live(self) -> str | None:
ok, reason = live_ready() ok, reason = live_ready()
if not ok: if not ok:
@@ -145,9 +155,10 @@ class OkxLiveExecutor(Matcher):
else: else:
side, pos_side = "sell", "short" side, pos_side = "sell", "short"
leverage = self.ledger.get_setting_float("leverage", s.leverage) leverage = self.ledger.get_setting_float("leverage", s.leverage)
mgn = self._perp_margin_mode()
try: try:
client.set_leverage( 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: except Exception as e_lev:
logger.warning("okx set_leverage failed: %s", e_lev) logger.warning("okx set_leverage failed: %s", e_lev)
@@ -155,7 +166,7 @@ class OkxLiveExecutor(Matcher):
inst_id=perp_inst, inst_id=perp_inst,
side=side, side=side,
sz=str(perp_sz), sz=str(perp_sz),
td_mode="cross", td_mode=mgn,
pos_side=pos_side, pos_side=pos_side,
) )
except Exception as e: except Exception as e:
@@ -642,7 +653,7 @@ class OkxLiveExecutor(Matcher):
inst_id=perp_inst, inst_id=perp_inst,
side=side, side=side,
sz=str(perp_sz), sz=str(perp_sz),
td_mode="cross", td_mode=self._perp_margin_mode(),
pos_side=pos_side, pos_side=pos_side,
reduce_only=True, reduce_only=True,
) )
@@ -927,7 +938,7 @@ class OkxLiveExecutor(Matcher):
inst_id=perp_inst, inst_id=perp_inst,
side=side, side=side,
sz=str(perp_sz), sz=str(perp_sz),
td_mode="cross", td_mode=self._perp_margin_mode(),
pos_side=pos_side, pos_side=pos_side,
reduce_only=True, reduce_only=True,
) )
+1
View File
@@ -228,6 +228,7 @@ class Database:
"skip_weekends": str(s.skip_weekends), "skip_weekends": str(s.skip_weekends),
"max_rounds": str(s.max_rounds), "max_rounds": str(s.max_rounds),
"leverage": str(s.leverage), "leverage": str(s.leverage),
"perp_margin_mode": str(s.perp_margin_mode),
"min_option_hours": str(s.min_option_hours), "min_option_hours": str(s.min_option_hours),
"min_option_leverage": str(s.min_option_leverage), "min_option_leverage": str(s.min_option_leverage),
"atm_open_offset_enabled": str(s.atm_open_offset_enabled), "atm_open_offset_enabled": str(s.atm_open_offset_enabled),
+8
View File
@@ -83,6 +83,13 @@ class StrategyEngine:
rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds) rest_sec = self.ledger.get_setting_int("rest_seconds", s.rest_seconds)
skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends) skip_weekends = self.ledger.get_setting_bool("skip_weekends", s.skip_weekends)
leverage = self.ledger.get_setting_float("leverage", s.leverage) 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_hours = self.ledger.get_setting_float("min_option_hours", s.min_option_hours)
min_opt_lev = self.ledger.get_setting_float( min_opt_lev = self.ledger.get_setting_float(
"min_option_leverage", s.min_option_leverage "min_option_leverage", s.min_option_leverage
@@ -140,6 +147,7 @@ class StrategyEngine:
"premium_exit_multiple": prem_mult, "premium_exit_multiple": prem_mult,
"exit_target_usdt": exit_amt, "exit_target_usdt": exit_amt,
"leverage": leverage, "leverage": leverage,
"perp_margin_mode": perp_mm,
"perp_qty_eth": perp_qty, "perp_qty_eth": perp_qty,
"option_qty_eth": opt_qty, "option_qty_eth": opt_qty,
"min_option_hours": min_hours, "min_option_hours": min_hours,
+4 -2
View File
@@ -32,10 +32,12 @@ ATM **期权买方** + **反向永续**;净利达标兑现,未达标拖到
| 项 | 建议 | | 项 | 建议 |
|----|------| |----|------|
| 永续 | 全仓 `cross`;双向持仓(hedge)与软件 `posSide` 对齐 | | 永续 | 设置页可选 **全仓 `cross`(默认)/ 逐仓 `isolated`**;双向持仓(hedge)与软件 `posSide` 对齐 |
| 期权 | **买卖模式 / cash**(买方付权利金);尽量 **逐仓/独立**,便于远虚残留不挡下一组 | | 期权 | 固定 **cash**(买方付权利金)**不受**永续保证金模式选项影响 |
| API | Key + Secret + **Passphrase**IP 白名单;交易权限最小化 | | API | Key + Secret + **Passphrase**IP 白名单;交易权限最小化 |
实盘顶部资金条(总资金 / 资金账户 / 交易账户)直接读 OKX;SIM 才显示本地模拟资金。
--- ---
## 3. 标准仓与缩放 ## 3. 标准仓与缩放
+10
View File
@@ -5,6 +5,16 @@
--- ---
## 2026-07-29 — 实盘资金读交易所;永续全仓/逐仓
### 变更
1. 选择 LIVE 时立即隐藏「模拟资金」(跟表单模式,不必等保存后的 runtime)。
2. LIVE 资金条总资金/资金账户/交易账户读 OKX;总资金按 USDT 等值(USDC×汇率);划转/兑换后立即刷新顶栏。
3. 策略仓位增加「永续保证金模式」:全仓(默认)/ 逐仓;仅永续;OKX 期权仍固定 cash。
---
## 2026-07-29 — 顶栏名称/导航去背景 ## 2026-07-29 — 顶栏名称/导航去背景
### 变更 ### 变更
+2 -1
View File
@@ -37,7 +37,7 @@
**平仓顺序**:同样先平期权(买一薄)→ 再瞬时平永续。平期权期间永续对冲先留着;永续盘口失败则回滚期权入账。 **平仓顺序**:同样先平期权(买一薄)→ 再瞬时平永续。平期权期间永续对冲先留着;永续盘口失败则回滚期权入账。
永续杠杆默认 **3×**(可配)。同时最多 **1 组**仓,禁止叠仓开下一组。 永续杠杆默认 **3×**(可配)。永续保证金模式默认 **全仓**(可改逐仓);期权保证金模式固定买方现金/逐仓意图。同时最多 **1 组**仓,禁止叠仓开下一组。
### 2.1 开仓方向 ### 2.1 开仓方向
@@ -331,6 +331,7 @@
| `perp_qty_eth` | 1 | 仓位 | | `perp_qty_eth` | 1 | 仓位 |
| `option_qty_eth` | 2 | 仓位 | | `option_qty_eth` | 2 | 仓位 |
| `leverage` | 3 | 永续 | | `leverage` | 3 | 永续 |
| `perp_margin_mode` | cross | 永续全仓/逐仓(期权不受影响) |
| `fee_rate` | 0.0005 | 成本 | | `fee_rate` | 0.0005 | 成本 |
| `exit_mode` | fixed_usdt | 出场 | | `exit_mode` | fixed_usdt | 出场 |
| `net_profit_target` | 15 | 出场 | | `net_profit_target` | 15 | 出场 |
+2
View File
@@ -228,6 +228,7 @@ export type PlanState = {
premium_exit_multiple: number; premium_exit_multiple: number;
exit_target_usdt: number; exit_target_usdt: number;
leverage: number; leverage: number;
perp_margin_mode?: "cross" | "isolated";
perp_qty_eth?: number; perp_qty_eth?: number;
option_qty_eth?: number; option_qty_eth?: number;
min_option_hours: number; min_option_hours: number;
@@ -311,6 +312,7 @@ export type StrategySettings = {
skip_weekends?: boolean; skip_weekends?: boolean;
initial_equity?: number; initial_equity?: number;
leverage?: number; leverage?: number;
perp_margin_mode?: "cross" | "isolated";
min_option_hours?: number; min_option_hours?: number;
min_option_leverage?: number; min_option_leverage?: number;
atm_open_offset_enabled?: boolean; atm_open_offset_enabled?: boolean;
+26 -5
View File
@@ -4,6 +4,7 @@ import { apiFetch } from "../api/client";
export type FundsSummary = { export type FundsSummary = {
ok: boolean; ok: boolean;
exchange: string; exchange: string;
mode?: string;
trading_day: string; trading_day: string;
total_trades: number; total_trades: number;
win_rate: number; win_rate: number;
@@ -53,22 +54,42 @@ function DualCcy({
export default function FundsBar() { export default function FundsBar() {
const [s, setS] = useState<FundsSummary | null>(null); const [s, setS] = useState<FundsSummary | null>(null);
const [err, setErr] = useState("");
const load = useCallback(() => { const load = useCallback(() => {
apiFetch<FundsSummary>("/api/funds/summary") apiFetch<FundsSummary>("/api/funds/summary")
.then(setS) .then((r) => {
.catch(() => { setS(r);
/* 顶栏静默失败,避免刷屏 */ setErr(r.ok ? "" : r.detail || "资金摘要不可用");
})
.catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
}); });
}, []); }, []);
useEffect(() => { useEffect(() => {
load(); load();
const t = window.setInterval(load, 5000); 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]); }, [load]);
if (!s?.ok) return null; if (!s?.ok) {
if (!err) return null;
return (
<div className="funds-bar-shell">
<div className="funds-bar-inner">
<div className="funds-bar funds-bar--err" aria-label="资金摘要异常">
<span className="meta">{err}</span>
</div>
</div>
</div>
);
}
return ( return (
<div className="funds-bar-shell"> <div className="funds-bar-shell">
+34 -4
View File
@@ -71,6 +71,9 @@ export default function SettingsPage() {
const [orderInterval, setOrderInterval] = useState(1); const [orderInterval, setOrderInterval] = useState(1);
const [skipWeekends, setSkipWeekends] = useState(true); const [skipWeekends, setSkipWeekends] = useState(true);
const [leverage, setLeverage] = useState(3); const [leverage, setLeverage] = useState(3);
const [perpMarginMode, setPerpMarginMode] = useState<"cross" | "isolated">(
"cross",
);
const [minHours, setMinHours] = useState(12); const [minHours, setMinHours] = useState(12);
const [minOptLev, setMinOptLev] = useState(100); const [minOptLev, setMinOptLev] = useState(100);
const [atmOffOn, setAtmOffOn] = useState(false); const [atmOffOn, setAtmOffOn] = useState(false);
@@ -157,6 +160,9 @@ export default function SettingsPage() {
setOrderInterval(s.live_order_interval_sec ?? 1); setOrderInterval(s.live_order_interval_sec ?? 1);
setSkipWeekends(s.skip_weekends !== false); setSkipWeekends(s.skip_weekends !== false);
setLeverage(s.leverage ?? 3); setLeverage(s.leverage ?? 3);
setPerpMarginMode(
s.perp_margin_mode === "isolated" ? "isolated" : "cross",
);
setMinHours(s.min_option_hours ?? 12); setMinHours(s.min_option_hours ?? 12);
setMinOptLev(s.min_option_leverage ?? 100); setMinOptLev(s.min_option_leverage ?? 100);
setAtmOffOn(s.atm_open_offset_enabled === true); setAtmOffOn(s.atm_open_offset_enabled === true);
@@ -292,7 +298,7 @@ export default function SettingsPage() {
e.preventDefault(); e.preventDefault();
setStratOk(""); setStratOk("");
setErr(""); setErr("");
const isLive = (runtime?.mode ?? mode) === "LIVE"; const isLive = mode === "LIVE";
try { try {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
fee_rate: fee, fee_rate: fee,
@@ -303,6 +309,7 @@ export default function SettingsPage() {
live_order_interval_sec: orderInterval, live_order_interval_sec: orderInterval,
skip_weekends: skipWeekends, skip_weekends: skipWeekends,
leverage, leverage,
perp_margin_mode: perpMarginMode,
min_option_hours: minHours, min_option_hours: minHours,
min_option_leverage: minOptLev, min_option_leverage: minOptLev,
atm_open_offset_enabled: atmOffOn, atm_open_offset_enabled: atmOffOn,
@@ -375,6 +382,7 @@ export default function SettingsPage() {
: `已切 LIVE,但未就绪:${r.live_ready_reason}` : `已切 LIVE,但未就绪:${r.live_ready_reason}`
: "已切换 SIM,配置已写入 .env", : "已切换 SIM,配置已写入 .env",
); );
window.dispatchEvent(new Event("funds-refresh"));
} catch (ex) { } catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex)); setErr(ex instanceof Error ? ex.message : String(ex));
} finally { } finally {
@@ -531,7 +539,7 @@ export default function SettingsPage() {
</option> </option>
</select> </select>
</div> </div>
{(runtime?.mode ?? mode) !== "LIVE" ? ( {mode !== "LIVE" ? (
<div className="field"> <div className="field">
<label htmlFor="equity">USDT</label> <label htmlFor="equity">USDT</label>
<input <input
@@ -559,6 +567,22 @@ export default function SettingsPage() {
onChange={(e) => setLeverage(Number(e.target.value))} onChange={(e) => setLeverage(Number(e.target.value))}
/> />
</div> </div>
<div className="field">
<label htmlFor="perpMm"></label>
<select
id="perpMm"
className="mono"
value={perpMarginMode}
onChange={(e) =>
setPerpMarginMode(
e.target.value === "isolated" ? "isolated" : "cross",
)
}
>
<option value="cross"></option>
<option value="isolated"></option>
</select>
</div>
<div className="field"> <div className="field">
<label htmlFor="perp"> ETH </label> <label htmlFor="perp"> ETH </label>
<input <input
@@ -822,19 +846,23 @@ export default function SettingsPage() {
> >
<ul className="settings-rules-list"> <ul className="settings-rules-list">
<li> <li>
{(runtime?.mode ?? mode) === "LIVE" {mode === "LIVE"
? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。" ? "当前 LIVE:仓位/选约/出场影响真下单;模拟资金已隐藏。"
: "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"} : "当前 SIM:本地撮合;可改模拟资金与交易所行情源。"}
</li> </li>
{stratSub === "position" ? ( {stratSub === "position" ? (
<> <>
<li></li> <li></li>
{(runtime?.mode ?? mode) !== "LIVE" ? ( {mode !== "LIVE" ? (
<li> <li>
SIM SIM
10000 10000
</li> </li>
) : null} ) : null}
<li>
/OKX
/cash
</li>
</> </>
) : null} ) : null}
{stratSub === "select" ? ( {stratSub === "select" ? (
@@ -1168,6 +1196,7 @@ export default function SettingsPage() {
}), }),
}); });
setFundsOk("兑换成功,顶部资金条将刷新"); setFundsOk("兑换成功,顶部资金条将刷新");
window.dispatchEvent(new Event("funds-refresh"));
} catch (e) { } catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e)); setFundsErr(e instanceof Error ? e.message : String(e));
} finally { } finally {
@@ -1257,6 +1286,7 @@ export default function SettingsPage() {
}), }),
}); });
setFundsOk("划转成功,顶部资金条将刷新"); setFundsOk("划转成功,顶部资金条将刷新");
window.dispatchEvent(new Event("funds-refresh"));
} catch (e) { } catch (e) {
setFundsErr(e instanceof Error ? e.message : String(e)); setFundsErr(e instanceof Error ? e.message : String(e));
} finally { } finally {