Add SIM/LIVE switch with API keys saved to .env and OKX live executor.

Enable settings UI for mode/keys, gate strategy start when LIVE is not ready, and stop PM2 from forcing MODE=SIM.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-26 21:08:39 +08:00
parent c2113b1a57
commit e666230d0b
19 changed files with 1367 additions and 33 deletions
+8 -3
View File
@@ -1,6 +1,7 @@
# eth_hedge_sim — 独立自动对冲模拟盘 # eth_hedge_sim — 独立自动对冲(SIM 本地撮合 / LIVE 实盘)
# 复制为 .env 后按需填写。模拟阶段禁止真实下单;真密钥不上库。 # 复制为 .env 后按需填写。真密钥不上库。
# SIM | LIVE(设置页可改;切 LIVE 须二次确认)
MODE=SIM MODE=SIM
ENV_NAME=test ENV_NAME=test
TZ=Asia/Shanghai TZ=Asia/Shanghai
@@ -17,7 +18,7 @@ AUTH_PASSWORD=admin123
AUTH_SECRET=change-me-eth-hedge-sim-secret AUTH_SECRET=change-me-eth-hedge-sim-secret
AUTH_TOKEN_TTL_SEC=604800 AUTH_TOKEN_TTL_SEC=604800
# OKXSIM 阶段公共盘口可不填 Key # OKXSIM 公共盘口可不填LIVE 下单必填
OKX_API_KEY= OKX_API_KEY=
OKX_API_SECRET= OKX_API_SECRET=
OKX_API_PASSPHRASE= OKX_API_PASSPHRASE=
@@ -26,6 +27,10 @@ OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public
# 云上一般直连留空;本机受限时再填代理 # 云上一般直连留空;本机受限时再填代理
OKX_HTTP_PROXY= OKX_HTTP_PROXY=
# 币安私有交易密钥(可落盘;实盘下单后续接入)
BINANCE_API_KEY=
BINANCE_API_SECRET=
# 币安公共行情(SIM # 币安公共行情(SIM
BINANCE_FAPI_BASE=https://fapi.binance.com BINANCE_FAPI_BASE=https://fapi.binance.com
BINANCE_EAPI_BASE=https://eapi.binance.com BINANCE_EAPI_BASE=https://eapi.binance.com
+115 -1
View File
@@ -1,11 +1,18 @@
from __future__ import annotations from __future__ import annotations
from typing import Annotated from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..config import get_settings from ..config import get_settings
from ..env_store import (
binance_keys_configured,
live_ready,
mask_secret,
okx_keys_configured,
upsert_env_keys,
)
from ..exchange.runtime import ( from ..exchange.runtime import (
load_runtime_settings, load_runtime_settings,
normalize_exchange_name, normalize_exchange_name,
@@ -195,3 +202,110 @@ async def put_strategy_settings(
) from e ) from e
return _read_settings() return _read_settings()
class RuntimeSettingsBody(BaseModel):
mode: Literal["SIM", "LIVE"] | None = None
confirm_live: bool | None = False
okx_api_key: str | None = None
okx_api_secret: str | None = None
okx_api_passphrase: str | None = None
binance_api_key: str | None = None
binance_api_secret: str | None = None
def _runtime_payload() -> dict:
s = get_settings()
rt = load_runtime_settings()
mode = "SIM" if s.is_sim else "LIVE"
ready, reason = live_ready(exchange=rt.exchange)
return {
"mode": mode,
"exchange": rt.exchange,
"okx_configured": okx_keys_configured(s),
"binance_configured": binance_keys_configured(s),
"okx_api_key_masked": mask_secret(s.okx_api_key),
"okx_api_secret_masked": mask_secret(s.okx_api_secret),
"okx_api_passphrase_masked": mask_secret(s.okx_api_passphrase),
"binance_api_key_masked": mask_secret(s.binance_api_key),
"binance_api_secret_masked": mask_secret(s.binance_api_secret),
"live_ready": bool(ready) if mode == "LIVE" else True,
"live_ready_reason": reason if mode == "LIVE" else "sim",
"sim": s.is_sim,
}
@router.get("/runtime")
async def get_runtime_settings(_user: Annotated[str, Depends(require_user)]) -> dict:
return _runtime_payload()
@router.put("/runtime")
async def put_runtime_settings(
body: RuntimeSettingsBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
db = get_db()
s = get_settings()
cur_mode = "SIM" if s.is_sim else "LIVE"
new_mode = (body.mode or cur_mode).strip().upper()
if new_mode not in ("SIM", "LIVE"):
raise HTTPException(status_code=400, detail="mode 须为 SIM 或 LIVE")
if new_mode != cur_mode and Matcher(db).has_open_position():
raise HTTPException(
status_code=409,
detail="有未平仓,无法切换 SIM/LIVE;请先平仓后再改",
)
if new_mode == "LIVE" and cur_mode != "LIVE":
if not body.confirm_live:
raise HTTPException(
status_code=400,
detail="切换到 LIVE 须二次确认(confirm_live=true",
)
updates: dict[str, str] = {}
if body.okx_api_key is not None and body.okx_api_key.strip():
updates["OKX_API_KEY"] = body.okx_api_key.strip()
if body.okx_api_secret is not None and body.okx_api_secret.strip():
updates["OKX_API_SECRET"] = body.okx_api_secret.strip()
if body.okx_api_passphrase is not None and body.okx_api_passphrase.strip():
updates["OKX_API_PASSPHRASE"] = body.okx_api_passphrase.strip()
if body.binance_api_key is not None and body.binance_api_key.strip():
updates["BINANCE_API_KEY"] = body.binance_api_key.strip()
if body.binance_api_secret is not None and body.binance_api_secret.strip():
updates["BINANCE_API_SECRET"] = body.binance_api_secret.strip()
if new_mode != cur_mode:
updates["MODE"] = new_mode
if updates:
upsert_env_keys(updates)
s2 = get_settings()
if new_mode == "LIVE":
rt = load_runtime_settings()
if rt.exchange == "okx" and not okx_keys_configured(s2):
if cur_mode == "SIM":
upsert_env_keys({"MODE": "SIM"})
raise HTTPException(
status_code=400,
detail="切到 LIVE 前请先配置完整 OKX API Key/Secret/Passphrase",
)
if rt.exchange == "binance" and not binance_keys_configured(s2):
if cur_mode == "SIM":
upsert_env_keys({"MODE": "SIM"})
raise HTTPException(
status_code=400,
detail="切到 LIVE 前请先配置完整币安 API Key/Secret",
)
try:
from ..strategy import get_engine
get_engine().refresh_executor()
except Exception:
pass
return _runtime_payload()
+10 -5
View File
@@ -6,10 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from ..config import get_settings from ..config import get_settings
from ..env_store import live_ready
from ..live import get_executor
from ..market import get_gateway from ..market import get_gateway
from ..models.db import get_db from ..models.db import get_db
from ..sim.ledger import Ledger from ..sim.ledger import Ledger
from ..sim.matcher import Matcher
from ..strategy.clock import can_open_new, window_key from ..strategy.clock import can_open_new, window_key
from ..strategy.group import next_group_id from ..strategy.group import next_group_id
from .auth import require_user from .auth import require_user
@@ -29,7 +30,7 @@ async def sim_ledger(_user: Annotated[str, Depends(require_user)]) -> dict:
@router.get("/position") @router.get("/position")
async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict: async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict:
m = Matcher() m = get_executor()
return {"position": m.current_position(), "unrealized": m.unrealized()} return {"position": m.current_position(), "unrealized": m.unrealized()}
@@ -38,7 +39,11 @@ async def sim_open_group(
_user: Annotated[str, Depends(require_user)], _user: Annotated[str, Depends(require_user)],
body: ManualOpenBody | None = None, body: ManualOpenBody | None = None,
) -> dict: ) -> dict:
if Matcher().has_open_position(): ok, reason = live_ready()
if not get_settings().is_sim and not ok:
raise HTTPException(status_code=400, detail=reason)
ex = get_executor()
if ex.has_open_position():
raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组") raise HTTPException(status_code=409, detail="有未平仓,禁止开下一组")
s = get_settings() s = get_settings()
skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends) skip_weekends = Ledger().get_setting_bool("skip_weekends", s.skip_weekends)
@@ -85,7 +90,7 @@ async def sim_open_group(
db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",)) db.fetchall("SELECT group_id FROM groups WHERE group_id LIKE ?", (f"G-{wkey}-%",))
) )
gid = next_group_id(count) gid = next_group_id(count)
r = Matcher().open_group( r = ex.open_group(
group_id=gid, group_id=gid,
bias=bias, bias=bias,
option_side=option_side, option_side=option_side,
@@ -110,7 +115,7 @@ async def sim_open_group(
@router.post("/close-group") @router.post("/close-group")
async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict: async def sim_close_group(_user: Annotated[str, Depends(require_user)]) -> dict:
r = Matcher().close_group(reason="manual") r = get_executor().close_group(reason="manual")
if not r.ok and not r.liquidity_wait: if not r.ok and not r.liquidity_wait:
raise HTTPException(status_code=400, detail=r.detail) raise HTTPException(status_code=400, detail=r.detail)
return { return {
+4
View File
@@ -33,6 +33,10 @@ class Settings(BaseSettings):
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public" okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
okx_http_proxy: str = "" okx_http_proxy: str = ""
# 币安私有交易密钥(本期仅落盘;实盘下单后续)
binance_api_key: str = ""
binance_api_secret: str = ""
# 币安公共行情(SIM 只读) # 币安公共行情(SIM 只读)
binance_fapi_base: str = "https://fapi.binance.com" binance_fapi_base: str = "https://fapi.binance.com"
binance_eapi_base: str = "https://eapi.binance.com" binance_eapi_base: str = "https://eapi.binance.com"
+67
View File
@@ -0,0 +1,67 @@
"""批量写入 .env 并刷新 Settings 缓存。"""
from __future__ import annotations
from pathlib import Path
from .config import get_settings
from .credentials import upsert_env_file
def upsert_env_keys(updates: dict[str, str]) -> Path | None:
"""写入多项;空 value 跳过。返回最后写入的 .env 路径。"""
target: Path | None = None
for key, value in updates.items():
if value is None:
continue
# 允许显式清空密钥(传空串以外的 sentinel 由调用方决定);空串表示跳过
if value == "":
continue
target = upsert_env_file(key, value)
get_settings.cache_clear()
return target
def mask_secret(raw: str | None, *, keep: int = 4) -> str | None:
"""脱敏:****末尾;过短则全部打码。"""
s = (raw or "").strip()
if not s:
return None
if len(s) <= keep:
return "*" * len(s)
return "*" * max(4, len(s) - keep) + s[-keep:]
def okx_keys_configured(s=None) -> bool:
st = s or get_settings()
return bool(
(st.okx_api_key or "").strip()
and (st.okx_api_secret or "").strip()
and (st.okx_api_passphrase or "").strip()
)
def binance_keys_configured(s=None) -> bool:
st = s or get_settings()
return bool(
(st.binance_api_key or "").strip() and (st.binance_api_secret or "").strip()
)
def live_ready(*, exchange: str | None = None) -> tuple[bool, str]:
"""LIVE 是否可下单。返回 (ok, reason)。"""
from .exchange.runtime import load_runtime_settings, normalize_exchange_name
st = get_settings()
if st.is_sim:
return True, "sim"
ex = normalize_exchange_name(exchange or load_runtime_settings().exchange)
if ex == "binance":
if not binance_keys_configured(st):
return False, "币安 API Key/Secret 未配置"
return False, "币安实盘下单尚未接入,请切回 OKX 或使用 SIM"
if ex == "okx":
if not okx_keys_configured(st):
return False, "OKX API Key/Secret/Passphrase 未配置"
return True, "ok"
return False, f"未知交易所: {ex}"
+5 -1
View File
@@ -1 +1,5 @@
# Placeholder: live OKX trade adapter (P5). Default off. """实盘执行适配层。"""
from .executor import BinanceLiveStub, OkxLiveExecutor, get_executor
__all__ = ["get_executor", "OkxLiveExecutor", "BinanceLiveStub"]
+592
View File
@@ -0,0 +1,592 @@
"""实盘执行:OKX 真下单 + 本地账本/持仓记录(与 Matcher 同结构)。"""
from __future__ import annotations
import logging
import time
from typing import Any
from ..config import get_settings
from ..env_store import live_ready
from ..exchange.runtime import load_runtime_settings
from ..models.db import get_db
from ..sim.liquidity import contracts_for_eth
from ..sim.matcher import CloseResult, Matcher, OpenResult
from ..sim.pricing import option_expiry_settle, option_intrinsic
from ..strategy.session import get_session
from .okx_trade import OkxTradeClient
logger = logging.getLogger(__name__)
class OkxLiveExecutor(Matcher):
"""开平仓走 OKX 私有接口;浮盈/残留逻辑复用 Matcher。"""
def __init__(self, db=None) -> None:
super().__init__(db)
self._trade: OkxTradeClient | None = None
def _client(self) -> OkxTradeClient:
if self._trade is None:
self._trade = OkxTradeClient()
return self._trade
def _guard_live(self) -> str | None:
ok, reason = live_ready()
if not ok:
return reason
return None
def open_group(
self,
*,
group_id: str,
bias: str,
option_side: str,
perp_side: str,
option_inst_id: str,
entry_index_px: float,
strike: float | None = None,
expiry_ymd: str | None = None,
) -> OpenResult:
err = self._guard_live()
if err:
return OpenResult(ok=False, detail=err)
s = get_settings()
pos = self.current_position()
if pos.get("status") == "open" and pos.get("group_id"):
return OpenResult(ok=False, detail="已有持仓组,请先平仓")
client = self._client()
perp_qty = self.ledger.get_setting_float("perp_qty_eth", s.perp_qty_eth)
opt_qty = self.ledger.get_setting_float("option_qty_eth", s.option_qty_eth)
ct_mult = self._ct_mult(option_inst_id)
opt_contracts = contracts_for_eth(opt_qty, ct_mult)
# 期权:买入,张数 = contracts
try:
opt_fill = client.place_market(
inst_id=option_inst_id,
side="buy",
sz=str(int(round(opt_contracts))),
td_mode="cash", # OKX 期权常见 cash;若账户不同可再扩展
)
except Exception as e:
logger.exception("live open option failed")
return OpenResult(ok=False, detail=f"实盘开期权失败: {e}")
# 永续:按仓位方向
try:
ct_val = client.get_ct_val(s.perp_inst_id, inst_type="SWAP")
perp_sz = max(1, int(round(perp_qty / ct_val)))
if perp_side == "long":
side, pos_side = "buy", "long"
else:
side, pos_side = "sell", "short"
perp_fill_live = client.place_market(
inst_id=s.perp_inst_id,
side=side,
sz=str(perp_sz),
td_mode="cross",
pos_side=pos_side,
)
except Exception as e:
logger.exception("live open perp failed; attempting option close")
try:
client.place_market(
inst_id=option_inst_id,
side="sell",
sz=str(int(round(opt_contracts))),
td_mode="cash",
reduce_only=True,
)
except Exception as e2:
logger.exception("live option rollback failed: %s", e2)
return OpenResult(
ok=False,
detail=f"永续开仓失败且期权回滚失败: {e} / {e2}",
)
return OpenResult(ok=False, detail=f"永续开仓失败,已尝试平期权: {e}")
of_px = float(opt_fill.avg_px)
pf_px = float(perp_fill_live.avg_px)
of_fee = float(opt_fill.fee)
pf_fee = float(perp_fill_live.fee)
initial_premium = of_px * opt_qty
of_notional = of_px * opt_qty
pf_notional = pf_px * perp_qty
try:
self.ledger.apply_cash(
-(of_notional + of_fee),
kind="open_option",
group_id=group_id,
note=f"LIVE open option {group_id}",
)
self.ledger.apply_cash(
-pf_fee,
kind="open_perp_fee",
group_id=group_id,
note=f"LIVE open perp {group_id}",
)
except RuntimeError as e:
return OpenResult(ok=False, detail=str(e))
now = int(time.time() * 1000)
with self.db._lock:
self.db._conn.execute(
"""INSERT INTO groups(
group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id,
strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost,
exec_mode
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"open",
bias,
option_side,
perp_side,
option_inst_id,
s.perp_inst_id,
strike,
expiry_ymd,
entry_index_px,
initial_premium,
now,
of_fee + pf_fee,
0.0,
"LIVE",
),
)
self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"option",
"open",
"long",
option_inst_id,
opt_qty,
opt_contracts,
of_px,
of_px,
of_fee,
0.0,
of_notional,
now,
"LIVE",
),
)
self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"perp",
"open",
perp_side,
s.perp_inst_id,
perp_qty,
None,
pf_px,
pf_px,
pf_fee,
0.0,
pf_notional,
now + 1,
"LIVE",
),
)
self.db._conn.execute(
"""UPDATE positions SET
group_id=?, perp_side=?, perp_qty_eth=?, perp_entry_px=?,
option_inst_id=?, option_side=?, option_qty_eth=?, option_qty_contracts=?,
option_entry_px=?, entry_index_px=?, initial_premium=?, status=?
WHERE id=1""",
(
group_id,
perp_side,
perp_qty,
pf_px,
option_inst_id,
option_side,
opt_qty,
opt_contracts,
of_px,
entry_index_px,
initial_premium,
"open",
),
)
self.db._conn.commit()
return OpenResult(
ok=True,
group_id=group_id,
detail="opened_live",
data={
"group_id": group_id,
"exec_mode": "LIVE",
"option_ord": opt_fill.ord_id,
"perp_ord": perp_fill_live.ord_id,
"initial_premium": initial_premium,
"fees": of_fee + pf_fee,
},
)
def close_group(self, *, reason: str, bypass_liquidity: bool = False) -> CloseResult:
err = self._guard_live()
if err:
return CloseResult(ok=False, detail=err)
s = get_settings()
pos = self.current_position()
if pos.get("status") != "open" or not pos.get("group_id"):
return CloseResult(ok=False, detail="无持仓可平")
group_id = str(pos["group_id"])
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
perp_side = str(pos["perp_side"])
opt_qty = float(pos["option_qty_eth"])
perp_qty = float(pos["perp_qty_eth"])
opt_contracts = float(pos["option_qty_contracts"] or 0)
client = self._client()
is_expiry = reason == "expiry"
fee_rate = self._fee_rate()
sess = get_session()
snap = sess.snapshot()
strike = self._group_strike(group_id, option_inst_id)
spot = self._close_spot_px(snap)
intrinsic = None
if strike is not None and spot is not None:
intrinsic = option_intrinsic(
option_side=option_side, strike=float(strike), spot=float(spot)
)
of_px = 0.0
of_fee = 0.0
of_slip = 0.0
of_notional = 0.0
if is_expiry:
if intrinsic is None:
return CloseResult(ok=False, detail="到期结算失败:缺行权价或标的价")
of = option_expiry_settle(
intrinsic=float(intrinsic), qty_eth=opt_qty, fee_rate=fee_rate
)
of_px, of_fee, of_slip, of_notional = of.fill_px, of.fee, of.slip, of.notional
else:
try:
opt_live = client.place_market(
inst_id=option_inst_id,
side="sell",
sz=str(int(round(opt_contracts))),
td_mode="cash",
reduce_only=True,
)
of_px = float(opt_live.avg_px)
of_fee = float(opt_live.fee)
of_notional = of_px * opt_qty
except Exception as e:
if not bypass_liquidity:
return CloseResult(
ok=False,
detail=f"实盘平期权失败: {e}",
liquidity_wait=True,
)
return CloseResult(ok=False, detail=f"实盘平期权失败: {e}")
try:
ct_val = client.get_ct_val(s.perp_inst_id, inst_type="SWAP")
perp_sz = max(1, int(round(perp_qty / ct_val)))
if perp_side == "long":
side, pos_side = "sell", "long"
else:
side, pos_side = "buy", "short"
perp_live = client.place_market(
inst_id=s.perp_inst_id,
side=side,
sz=str(perp_sz),
td_mode="cross",
pos_side=pos_side,
reduce_only=True,
)
pf_px = float(perp_live.avg_px)
pf_fee = float(perp_live.fee)
except Exception as e:
return CloseResult(ok=False, detail=f"期权已平但永续平仓失败: {e}")
opt_entry = float(pos["option_entry_px"])
perp_entry = float(pos["perp_entry_px"])
opt_pnl = (of_px - opt_entry) * opt_qty
if perp_side == "long":
perp_pnl = (pf_px - perp_entry) * perp_qty
else:
perp_pnl = (perp_entry - pf_px) * perp_qty
self.ledger.apply_cash(
of_notional - of_fee,
kind="close_option",
group_id=group_id,
note=f"LIVE close option {reason}",
)
self.ledger.apply_cash(
perp_pnl - pf_fee,
kind="close_perp",
group_id=group_id,
note=f"LIVE close perp {reason}",
)
now = int(time.time() * 1000)
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
fees = float((g["fees"] if g else 0) or 0) + of_fee + pf_fee
slip = float((g["slip_cost"] if g else 0) or 0) + of_slip
from ..sim.pnl import summarize_fills_pnl
with self.db._lock:
self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"option",
"close",
"flat",
option_inst_id,
opt_qty,
opt_contracts,
of_px,
of_px,
of_fee,
of_slip,
of_notional,
now,
"LIVE",
),
)
self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"perp",
"close",
"flat",
s.perp_inst_id,
perp_qty,
None,
pf_px,
pf_px,
pf_fee,
0.0,
pf_px * perp_qty,
now + 1,
"LIVE",
),
)
fills = self.db._conn.execute(
"SELECT * FROM fills WHERE group_id=? ORDER BY id ASC", (group_id,)
).fetchall()
summary = summarize_fills_pnl(list(fills))
net = summary.get("net_pnl")
if net is None:
net = opt_pnl + perp_pnl - of_fee - pf_fee
self.db._conn.execute(
"""UPDATE groups SET status=?, close_at_ms=?, close_reason=?, realized_pnl=?,
fees=?, slip_cost=? WHERE group_id=?""",
("closed", now, reason, float(net), fees, slip, group_id),
)
self.db._conn.execute(
"""UPDATE positions SET
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
WHERE id=1"""
)
self.db._conn.commit()
return CloseResult(
ok=True,
detail="closed_live",
data={"group_id": group_id, "reason": reason, "net_pnl": net, "exec_mode": "LIVE"},
)
def close_perp_abandon_option(self, *, reason: str = "target_perp_only") -> CloseResult:
err = self._guard_live()
if err:
return CloseResult(ok=False, detail=err)
# 先校验远虚,再实盘只平永续,其余写入复用父类逻辑的简化版:
if not self.option_is_deep_otm():
return CloseResult(ok=False, detail="期权非远虚,应走双腿全平")
s = get_settings()
pos = self.current_position()
if pos.get("status") != "open" or not pos.get("group_id"):
return CloseResult(ok=False, detail="无持仓可平")
group_id = str(pos["group_id"])
perp_side = str(pos["perp_side"])
perp_qty = float(pos["perp_qty_eth"])
perp_entry = float(pos["perp_entry_px"])
client = self._client()
try:
ct_val = client.get_ct_val(s.perp_inst_id, inst_type="SWAP")
perp_sz = max(1, int(round(perp_qty / ct_val)))
if perp_side == "long":
side, pos_side = "sell", "long"
else:
side, pos_side = "buy", "short"
perp_live = client.place_market(
inst_id=s.perp_inst_id,
side=side,
sz=str(perp_sz),
td_mode="cross",
pos_side=pos_side,
reduce_only=True,
)
except Exception as e:
return CloseResult(ok=False, detail=f"实盘平永续失败: {e}")
pf_px = float(perp_live.avg_px)
pf_fee = float(perp_live.fee)
if perp_side == "long":
perp_pnl = (pf_px - perp_entry) * perp_qty
else:
perp_pnl = (perp_entry - pf_px) * perp_qty
self.ledger.apply_cash(
perp_pnl - pf_fee,
kind="close_perp",
group_id=group_id,
note=f"LIVE close perp abandon option {reason}",
)
# 复用父类归档写入:临时改 fill 路径太重,直接调用父类会再平一次本地假价。
# 因此把实盘价写入后走父类结构——这里内联父类 abandon 的 DB 段。
option_inst_id = str(pos["option_inst_id"])
option_side = str(pos["option_side"])
strike = self._group_strike(group_id, option_inst_id)
g = self.db.fetchone("SELECT * FROM groups WHERE group_id=?", (group_id,))
expiry_ymd = str(g["expiry_ymd"]) if g and g["expiry_ymd"] else None
expiry_ms = None
if expiry_ymd:
try:
from ..exchange.expiry import expiry_ms_from_ymd
expiry_ms = int(expiry_ms_from_ymd(expiry_ymd))
except Exception:
expiry_ms = None
now = int(time.time() * 1000)
open_fees = float((g["fees"] if g else 0) or 0)
fees = open_fees + pf_fee
slip = float((g["slip_cost"] if g else 0) or 0)
interim_net = perp_pnl - open_fees - pf_fee
spot = self._close_spot_px(get_session().snapshot())
with self.db._lock:
self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
"perp",
"close",
"flat",
s.perp_inst_id,
perp_qty,
None,
pf_px,
pf_px,
pf_fee,
0.0,
pf_px * perp_qty,
now,
"LIVE",
),
)
self.db._conn.execute(
"""INSERT INTO residual_options(
group_id, option_inst_id, option_side, option_qty_eth, option_qty_contracts,
option_entry_px, strike, expiry_ymd, expiry_ms, entry_index_px,
initial_premium, status, created_at_ms, note
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
group_id,
option_inst_id,
option_side,
float(pos["option_qty_eth"]),
float(pos["option_qty_contracts"] or 0),
float(pos["option_entry_px"]),
float(strike) if strike is not None else None,
expiry_ymd,
expiry_ms,
float(pos["entry_index_px"] or 0),
float(pos["initial_premium"] or 0),
"pending",
now,
f"LIVE abandoned after {reason}; spot={spot}",
),
)
self.db._conn.execute(
"""UPDATE groups SET status=?, close_reason=?, realized_pnl=?,
fees=?, slip_cost=?, note=?, exec_mode=? WHERE group_id=?""",
(
"option_residual",
reason,
interim_net,
fees,
slip,
"LIVE perp_closed; option residual until expiry",
"LIVE",
group_id,
),
)
self.db._conn.execute(
"""UPDATE positions SET
group_id=NULL, perp_side=NULL, perp_qty_eth=0, perp_entry_px=NULL,
option_inst_id=NULL, option_side=NULL, option_qty_eth=0, option_qty_contracts=0,
option_entry_px=NULL, entry_index_px=NULL, initial_premium=0, status='flat'
WHERE id=1"""
)
self.db._conn.commit()
return CloseResult(
ok=True,
detail="perp_closed_option_residual_live",
data={"group_id": group_id, "reason": reason, "mode": "target_perp_only", "exec_mode": "LIVE"},
)
class BinanceLiveStub(Matcher):
def open_group(self, **kwargs: Any) -> OpenResult: # type: ignore[override]
return OpenResult(ok=False, detail="币安实盘下单尚未接入,请使用 OKX 或切回 SIM")
def close_group(self, **kwargs: Any) -> CloseResult: # type: ignore[override]
return CloseResult(ok=False, detail="币安实盘下单尚未接入,请使用 OKX 或切回 SIM")
def close_perp_abandon_option(self, **kwargs: Any) -> CloseResult: # type: ignore[override]
return CloseResult(ok=False, detail="币安实盘下单尚未接入,请使用 OKX 或切回 SIM")
def get_executor(db=None) -> Matcher:
"""按 MODE + 交易所返回执行器。"""
from ..models.db import get_db
database = db or get_db()
s = get_settings()
if s.is_sim:
return Matcher(database)
ex = load_runtime_settings().exchange
if ex == "binance":
return BinanceLiveStub(database)
return OkxLiveExecutor(database)
+163
View File
@@ -0,0 +1,163 @@
"""OKX V5 私有交易 REST(下单)。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from dataclasses import dataclass
from typing import Any
import httpx
from ..config import Settings, get_settings
from ..exchange.okx.parse import safe_float
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class LiveFill:
inst_id: str
side: str
avg_px: float
sz: float # 张或币,取决于合约
fee: float
ord_id: str
raw: dict[str, Any]
class OkxTradeClient:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
proxy = (self.settings.okx_http_proxy or "").strip() or None
self._client = httpx.Client(
base_url=self.settings.okx_rest_base.rstrip("/"),
timeout=20.0,
proxy=proxy,
headers={"Accept": "application/json", "User-Agent": "eth-hedge-live/0.1"},
)
self._ct_val_cache: dict[str, float] = {}
def close(self) -> None:
self._client.close()
def _ts(self) -> str:
# OKX: ISO8601 with milliseconds
return (
time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime())
+ f".{int(time.time() * 1000) % 1000:03d}Z"
)
def _sign(self, ts: str, method: str, path: str, body: str) -> str:
secret = (self.settings.okx_api_secret or "").encode("utf-8")
msg = f"{ts}{method.upper()}{path}{body}".encode("utf-8")
dig = hmac.new(secret, msg, hashlib.sha256).digest()
return base64.b64encode(dig).decode("utf-8")
def _headers(self, ts: str, sign: str) -> dict[str, str]:
return {
"OK-ACCESS-KEY": self.settings.okx_api_key or "",
"OK-ACCESS-SIGN": sign,
"OK-ACCESS-TIMESTAMP": ts,
"OK-ACCESS-PASSPHRASE": self.settings.okx_api_passphrase or "",
"Content-Type": "application/json",
}
def _request(
self, method: str, path: str, body: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
payload = "" if body is None else json.dumps(body, separators=(",", ":"))
ts = self._ts()
sign = self._sign(ts, method, path, payload)
headers = self._headers(ts, sign)
if method.upper() == "GET":
r = self._client.get(path, headers=headers)
else:
r = self._client.request(method.upper(), path, content=payload, headers=headers)
r.raise_for_status()
data = r.json()
if str(data.get("code")) != "0":
raise RuntimeError(
f"OKX trade error code={data.get('code')} msg={data.get('msg')} data={data.get('data')}"
)
rows = data.get("data") or []
return [x for x in rows if isinstance(x, dict)]
def get_ct_val(self, inst_id: str, *, inst_type: str) -> float:
if inst_id in self._ct_val_cache:
return self._ct_val_cache[inst_id]
r = self._client.get(
"/api/v5/public/instruments",
params={"instType": inst_type, "instId": inst_id},
)
r.raise_for_status()
body = r.json()
rows = body.get("data") or []
for row in rows:
if str(row.get("instId")) == inst_id:
v = safe_float(row.get("ctVal")) or safe_float(row.get("ctMult"))
if v and v > 0:
self._ct_val_cache[inst_id] = float(v)
return float(v)
default = 0.01
self._ct_val_cache[inst_id] = default
return default
def place_market(
self,
*,
inst_id: str,
side: str, # buy|sell
sz: str,
td_mode: str,
pos_side: str | None = None,
reduce_only: bool = False,
) -> LiveFill:
body: dict[str, Any] = {
"instId": inst_id,
"tdMode": td_mode,
"side": side,
"ordType": "market",
"sz": str(sz),
}
if pos_side:
body["posSide"] = pos_side
if reduce_only:
body["reduceOnly"] = True
rows = self._request("POST", "/api/v5/trade/order", body)
if not rows:
raise RuntimeError("OKX 下单无返回")
ord_id = str(rows[0].get("ordId") or "")
# 查单取均价
fill = self._wait_fill(inst_id, ord_id)
return fill
def _wait_fill(self, inst_id: str, ord_id: str, *, tries: int = 8) -> LiveFill:
path = f"/api/v5/trade/order?instId={inst_id}&ordId={ord_id}"
last: dict[str, Any] = {}
for _ in range(tries):
rows = self._request("GET", path)
if rows:
last = rows[0]
state = str(last.get("state") or "")
avg = safe_float(last.get("avgPx"))
if state in ("filled", "partially_filled") and avg and avg > 0:
fee = abs(safe_float(last.get("fee")) or 0.0)
sz = safe_float(last.get("accFillSz")) or safe_float(last.get("sz")) or 0.0
return LiveFill(
inst_id=inst_id,
side=str(last.get("side") or ""),
avg_px=float(avg),
sz=float(sz),
fee=float(fee),
ord_id=ord_id,
raw=last,
)
if state in ("canceled", "failed"):
raise RuntimeError(f"OKX 订单失败 state={state} {last}")
time.sleep(0.25)
raise RuntimeError(f"OKX 订单未成交 ordId={ord_id} last={last}")
+2 -1
View File
@@ -45,8 +45,9 @@ async def lifespan(app: FastAPI):
try: try:
await session.start() await session.start()
logger.info( logger.info(
"exchange=%s strategy session started (SIM)", "exchange=%s strategy session started mode=%s",
settings.exchange, settings.exchange,
"SIM" if get_settings().is_sim else "LIVE",
) )
except Exception: except Exception:
logger.exception("strategy session failed to start") logger.exception("strategy session failed to start")
+21 -1
View File
@@ -40,7 +40,8 @@ CREATE TABLE IF NOT EXISTS groups (
realized_pnl REAL DEFAULT 0, realized_pnl REAL DEFAULT 0,
fees REAL DEFAULT 0, fees REAL DEFAULT 0,
slip_cost REAL DEFAULT 0, slip_cost REAL DEFAULT 0,
note TEXT note TEXT,
exec_mode TEXT
); );
CREATE TABLE IF NOT EXISTS fills ( CREATE TABLE IF NOT EXISTS fills (
@@ -58,6 +59,7 @@ CREATE TABLE IF NOT EXISTS fills (
slip REAL NOT NULL, slip REAL NOT NULL,
notional REAL NOT NULL, notional REAL NOT NULL,
ts_ms INTEGER NOT NULL, ts_ms INTEGER NOT NULL,
exec_mode TEXT,
FOREIGN KEY(group_id) REFERENCES groups(group_id) FOREIGN KEY(group_id) REFERENCES groups(group_id)
); );
@@ -140,8 +142,26 @@ class Database:
self._conn.execute("PRAGMA journal_mode=WAL;") self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn.executescript(_SCHEMA) self._conn.executescript(_SCHEMA)
self._conn.commit() self._conn.commit()
self._migrate_columns()
self._ensure_seed() self._ensure_seed()
def _migrate_columns(self) -> None:
"""幂等补列:exec_mode。"""
with self._lock:
for table, col, decl in (
("groups", "exec_mode", "TEXT"),
("fills", "exec_mode", "TEXT"),
):
cols = {
str(r[1])
for r in self._conn.execute(f"PRAGMA table_info({table})").fetchall()
}
if col not in cols:
self._conn.execute(
f"ALTER TABLE {table} ADD COLUMN {col} {decl}"
)
self._conn.commit()
def close(self) -> None: def close(self) -> None:
with self._lock: with self._lock:
self._conn.close() self._conn.close()
+10 -6
View File
@@ -231,8 +231,9 @@ class Matcher:
self.db._conn.execute( self.db._conn.execute(
"""INSERT INTO groups( """INSERT INTO groups(
group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id, group_id, status, bias, option_side, perp_side, option_inst_id, perp_inst_id,
strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", exec_mode
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
( (
group_id, group_id,
"open", "open",
@@ -248,13 +249,14 @@ class Matcher:
now, now,
pf.fee + of.fee, pf.fee + of.fee,
pf.slip + of.slip, pf.slip + of.slip,
"SIM",
), ),
) )
# 成交顺序:期权先、永续后(时间戳差 1ms 便于审计) # 成交顺序:期权先、永续后(时间戳差 1ms 便于审计)
self.db._conn.execute( self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms) base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
( (
group_id, group_id,
"option", "option",
@@ -269,12 +271,13 @@ class Matcher:
of.slip, of.slip,
of.notional, of.notional,
now, now,
"SIM",
), ),
) )
self.db._conn.execute( self.db._conn.execute(
"""INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts, """INSERT INTO fills(group_id, leg, action, side, inst_id, qty_eth, qty_contracts,
base_px, fill_px, fee, slip, notional, ts_ms) base_px, fill_px, fee, slip, notional, ts_ms, exec_mode)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
( (
group_id, group_id,
"perp", "perp",
@@ -289,6 +292,7 @@ class Matcher:
pf.slip, pf.slip,
pf.notional, pf.notional,
now + 1, now + 1,
"SIM",
), ),
) )
self.db._conn.execute( self.db._conn.execute(
+18 -2
View File
@@ -11,7 +11,8 @@ from ..config import get_settings
from .session import get_session from .session import get_session
from ..models.db import get_db from ..models.db import get_db
from ..sim.ledger import Ledger from ..sim.ledger import Ledger
from ..sim.matcher import Matcher from ..live import get_executor
from ..env_store import live_ready
from .clock import can_open_new, window_key from .clock import can_open_new, window_key
from .exits import check_expiry_close, check_exits, resolve_exit_target from .exits import check_expiry_close, check_exits, resolve_exit_target
from .group import next_group_id from .group import next_group_id
@@ -22,11 +23,15 @@ logger = logging.getLogger(__name__)
class StrategyEngine: class StrategyEngine:
def __init__(self) -> None: def __init__(self) -> None:
self.db = get_db() self.db = get_db()
self.matcher = Matcher(self.db)
self.ledger = Ledger(self.db) self.ledger = Ledger(self.db)
self.matcher = get_executor(self.db)
self._task: asyncio.Task[None] | None = None self._task: asyncio.Task[None] | None = None
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
def refresh_executor(self) -> None:
"""MODE 变更后刷新执行器。"""
self.matcher = get_executor(self.db)
def state(self) -> dict[str, Any]: def state(self) -> dict[str, Any]:
row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1") row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1")
assert row is not None assert row is not None
@@ -90,6 +95,10 @@ class StrategyEngine:
"position": upl, "position": upl,
"residuals": self.matcher.list_residual_options(pending_only=True), "residuals": self.matcher.list_residual_options(pending_only=True),
"ledger": self.ledger.snapshot(), "ledger": self.ledger.snapshot(),
"mode": "SIM" if s.is_sim else "LIVE",
"sim": s.is_sim,
"live_ready": (live_ready()[0] if not s.is_sim else True),
"live_ready_reason": (live_ready()[1] if not s.is_sim else "sim"),
} }
def _set_state(self, **kwargs: Any) -> None: def _set_state(self, **kwargs: Any) -> None:
@@ -108,6 +117,13 @@ class StrategyEngine:
return self.state() return self.state()
async def start(self) -> dict[str, Any]: async def start(self) -> dict[str, Any]:
self.refresh_executor()
s = get_settings()
if not s.is_sim:
ok, reason = live_ready()
if not ok:
self._set_state(running=0, phase="paused", last_error=reason)
return self.state()
self._set_state(running=1, last_error=None, phase="idle") self._set_state(running=1, last_error=None, phase="idle")
self.ensure_loop() self.ensure_loop()
return self.state() return self.state()
+79
View File
@@ -0,0 +1,79 @@
"""SIM/LIVE 运行时闸门与脱敏。"""
from app.env_store import live_ready, mask_secret, okx_keys_configured
def test_mask_secret() -> None:
assert mask_secret(None) is None
assert mask_secret("") is None
assert mask_secret("abcd") == "****"
m = mask_secret("abcdefghij")
assert m is not None
assert m.endswith("ghij")
assert m.startswith("*")
def test_live_ready_sim(monkeypatch) -> None:
import app.env_store as es
class S:
mode = "SIM"
is_sim = True
okx_api_key = ""
okx_api_secret = ""
okx_api_passphrase = ""
binance_api_key = ""
binance_api_secret = ""
monkeypatch.setattr(es, "get_settings", lambda: S())
ok, reason = live_ready(exchange="okx")
assert ok is True
assert reason == "sim"
def test_live_ready_okx_missing_keys(monkeypatch) -> None:
import app.env_store as es
class S:
mode = "LIVE"
is_sim = False
okx_api_key = ""
okx_api_secret = ""
okx_api_passphrase = ""
binance_api_key = ""
binance_api_secret = ""
monkeypatch.setattr(es, "get_settings", lambda: S())
ok, reason = live_ready(exchange="okx")
assert ok is False
assert "OKX" in reason
def test_live_ready_binance_stub(monkeypatch) -> None:
import app.env_store as es
class S:
mode = "LIVE"
is_sim = False
okx_api_key = "k"
okx_api_secret = "s"
okx_api_passphrase = "p"
binance_api_key = "bk"
binance_api_secret = "bs"
monkeypatch.setattr(es, "get_settings", lambda: S())
ok, reason = live_ready(exchange="binance")
assert ok is False
assert "尚未接入" in reason
def test_okx_keys_configured(monkeypatch) -> None:
import app.env_store as es
class S:
okx_api_key = "k"
okx_api_secret = "s"
okx_api_passphrase = "p"
monkeypatch.setattr(es, "get_settings", lambda: S())
assert okx_keys_configured() is True
+1 -1
View File
@@ -7,7 +7,7 @@ module.exports = {
args: 'app.main:app --host 0.0.0.0 --port 5155', args: 'app.main:app --host 0.0.0.0 --port 5155',
interpreter: 'none', interpreter: 'none',
env: { env: {
MODE: 'SIM', // MODE 以仓库根 .env 为准(设置页可切 SIM/LIVE),勿在此硬编码覆盖
ENV_NAME: 'test', ENV_NAME: 'test',
TZ: 'Asia/Shanghai', TZ: 'Asia/Shanghai',
}, },
+10 -6
View File
@@ -279,12 +279,15 @@
## 11. 上线检查清单 ## 11. 上线检查清单
1. SIM 同规则已跑通(含目标 A/B、到期、残留)。 1. SIM 同规则已跑通(含目标 A/B、到期、残留)。
2. 实盘授权档位 + 二次确认(见商业化方案) 2. 设置页切 **LIVE**,二次确认输入 `LIVE`OKX Key/Secret/Passphrase 写入 `.env`
3. 选定交易所、合约族、API 与 IP 白名单 3. 实盘授权档位 + 二次确认(见商业化方案)
4. 确定 k;写入永续/期权名义与净利目标(15×k) 4. 选定交易所、合约族、API 与 IP 白名单
5. 保证金与权利金缓冲到位;期权逐仓 5. 确定 k;写入永续/期权名义与净利目标(15×k)
6. 监控:活跃仓、残留列表、持仓盘口钉死、紧急全平可用 6. 保证金与权利金缓冲到位;期权逐仓
7. 先 k=0.1 试跑至少覆盖:开仓、目标平、到期或残留结算各一类 7. 监控:活跃仓、残留列表、持仓盘口钉死、紧急全平可用
8. 先 k=0.1 试跑至少覆盖:开仓、目标平、到期或残留结算各一类。
> 软件侧:`MODE=LIVE` + OKX 密钥齐全后,策略开平仓走 OKX 私有下单;币安真下单尚未接入。
--- ---
@@ -293,3 +296,4 @@
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 2026-07-26 | 初稿:由 SIM 策略说明改写实盘;标准仓 1+2 ETH;倍数 k 缩放与目标同比 | | 2026-07-26 | 初稿:由 SIM 策略说明改写实盘;标准仓 1+2 ETH;倍数 k 缩放与目标同比 |
| 2026-07-26 | 对齐软件:设置页 SIM/LIVE + API→.envOKX 真下单 |
+3
View File
@@ -4,6 +4,8 @@
> 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md)、[实盘策略说明](./实盘策略说明.md) > 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md)、[实盘策略说明](./实盘策略说明.md)
> 更新:2026-07-26 > 更新:2026-07-26
**运行模式**:设置页「运行模式」可切 **SIM / LIVE**;交易所 API 录入后写入服务器 `.env`(不回传明文)。LIVE 须二次确认输入 `LIVE`;当前 **OKX** 可真下单,币安仅存密钥。有持仓时不可切模式。
--- ---
## 1. 策略一句话 ## 1. 策略一句话
@@ -334,3 +336,4 @@
| 2026-07-26 | 到期按内在价值结算(对齐实盘);紧急平仓仍用 max(买一,标记,内在价值) | | 2026-07-26 | 到期按内在价值结算(对齐实盘);紧急平仓仍用 max(买一,标记,内在价值) |
| 2026-07-26 | 明确两套目标平仓:双腿全平 / 远虚只平永续+期权归档到期;到期为未达标路径 | | 2026-07-26 | 明确两套目标平仓:双腿全平 / 远虚只平永续+期权归档到期;到期为未达标路径 |
| 2026-07-26 | 补充 §4.6:有仓钉持仓监控、空仓/仅残留跟新 ATM、残留列表与到期内在价值结算展示分工 | | 2026-07-26 | 补充 §4.6:有仓钉持仓监控、空仓/仅残留跟新 ATM、残留列表与到期内在价值结算展示分工 |
| 2026-07-26 | 设置页 SIM/LIVE 切换;API 密钥落 `.env`;OKX LIVE 开平仓;币安密钥可存 |
+19
View File
@@ -175,6 +175,25 @@ export type PlanState = {
status: string; status: string;
}[]; }[];
ledger: { equity: number; available: number; reserved: number }; ledger: { equity: number; available: number; reserved: number };
mode?: "SIM" | "LIVE";
sim?: boolean;
live_ready?: boolean;
live_ready_reason?: string;
};
export type RuntimeSettings = {
mode: "SIM" | "LIVE";
exchange: string;
okx_configured: boolean;
binance_configured: boolean;
okx_api_key_masked: string | null;
okx_api_secret_masked: string | null;
okx_api_passphrase_masked: string | null;
binance_api_key_masked: string | null;
binance_api_secret_masked: string | null;
live_ready: boolean;
live_ready_reason: string;
sim: boolean;
}; };
export type StrategySettings = { export type StrategySettings = {
+17 -3
View File
@@ -132,10 +132,13 @@ export default function PlanPage() {
<div> <div>
<h2 style={{ marginTop: 0 }}></h2> <h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}> <p style={{ color: "var(--muted)", marginTop: -8 }}>
SIM · {" "} {plan?.mode === "LIVE" ? "LIVE 实盘下单" : "SIM 本地撮合"} · {" "}
{(snap?.exchange || "okx").toUpperCase()} {(snap?.exchange || "okx").toUpperCase()}
{snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} · {snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} ·
(/) · (/) ·
{plan?.mode === "LIVE" && plan.live_ready === false
? ` · 未就绪: ${plan.live_ready_reason || "请配置 API"}`
: ""}
</p> </p>
{err ? <div className="err">{err}</div> : null} {err ? <div className="err">{err}</div> : null}
@@ -143,7 +146,16 @@ export default function PlanPage() {
<button <button
className="btn" className="btn"
type="button" type="button"
disabled={!!busy || plan?.running} disabled={
!!busy ||
plan?.running ||
(plan?.mode === "LIVE" && plan.live_ready === false)
}
title={
plan?.mode === "LIVE" && plan.live_ready === false
? plan.live_ready_reason || "LIVE 未就绪"
: undefined
}
onClick={() => act("/api/plan/start", "start")} onClick={() => act("/api/plan/start", "start")}
> >
@@ -159,7 +171,9 @@ export default function PlanPage() {
<button <button
className="btn ghost" className="btn ghost"
type="button" type="button"
disabled={!!busy} disabled={
!!busy || (plan?.mode === "LIVE" && plan.live_ready === false)
}
onClick={() => act("/api/sim/open-group", "open")} onClick={() => act("/api/sim/open-group", "open")}
> >
+223 -3
View File
@@ -5,9 +5,10 @@ import {
setSession, setSession,
apiFetch, apiFetch,
StrategySettings, StrategySettings,
RuntimeSettings,
} from "../api/client"; } from "../api/client";
type Tab = "strategy" | "account"; type Tab = "strategy" | "runtime" | "account";
export default function SettingsPage() { export default function SettingsPage() {
const [tab, setTab] = useState<Tab>("strategy"); const [tab, setTab] = useState<Tab>("strategy");
@@ -40,6 +41,24 @@ export default function SettingsPage() {
const [exchange, setExchange] = useState<"okx" | "binance">("okx"); const [exchange, setExchange] = useState<"okx" | "binance">("okx");
const [stratOk, setStratOk] = useState(""); const [stratOk, setStratOk] = useState("");
const [runtime, setRuntime] = useState<RuntimeSettings | null>(null);
const [mode, setMode] = useState<"SIM" | "LIVE">("SIM");
const [okxKey, setOkxKey] = useState("");
const [okxSecret, setOkxSecret] = useState("");
const [okxPass, setOkxPass] = useState("");
const [bnKey, setBnKey] = useState("");
const [bnSecret, setBnSecret] = useState("");
const [runtimeOk, setRuntimeOk] = useState("");
function loadRuntime() {
apiFetch<RuntimeSettings>("/api/settings/runtime")
.then((r) => {
setRuntime(r);
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
})
.catch(() => undefined);
}
useEffect(() => { useEffect(() => {
apiFetch<StrategySettings>("/api/settings/strategy") apiFetch<StrategySettings>("/api/settings/strategy")
.then((s) => { .then((s) => {
@@ -61,6 +80,7 @@ export default function SettingsPage() {
setExchange(s.exchange === "binance" ? "binance" : "okx"); setExchange(s.exchange === "binance" ? "binance" : "okx");
}) })
.catch(() => undefined); .catch(() => undefined);
loadRuntime();
}, []); }, []);
async function onSaveCreds(e: FormEvent) { async function onSaveCreds(e: FormEvent) {
@@ -128,6 +148,54 @@ export default function SettingsPage() {
} }
} }
async function onSaveRuntime(e: FormEvent) {
e.preventDefault();
setErr("");
setRuntimeOk("");
const goingLive = mode === "LIVE" && runtime?.mode !== "LIVE";
if (goingLive) {
const typed = window.prompt('切换到 LIVE 实盘:请输入 LIVE 确认(将真实下单)');
if (typed !== "LIVE") {
setErr("已取消:须输入 LIVE 才能切换到实盘");
return;
}
}
setLoading(true);
try {
const body: Record<string, unknown> = {
mode,
confirm_live: goingLive,
};
if (okxKey.trim()) body.okx_api_key = okxKey.trim();
if (okxSecret.trim()) body.okx_api_secret = okxSecret.trim();
if (okxPass.trim()) body.okx_api_passphrase = okxPass.trim();
if (bnKey.trim()) body.binance_api_key = bnKey.trim();
if (bnSecret.trim()) body.binance_api_secret = bnSecret.trim();
const r = await apiFetch<RuntimeSettings>("/api/settings/runtime", {
method: "PUT",
body: JSON.stringify(body),
});
setRuntime(r);
setMode(r.mode === "LIVE" ? "LIVE" : "SIM");
setOkxKey("");
setOkxSecret("");
setOkxPass("");
setBnKey("");
setBnSecret("");
setRuntimeOk(
r.mode === "LIVE"
? r.live_ready
? "已切换 LIVE,密钥已写入 .env"
: `已切 LIVE,但未就绪:${r.live_ready_reason}`
: "已切换 SIM,配置已写入 .env",
);
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
return ( return (
<div className="settings-page"> <div className="settings-page">
<h2 style={{ marginTop: 0 }}></h2> <h2 style={{ marginTop: 0 }}></h2>
@@ -139,6 +207,16 @@ export default function SettingsPage() {
> >
</button> </button>
<button
type="button"
className={tab === "runtime" ? "tab active" : "tab"}
onClick={() => {
setTab("runtime");
loadRuntime();
}}
>
</button>
<button <button
type="button" type="button"
className={tab === "account" ? "tab active" : "tab"} className={tab === "account" ? "tab active" : "tab"}
@@ -401,7 +479,149 @@ export default function SettingsPage() {
</div> </div>
</form> </form>
</div> </div>
) : ( ) : null}
{tab === "runtime" ? (
<div className="card settings-card">
<p className="settings-lead">
SIM = LIVE = OKX {" "}
<span className="mono">.env</span>
</p>
{runtimeOk ? <div className="settings-ok">{runtimeOk}</div> : null}
{err && tab === "runtime" ? <div className="err">{err}</div> : null}
<form onSubmit={onSaveRuntime}>
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="mode"></label>
<select
id="mode"
className="mono"
value={mode}
onChange={(e) =>
setMode(e.target.value === "LIVE" ? "LIVE" : "SIM")
}
>
<option value="SIM">SIM </option>
<option value="LIVE">LIVE </option>
</select>
<p className="settings-hint">
{runtime?.mode || "—"} · {runtime?.exchange || "—"} ·{" "}
{runtime?.mode === "LIVE"
? runtime.live_ready
? "LIVE 就绪"
: `未就绪(${runtime.live_ready_reason})`
: "SIM"}
</p>
</div>
</div>
</section>
<section className="settings-section">
<h3>OKX API</h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="okxKey">API Key</label>
<input
id="okxKey"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.okx_api_key_masked
? `已配置 ${runtime.okx_api_key_masked}`
: "未配置"
}
value={okxKey}
onChange={(e) => setOkxKey(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="okxSecret">Secret</label>
<input
id="okxSecret"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.okx_api_secret_masked
? `已配置 ${runtime.okx_api_secret_masked}`
: "未配置"
}
value={okxSecret}
onChange={(e) => setOkxSecret(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="okxPass">Passphrase</label>
<input
id="okxPass"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.okx_api_passphrase_masked
? `已配置 ${runtime.okx_api_passphrase_masked}`
: "未配置"
}
value={okxPass}
onChange={(e) => setOkxPass(e.target.value)}
/>
</div>
</div>
</section>
<section className="settings-section">
<h3> API</h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="bnKey">API Key</label>
<input
id="bnKey"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.binance_api_key_masked
? `已配置 ${runtime.binance_api_key_masked}`
: "未配置"
}
value={bnKey}
onChange={(e) => setBnKey(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="bnSecret">Secret</label>
<input
id="bnSecret"
className="mono"
type="password"
autoComplete="off"
placeholder={
runtime?.binance_api_secret_masked
? `已配置 ${runtime.binance_api_secret_masked}`
: "未配置"
}
value={bnSecret}
onChange={(e) => setBnSecret(e.target.value)}
/>
</div>
<p className="settings-hint">=</p>
</div>
</section>
<div className="settings-actions">
<button className="btn" type="submit" disabled={loading}>
{loading ? "保存中…" : "保存模式与密钥"}
</button>
</div>
</form>
</div>
) : null}
{tab === "account" ? (
<div className="card settings-card settings-card-narrow"> <div className="card settings-card settings-card-narrow">
{err ? <div className="err">{err}</div> : null} {err ? <div className="err">{err}</div> : null}
{ok ? <div className="settings-ok">{ok}</div> : null} {ok ? <div className="settings-ok">{ok}</div> : null}
@@ -454,7 +674,7 @@ export default function SettingsPage() {
</div> </div>
</form> </form>
</div> </div>
)} ) : null}
</div> </div>
); );
} }