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:
+8
-3
@@ -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
|
||||||
|
|
||||||
# OKX(SIM 阶段公共盘口可不填 Key)
|
# OKX(SIM 公共盘口可不填;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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -1 +1,5 @@
|
|||||||
# Placeholder: live OKX trade adapter (P5). Default off.
|
"""实盘执行适配层。"""
|
||||||
|
|
||||||
|
from .executor import BinanceLiveStub, OkxLiveExecutor, get_executor
|
||||||
|
|
||||||
|
__all__ = ["get_executor", "OkxLiveExecutor", "BinanceLiveStub"]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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→.env;OKX 真下单 |
|
||||||
|
|||||||
@@ -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 开平仓;币安密钥可存 |
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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")}
|
||||||
>
|
>
|
||||||
手动开一组
|
手动开一组
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user