diff --git a/.env.example b/.env.example index 075038f..be68644 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ -# eth_hedge_sim — 独立自动对冲模拟盘 -# 复制为 .env 后按需填写。模拟阶段禁止真实下单;真密钥不上库。 +# eth_hedge_sim — 独立自动对冲(SIM 本地撮合 / LIVE 实盘) +# 复制为 .env 后按需填写。真密钥不上库。 +# SIM | LIVE(设置页可改;切 LIVE 须二次确认) MODE=SIM ENV_NAME=test TZ=Asia/Shanghai @@ -17,7 +18,7 @@ AUTH_PASSWORD=admin123 AUTH_SECRET=change-me-eth-hedge-sim-secret AUTH_TOKEN_TTL_SEC=604800 -# OKX(SIM 阶段公共盘口可不填 Key) +# OKX(SIM 公共盘口可不填;LIVE 下单必填) OKX_API_KEY= OKX_API_SECRET= OKX_API_PASSPHRASE= @@ -26,6 +27,10 @@ OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public # 云上一般直连留空;本机受限时再填代理 OKX_HTTP_PROXY= +# 币安私有交易密钥(可落盘;实盘下单后续接入) +BINANCE_API_KEY= +BINANCE_API_SECRET= + # 币安公共行情(SIM) BINANCE_FAPI_BASE=https://fapi.binance.com BINANCE_EAPI_BASE=https://eapi.binance.com diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index dc0a647..7e5a2c0 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -1,11 +1,18 @@ from __future__ import annotations -from typing import Annotated +from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field 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 ( load_runtime_settings, normalize_exchange_name, @@ -195,3 +202,110 @@ async def put_strategy_settings( ) from e 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() diff --git a/backend/app/api/sim.py b/backend/app/api/sim.py index dacad66..e09e422 100644 --- a/backend/app/api/sim.py +++ b/backend/app/api/sim.py @@ -6,10 +6,11 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from ..config import get_settings +from ..env_store import live_ready +from ..live import get_executor from ..market import get_gateway from ..models.db import get_db from ..sim.ledger import Ledger -from ..sim.matcher import Matcher from ..strategy.clock import can_open_new, window_key from ..strategy.group import next_group_id from .auth import require_user @@ -29,7 +30,7 @@ async def sim_ledger(_user: Annotated[str, Depends(require_user)]) -> dict: @router.get("/position") async def sim_position(_user: Annotated[str, Depends(require_user)]) -> dict: - m = Matcher() + m = get_executor() return {"position": m.current_position(), "unrealized": m.unrealized()} @@ -38,7 +39,11 @@ async def sim_open_group( _user: Annotated[str, Depends(require_user)], body: ManualOpenBody | None = None, ) -> 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="有未平仓,禁止开下一组") s = get_settings() 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}-%",)) ) gid = next_group_id(count) - r = Matcher().open_group( + r = ex.open_group( group_id=gid, bias=bias, option_side=option_side, @@ -110,7 +115,7 @@ async def sim_open_group( @router.post("/close-group") 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: raise HTTPException(status_code=400, detail=r.detail) return { diff --git a/backend/app/config.py b/backend/app/config.py index 32ccb6e..bed76cc 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,6 +33,10 @@ class Settings(BaseSettings): okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public" okx_http_proxy: str = "" + # 币安私有交易密钥(本期仅落盘;实盘下单后续) + binance_api_key: str = "" + binance_api_secret: str = "" + # 币安公共行情(SIM 只读) binance_fapi_base: str = "https://fapi.binance.com" binance_eapi_base: str = "https://eapi.binance.com" diff --git a/backend/app/env_store.py b/backend/app/env_store.py new file mode 100644 index 0000000..9ac3387 --- /dev/null +++ b/backend/app/env_store.py @@ -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}" diff --git a/backend/app/live/__init__.py b/backend/app/live/__init__.py index 181d4f1..eeb6398 100644 --- a/backend/app/live/__init__.py +++ b/backend/app/live/__init__.py @@ -1 +1,5 @@ -# Placeholder: live OKX trade adapter (P5). Default off. +"""实盘执行适配层。""" + +from .executor import BinanceLiveStub, OkxLiveExecutor, get_executor + +__all__ = ["get_executor", "OkxLiveExecutor", "BinanceLiveStub"] diff --git a/backend/app/live/executor.py b/backend/app/live/executor.py new file mode 100644 index 0000000..1504283 --- /dev/null +++ b/backend/app/live/executor.py @@ -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) diff --git a/backend/app/live/okx_trade.py b/backend/app/live/okx_trade.py new file mode 100644 index 0000000..44dea29 --- /dev/null +++ b/backend/app/live/okx_trade.py @@ -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}") diff --git a/backend/app/main.py b/backend/app/main.py index 4f5d8df..b7cdb0d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -45,8 +45,9 @@ async def lifespan(app: FastAPI): try: await session.start() logger.info( - "exchange=%s strategy session started (SIM)", + "exchange=%s strategy session started mode=%s", settings.exchange, + "SIM" if get_settings().is_sim else "LIVE", ) except Exception: logger.exception("strategy session failed to start") diff --git a/backend/app/models/db.py b/backend/app/models/db.py index 0bc2df1..e561c32 100644 --- a/backend/app/models/db.py +++ b/backend/app/models/db.py @@ -40,7 +40,8 @@ CREATE TABLE IF NOT EXISTS groups ( realized_pnl REAL DEFAULT 0, fees REAL DEFAULT 0, slip_cost REAL DEFAULT 0, - note TEXT + note TEXT, + exec_mode TEXT ); CREATE TABLE IF NOT EXISTS fills ( @@ -58,6 +59,7 @@ CREATE TABLE IF NOT EXISTS fills ( slip REAL NOT NULL, notional REAL NOT NULL, ts_ms INTEGER NOT NULL, + exec_mode TEXT, FOREIGN KEY(group_id) REFERENCES groups(group_id) ); @@ -140,8 +142,26 @@ class Database: self._conn.execute("PRAGMA journal_mode=WAL;") self._conn.executescript(_SCHEMA) self._conn.commit() + self._migrate_columns() 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: with self._lock: self._conn.close() diff --git a/backend/app/sim/matcher.py b/backend/app/sim/matcher.py index e5361cf..1f1d3e9 100644 --- a/backend/app/sim/matcher.py +++ b/backend/app/sim/matcher.py @@ -231,8 +231,9 @@ class Matcher: 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 - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + strike, expiry_ymd, entry_index_px, initial_premium, open_at_ms, fees, slip_cost, + exec_mode + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( group_id, "open", @@ -248,13 +249,14 @@ class Matcher: now, pf.fee + of.fee, pf.slip + of.slip, + "SIM", ), ) # 成交顺序:期权先、永续后(时间戳差 1ms 便于审计) 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) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( group_id, "option", @@ -269,12 +271,13 @@ class Matcher: of.slip, of.notional, now, + "SIM", ), ) 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) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""", + base_px, fill_px, fee, slip, notional, ts_ms, exec_mode) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( group_id, "perp", @@ -289,6 +292,7 @@ class Matcher: pf.slip, pf.notional, now + 1, + "SIM", ), ) self.db._conn.execute( diff --git a/backend/app/strategy/engine.py b/backend/app/strategy/engine.py index f331f2d..3d62c14 100644 --- a/backend/app/strategy/engine.py +++ b/backend/app/strategy/engine.py @@ -11,7 +11,8 @@ from ..config import get_settings from .session import get_session from ..models.db import get_db 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 .exits import check_expiry_close, check_exits, resolve_exit_target from .group import next_group_id @@ -22,11 +23,15 @@ logger = logging.getLogger(__name__) class StrategyEngine: def __init__(self) -> None: self.db = get_db() - self.matcher = Matcher(self.db) self.ledger = Ledger(self.db) + self.matcher = get_executor(self.db) self._task: asyncio.Task[None] | None = None self._lock = asyncio.Lock() + def refresh_executor(self) -> None: + """MODE 变更后刷新执行器。""" + self.matcher = get_executor(self.db) + def state(self) -> dict[str, Any]: row = self.db.fetchone("SELECT * FROM strategy_state WHERE id=1") assert row is not None @@ -90,6 +95,10 @@ class StrategyEngine: "position": upl, "residuals": self.matcher.list_residual_options(pending_only=True), "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: @@ -108,6 +117,13 @@ class StrategyEngine: return self.state() 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.ensure_loop() return self.state() diff --git a/backend/tests/test_runtime_mode.py b/backend/tests/test_runtime_mode.py new file mode 100644 index 0000000..069f396 --- /dev/null +++ b/backend/tests/test_runtime_mode.py @@ -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 diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs index 731cac7..7ac71f6 100644 --- a/deploy/ecosystem.config.cjs +++ b/deploy/ecosystem.config.cjs @@ -7,7 +7,7 @@ module.exports = { args: 'app.main:app --host 0.0.0.0 --port 5155', interpreter: 'none', env: { - MODE: 'SIM', + // MODE 以仓库根 .env 为准(设置页可切 SIM/LIVE),勿在此硬编码覆盖 ENV_NAME: 'test', TZ: 'Asia/Shanghai', }, diff --git a/docs/实盘策略说明.md b/docs/实盘策略说明.md index c23b1f5..38152da 100644 --- a/docs/实盘策略说明.md +++ b/docs/实盘策略说明.md @@ -279,12 +279,15 @@ ## 11. 上线检查清单 1. SIM 同规则已跑通(含目标 A/B、到期、残留)。 -2. 实盘授权档位 + 二次确认(见商业化方案)。 -3. 选定交易所、合约族、API 与 IP 白名单。 -4. 确定 k;写入永续/期权名义与净利目标(15×k)。 -5. 保证金与权利金缓冲到位;期权逐仓。 -6. 监控:活跃仓、残留列表、持仓盘口钉死、紧急全平可用。 -7. 先 k=0.1 试跑至少覆盖:开仓、目标平、到期或残留结算各一类。 +2. 设置页切 **LIVE**,二次确认输入 `LIVE`;OKX Key/Secret/Passphrase 写入 `.env`。 +3. 实盘授权档位 + 二次确认(见商业化方案)。 +4. 选定交易所、合约族、API 与 IP 白名单。 +5. 确定 k;写入永续/期权名义与净利目标(15×k)。 +6. 保证金与权利金缓冲到位;期权逐仓。 +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/LIVE + API→.env;OKX 真下单 | diff --git a/docs/策略说明.md b/docs/策略说明.md index 4ee6fc1..dcba093 100644 --- a/docs/策略说明.md +++ b/docs/策略说明.md @@ -4,6 +4,8 @@ > 关联:[开发方案](./开发方案.md)、[商业化与授权方案](./商业化与授权方案.md)、[实盘策略说明](./实盘策略说明.md) > 更新:2026-07-26 +**运行模式**:设置页「运行模式」可切 **SIM / LIVE**;交易所 API 录入后写入服务器 `.env`(不回传明文)。LIVE 须二次确认输入 `LIVE`;当前 **OKX** 可真下单,币安仅存密钥。有持仓时不可切模式。 + --- ## 1. 策略一句话 @@ -334,3 +336,4 @@ | 2026-07-26 | 到期按内在价值结算(对齐实盘);紧急平仓仍用 max(买一,标记,内在价值) | | 2026-07-26 | 明确两套目标平仓:双腿全平 / 远虚只平永续+期权归档到期;到期为未达标路径 | | 2026-07-26 | 补充 §4.6:有仓钉持仓监控、空仓/仅残留跟新 ATM、残留列表与到期内在价值结算展示分工 | +| 2026-07-26 | 设置页 SIM/LIVE 切换;API 密钥落 `.env`;OKX LIVE 开平仓;币安密钥可存 | diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d040705..fb70295 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -175,6 +175,25 @@ export type PlanState = { status: string; }[]; 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 = { diff --git a/frontend/src/pages/Plan.tsx b/frontend/src/pages/Plan.tsx index 48ca7bf..f725259 100644 --- a/frontend/src/pages/Plan.tsx +++ b/frontend/src/pages/Plan.tsx @@ -132,10 +132,13 @@ export default function PlanPage() {
- SIM 本地撮合 · 行情{" "} + {plan?.mode === "LIVE" ? "LIVE 实盘下单" : "SIM 本地撮合"} · 行情{" "} {(snap?.exchange || "okx").toUpperCase()} {snap?.perp_inst_id ? ` · ${snap.perp_inst_id}` : ""} · 目标平仓(双腿/远虚只平永续) · 未达标则到期结算 + {plan?.mode === "LIVE" && plan.live_ready === false + ? ` · 未就绪: ${plan.live_ready_reason || "请配置 API"}` + : ""}
{err ?