Initial eth_hedge_sim: P0 market, auth UI, one-click deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-24 16:33:25 +08:00
commit e51f357b48
49 changed files with 4783 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# eth_hedge_sim — 独立自动对冲模拟盘
# 复制为 .env 后按需填写。模拟阶段禁止真实下单;真密钥不上库。
MODE=SIM
ENV_NAME=test
TZ=Asia/Shanghai
# HTTP(前后端同端口,默认 5155)
API_HOST=0.0.0.0
API_PORT=5155
# Web 登录(部署后请立刻修改)
AUTH_USERNAME=admin
AUTH_PASSWORD=admin123
AUTH_SECRET=change-me-eth-hedge-sim-secret
AUTH_TOKEN_TTL_SEC=604800
# OKX(SIM 阶段公共盘口可不填 Key)
OKX_API_KEY=
OKX_API_SECRET=
OKX_API_PASSPHRASE=
OKX_REST_BASE=https://www.okx.com
OKX_WS_PUBLIC=wss://ws.okx.com:8443/ws/v5/public
# 云上一般直连留空;本机受限时再填代理
OKX_HTTP_PROXY=
PERP_INST_ID=ETH-USDT-SWAP
OPTION_INST_FAMILY=ETH-USD_UM
INDEX_INST_ID=ETH-USD
FEE_RATE=0.0005
INITIAL_EQUITY=100000
MAX_ROUNDS=3
OPEN_HHMM=16:00
STOP_OPEN_HHMM=08:00
+35
View File
@@ -0,0 +1,35 @@
# env / secrets
.env
.env.local
*.pem
# python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
# local data
backend/data/*.db
backend/data/*.sqlite
backend/data/*.sqlite3
!backend/data/.gitkeep
# frontend
frontend/node_modules/
frontend/dist/
# ide / os
.idea/
.vscode/
*.swp
.DS_Store
Thumbs.db
# logs
*.log
logs/
+70
View File
@@ -0,0 +1,70 @@
# eth_hedge_sim
独立自动对冲**模拟盘**:OKX 实盘只读行情 + 本地虚拟资金撮合。
与现网 `crypto_monitor` **无代码、进程、密钥共用**
仓库:<https://git.bz121.com/dekun/eth_hedge_sim.git>
## 文档
- [开发方案](docs/开发方案.md)
- [代码结构](docs/代码结构.md)
## 访问(测试机)
- 地址:`http://47.236.184.99:5155`
- 默认登录:见服务器 `/opt/eth_hedge_sim/.env``AUTH_USERNAME` / `AUTH_PASSWORD`(示例 `admin` / `admin123`
- 登录页可填写 **API 地址**(同机部署填 `http://47.236.184.99:5155` 或留当前域名)
## 一键部署 / 更新(禁止 scp 传代码)
服务器目录:`/opt/eth_hedge_sim`
更新方式:**只允许 `git pull`**,然后构建并 `pm2 startOrReload` 本项目进程。
### 服务器上
```bash
# 首次
export REPO_URL=https://git.bz121.com/dekun/eth_hedge_sim.git
bash /opt/eth_hedge_sim/deploy/bootstrap.sh
# 若尚未 clone
# git clone "$REPO_URL" /opt/eth_hedge_sim && bash /opt/eth_hedge_sim/deploy/bootstrap.sh
# 日常更新
bash /opt/eth_hedge_sim/deploy/pull_and_restart.sh
```
### 本机触发远程更新
```bash
pip install paramiko
set DEPLOY_PASS=*** # Windows PowerShell: $env:DEPLOY_PASS='***'
python scripts/deploy_remote.py
```
PM2 进程名:`eth-hedge-api`(端口 **5155**)。禁止 `pm2 restart all`
## 本地开发
```bash
python -m venv .venv
.venv\Scripts\activate
pip install -r requirements.txt
copy .env.example .env
python scripts/smoke_market.py
cd backend
uvicorn app.main:app --host 0.0.0.0 --port 5155 --reload
# 另开终端
cd frontend
npm ci
npm run dev
```
## 硬规则摘要
- 模拟阶段零交易类 API
- 永续市价;期权只吃买卖一;滑点 = 1×手续费
- 仓位:永续 1 ETH,期权 2 ETH 名义
- 部署更新只用 git pull,不用 scp
+1
View File
@@ -0,0 +1 @@
"""eth_hedge_sim backend package."""
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from .auth_routes import router as auth_router
from .market import router as market_router
router = APIRouter()
router.include_router(auth_router)
router.include_router(market_router)
+85
View File
@@ -0,0 +1,85 @@
"""简单 HMAC Token 鉴权(无 JWT 依赖)。"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel, Field
from ..config import Settings, get_settings
_bearer = HTTPBearer(auto_error=False)
class LoginRequest(BaseModel):
username: str = Field(min_length=1)
password: str = Field(min_length=1)
class LoginResponse(BaseModel):
token: str
username: str
expires_in: int
env_name: str
mode: str
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
def _b64url_decode(s: str) -> bytes:
pad = "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s + pad)
def issue_token(username: str, settings: Settings) -> tuple[str, int]:
exp = int(time.time()) + int(settings.auth_token_ttl_sec)
payload = {"u": username, "exp": exp}
raw = _b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8"))
sig = hmac.new(
settings.auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
return f"{raw}.{sig}", settings.auth_token_ttl_sec
def verify_token(token: str, settings: Settings) -> str:
try:
raw, sig = token.rsplit(".", 1)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
expect = hmac.new(
settings.auth_secret.encode("utf-8"),
raw.encode("ascii"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expect, sig):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
try:
payload = json.loads(_b64url_decode(raw))
except Exception as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token") from e
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="token expired")
username = str(payload.get("u") or "")
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid token")
return username
def require_user(
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)],
settings: Annotated[Settings, Depends(get_settings)],
) -> str:
if creds is None or not creds.credentials:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return verify_token(creds.credentials, settings)
+36
View File
@@ -0,0 +1,36 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from ..config import Settings, get_settings
from .auth import LoginRequest, LoginResponse, issue_token, require_user
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, settings: Annotated[Settings, Depends(get_settings)]) -> LoginResponse:
user_ok = body.username == settings.auth_username
pass_ok = body.password == settings.auth_password
if not (user_ok and pass_ok):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
token, ttl = issue_token(body.username, settings)
return LoginResponse(
token=token,
username=body.username,
expires_in=ttl,
env_name=settings.env_name,
mode=settings.mode,
)
@router.get("/me")
async def me(username: Annotated[str, Depends(require_user)], settings: Annotated[Settings, Depends(get_settings)]) -> dict:
return {
"username": username,
"env_name": settings.env_name,
"mode": settings.mode,
"sim": settings.is_sim,
}
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException
from ..market import get_gateway
from .auth import require_user
router = APIRouter(prefix="/api/market", tags=["market"])
@router.get("/snapshot")
async def market_snapshot(_user: Annotated[str, Depends(require_user)]) -> dict:
gw = get_gateway()
snap = gw.snapshot_dict()
if snap.get("pair") is None:
raise HTTPException(status_code=503, detail="market not aligned yet")
return snap
@router.post("/realign")
async def market_realign(_user: Annotated[str, Depends(require_user)]) -> dict:
"""手动重对齐次日到期 ATM 合约(运维/调试用)。"""
gw = get_gateway()
try:
pair = await gw.realign_async()
except Exception as e:
raise HTTPException(status_code=502, detail=str(e)) from e
return {"ok": True, "pair": pair.to_dict() if pair else None, "snapshot": gw.snapshot_dict()}
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=(".env", "../.env"),
env_file_encoding="utf-8",
extra="ignore",
)
mode: str = "SIM"
tz: str = "Asia/Shanghai"
env_name: str = "test" # test / prod
api_host: str = "0.0.0.0"
api_port: int = 5155
# Web 登录(仅本机 .env,勿提交真密码)
auth_username: str = "admin"
auth_password: str = "admin123"
auth_secret: str = "change-me-eth-hedge-sim-secret"
auth_token_ttl_sec: int = 60 * 60 * 24 * 7
okx_api_key: str = ""
okx_api_secret: str = ""
okx_api_passphrase: str = ""
okx_rest_base: str = "https://www.okx.com"
okx_ws_public: str = "wss://ws.okx.com:8443/ws/v5/public"
okx_http_proxy: str = ""
perp_inst_id: str = "ETH-USDT-SWAP"
option_inst_family: str = "ETH-USD_UM"
index_inst_id: str = "ETH-USD"
fee_rate: float = 0.0005
initial_equity: float = 100_000.0
max_rounds: int = 3
open_hhmm: str = "16:00"
stop_open_hhmm: str = "08:00"
@property
def is_sim(self) -> bool:
return self.mode.strip().upper() != "LIVE"
@lru_cache
def get_settings() -> Settings:
return Settings()
+1
View File
@@ -0,0 +1 @@
# Placeholder: live OKX trade adapter (P5). Default off.
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from .api import router as api_router
from .config import get_settings
from .market import MarketGateway, set_gateway
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
)
logger = logging.getLogger("eth_hedge_sim")
def resolve_frontend_dist() -> Path:
here = Path(__file__).resolve()
# backend/app/main.py -> repo root is parents[2]
repo_root = here.parents[2]
return repo_root / "frontend" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = get_settings()
if not settings.is_sim:
logger.warning("MODE=%s — still read-only market in current phase", settings.mode)
gw = MarketGateway(settings)
set_gateway(gw)
try:
await gw.start()
logger.info("market gateway started (SIM read-only)")
except Exception:
logger.exception("market gateway failed to start")
yield
await gw.stop()
set_gateway(None)
app = FastAPI(
title="eth_hedge_sim",
version="0.2.0",
description="ETH 自动对冲模拟盘",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router)
@app.get("/health")
async def health() -> dict:
from .market import get_gateway
settings = get_settings()
gw = get_gateway()
snap = gw.snapshot()
return {
"ok": True,
"mode": settings.mode,
"env_name": settings.env_name,
"sim": settings.is_sim,
"market_connected": snap.connected,
"pair": snap.pair.to_dict() if snap.pair else None,
"updated_at_ms": snap.updated_at_ms,
}
_DIST = resolve_frontend_dist()
if (_DIST / "assets").is_dir():
app.mount("/assets", StaticFiles(directory=str(_DIST / "assets")), name="assets")
@app.get("/")
async def index_page():
index = _DIST / "index.html"
if index.exists():
return FileResponse(index)
return {
"ok": True,
"msg": "frontend not built yet; run: cd frontend && npm ci && npm run build",
"health": "/health",
}
@app.get("/app/{full_path:path}")
@app.get("/plan")
@app.get("/trades")
@app.get("/stats")
@app.get("/settings")
@app.get("/login")
async def spa_pages(full_path: str = ""):
index = _DIST / "index.html"
if not index.exists():
raise HTTPException(status_code=404, detail="frontend not built")
return FileResponse(index)
+22
View File
@@ -0,0 +1,22 @@
"""OKX 实盘只读行情网关。"""
from .book_cache import BookCache
from .gateway import MarketGateway, get_gateway, set_gateway
from .instruments import next_session_expiry_ymd, select_option_pair
from .okx_rest import OkxRestClient
from .okx_ws import OkxPublicWs
from .types import MarketSnapshot, OptionPair, Quote
__all__ = [
"BookCache",
"MarketGateway",
"MarketSnapshot",
"OkxPublicWs",
"OkxRestClient",
"OptionPair",
"Quote",
"get_gateway",
"next_session_expiry_ymd",
"select_option_pair",
"set_gateway",
]
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import threading
import time
from typing import Iterable
from .types import BookLevel, MarketSnapshot, OptionPair, Quote
class BookCache:
"""内存盘口缓存:永续 + Call/Put。线程安全。"""
def __init__(self) -> None:
self._lock = threading.RLock()
self._quotes: dict[str, Quote] = {}
self._index_px: float | None = None
self._pair: OptionPair | None = None
self._connected = False
self._updated_at_ms: int | None = None
def set_connected(self, ok: bool) -> None:
with self._lock:
self._connected = bool(ok)
def set_pair(self, pair: OptionPair | None) -> None:
with self._lock:
self._pair = pair
def set_index_px(self, px: float | None) -> None:
with self._lock:
if px is not None and px > 0:
self._index_px = float(px)
self._touch()
def upsert_book(
self,
inst_id: str,
*,
bids: list[BookLevel],
asks: list[BookLevel],
ts_ms: int | None = None,
) -> None:
with self._lock:
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
q.bids = bids
q.asks = asks
q.bid = bids[0].px if bids else None
q.ask = asks[0].px if asks else None
q.bid_sz = bids[0].sz if bids else None
q.ask_sz = asks[0].sz if asks else None
if ts_ms is not None:
q.ts_ms = ts_ms
self._quotes[inst_id] = q
self._touch(ts_ms)
def upsert_top(
self,
inst_id: str,
*,
bid: float | None,
ask: float | None,
bid_sz: float | None = None,
ask_sz: float | None = None,
ts_ms: int | None = None,
) -> None:
with self._lock:
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
if bid is not None:
q.bid = bid
if ask is not None:
q.ask = ask
if bid_sz is not None:
q.bid_sz = bid_sz
if ask_sz is not None:
q.ask_sz = ask_sz
if ts_ms is not None:
q.ts_ms = ts_ms
# 同步一层盘口,便于 snapshot 展示
if bid is not None and bid_sz is not None:
q.bids = [BookLevel(px=bid, sz=bid_sz)] + q.bids[1:]
if ask is not None and ask_sz is not None:
q.asks = [BookLevel(px=ask, sz=ask_sz)] + q.asks[1:]
self._quotes[inst_id] = q
self._touch(ts_ms)
def set_mark_px(self, inst_id: str, mark_px: float | None, ts_ms: int | None = None) -> None:
with self._lock:
if mark_px is None or mark_px <= 0:
return
q = self._quotes.get(inst_id) or Quote(inst_id=inst_id)
q.mark_px = float(mark_px)
if ts_ms is not None:
q.ts_ms = ts_ms
self._quotes[inst_id] = q
self._touch(ts_ms)
def get(self, inst_id: str) -> Quote | None:
with self._lock:
return self._quotes.get(inst_id)
def drop_except(self, keep: Iterable[str]) -> None:
keep_set = set(keep)
with self._lock:
for k in list(self._quotes):
if k not in keep_set:
del self._quotes[k]
def snapshot(self, perp_inst_id: str) -> MarketSnapshot:
with self._lock:
pair = self._pair
call = self._quotes.get(pair.call_inst_id) if pair else None
put = self._quotes.get(pair.put_inst_id) if pair else None
return MarketSnapshot(
perp=self._quotes.get(perp_inst_id),
call=call,
put=put,
index_px=self._index_px,
pair=pair,
connected=self._connected,
updated_at_ms=self._updated_at_ms,
)
def _touch(self, ts_ms: int | None = None) -> None:
self._updated_at_ms = int(ts_ms) if ts_ms is not None else int(time.time() * 1000)
+150
View File
@@ -0,0 +1,150 @@
"""行情网关:REST 对齐合约 + WS 推送盘口。"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from ..config import Settings, get_settings
from .book_cache import BookCache
from .instruments import next_session_expiry_ymd, select_option_pair
from .okx_rest import OkxRestClient
from .okx_ws import OkxPublicWs
from .types import MarketSnapshot, OptionPair
logger = logging.getLogger(__name__)
class MarketGateway:
def __init__(self, settings: Settings | None = None) -> None:
self.settings = settings or get_settings()
self.cache = BookCache()
proxy = self.settings.okx_http_proxy or None
self.rest = OkxRestClient(self.settings.okx_rest_base, proxy=proxy)
self.ws = OkxPublicWs(self.settings.okx_ws_public, self.cache, proxy=proxy)
self._pair: OptionPair | None = None
self._refresh_task: asyncio.Task[None] | None = None
self._started = False
@property
def pair(self) -> OptionPair | None:
return self._pair
async def start(self) -> None:
if self._started:
return
self._started = True
await asyncio.to_thread(self.align_instruments)
await self.ws.start()
self._refresh_task = asyncio.create_task(self._refresh_loop(), name="market-align")
async def stop(self) -> None:
self._started = False
if self._refresh_task:
self._refresh_task.cancel()
try:
await self._refresh_task
except asyncio.CancelledError:
pass
self._refresh_task = None
await self.ws.stop()
self.rest.close()
def align_instruments(self) -> OptionPair | None:
"""同步:拉期权列表,选次日到期 ATM Call/Put,REST 预热盘口,切换 WS 订阅。"""
s = self.settings
idx = self.rest.fetch_index_ticker(s.index_inst_id)
mark = self.rest.fetch_mark_price(s.perp_inst_id) or idx
if mark is None or mark <= 0:
raise RuntimeError("无法获取 ETH 标记/指数价格,无法选 ATM")
instruments = self.rest.fetch_option_instruments(s.option_inst_family)
ymd = next_session_expiry_ymd()
pair = select_option_pair(instruments, mark_px=float(mark), expiry_ymd=ymd)
if pair is None:
raise RuntimeError(f"未找到到期 {ymd} 的 ATM Call/Put 合约 pair (family={s.option_inst_family})")
self._pair = pair
self.cache.set_pair(pair)
self.cache.set_index_px(idx)
# REST 预热:永续 + Call + Put
for inst in (s.perp_inst_id, pair.call_inst_id, pair.put_inst_id):
bids, asks, ts = self.rest.fetch_books(inst, sz=5)
self.cache.upsert_book(inst, bids=bids, asks=asks, ts_ms=ts)
mp = self.rest.fetch_mark_price(inst)
if mp:
self.cache.set_mark_px(inst, mp)
keep = {s.perp_inst_id, pair.call_inst_id, pair.put_inst_id}
self.cache.drop_except(keep)
self.ws.set_instruments([s.perp_inst_id, pair.call_inst_id, pair.put_inst_id])
logger.info(
"aligned pair expiry=%s strike=%s call=%s put=%s mark=%.2f",
pair.expiry_ymd,
pair.strike,
pair.call_inst_id,
pair.put_inst_id,
mark,
)
return pair
async def realign_async(self) -> OptionPair | None:
old = self._pair
pair = await asyncio.to_thread(self.align_instruments)
if old is None or (
pair
and (
pair.call_inst_id != old.call_inst_id
or pair.put_inst_id != old.put_inst_id
)
):
await self.ws.resubscribe(
[
self.settings.perp_inst_id,
pair.call_inst_id,
pair.put_inst_id,
]
)
return pair
def snapshot(self) -> MarketSnapshot:
return self.cache.snapshot(self.settings.perp_inst_id)
def snapshot_dict(self) -> dict[str, Any]:
return self.snapshot().to_dict()
async def _refresh_loop(self) -> None:
"""周期性刷新指数价;跨日到期切换时重对齐。"""
while True:
await asyncio.sleep(30)
try:
idx = await asyncio.to_thread(
self.rest.fetch_index_ticker, self.settings.index_inst_id
)
self.cache.set_index_px(idx)
want = next_session_expiry_ymd()
if self._pair and self._pair.expiry_ymd != want:
logger.info("expiry rollover %s -> %s", self._pair.expiry_ymd, want)
await self.realign_async()
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("market refresh failed: %s", e)
# 进程级单例(FastAPI lifespan 注入)
_gateway: MarketGateway | None = None
def get_gateway() -> MarketGateway:
global _gateway
if _gateway is None:
_gateway = MarketGateway()
return _gateway
def set_gateway(gw: MarketGateway | None) -> None:
global _gateway
_gateway = gw
+119
View File
@@ -0,0 +1,119 @@
"""合约选择:次日 16:00(上海)到期 + ATM 行权价(暂定默认,待拍板可改)。"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from zoneinfo import ZoneInfo
from .types import OptionPair
_SH = ZoneInfo("Asia/Shanghai")
_DATE_RE = re.compile(r"^\d{6}$")
def safe_float(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def parse_option_inst_id(inst_id: str) -> tuple[str | None, float | None, str | None]:
"""ETH-USD_UM-YYMMDD-STRIKE-C → (YYMMDD, strike, C|P)."""
parts = (inst_id or "").strip().split("-")
if len(parts) < 5:
return None, None, None
ymd = parts[-3]
strike = safe_float(parts[-2])
opt = parts[-1].upper()
if not _DATE_RE.fullmatch(ymd) or strike is None or opt not in ("C", "P"):
return None, None, None
return ymd, strike, opt
def expiry_ms_from_ymd(ymd: str) -> int:
"""OKX 期权到期:当日 08:00 UTC = 上海 16:00。"""
yy, mm, dd = int(ymd[0:2]), int(ymd[2:4]), int(ymd[4:6])
dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
def next_session_expiry_ymd(now: datetime | None = None) -> str:
"""
业务约定:开仓选「次日 16:00」到期。
- 上海时间 >= 当日 16:00:目标到期日 = 次日
- 上海时间 < 当日 16:00:目标到期日 = 当日(当日 16:00 到期仍可用作盘口对齐/预热)
正式开仓窗从当日 16:00 起,届时「次日」即日历次日。
"""
now_sh = (now or datetime.now(tz=_SH)).astimezone(_SH)
open_today = now_sh.replace(hour=16, minute=0, second=0, microsecond=0)
if now_sh >= open_today:
target = now_sh.date() + timedelta(days=1)
else:
target = now_sh.date()
return target.strftime("%y%m%d")
def pick_atm_strike(strikes: list[float], mark_px: float) -> float | None:
if not strikes or mark_px <= 0:
return None
return min(strikes, key=lambda s: (abs(s - mark_px), s))
def select_option_pair(
instruments: list[dict[str, Any]],
*,
mark_px: float,
expiry_ymd: str | None = None,
now: datetime | None = None,
) -> OptionPair | None:
"""
从 live 合约列表中选出:目标到期日 + ATM 同行权价 Call/Put。
行权价规则暂定 ATM(最接近标记/指数价);待拍板后可替换。
"""
ymd = expiry_ymd or next_session_expiry_ymd(now)
by_strike: dict[float, dict[str, str]] = {}
for row in instruments:
if not isinstance(row, dict):
continue
state = str(row.get("state") or "live").lower()
if state and state != "live":
continue
inst_id = str(row.get("instId") or "")
y, stk, opt = parse_option_inst_id(inst_id)
if y is None or stk is None or opt is None:
exp = safe_float(row.get("expTime"))
if exp:
ms = int(exp) if exp > 10_000_000_000 else int(exp * 1000)
y = datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%y%m%d")
stk = safe_float(row.get("stk"))
opt_raw = str(row.get("optType") or "").upper()
opt = opt_raw if opt_raw in ("C", "P") else None
if not inst_id or y != ymd or stk is None or opt not in ("C", "P"):
continue
by_strike.setdefault(float(stk), {})[opt] = inst_id
complete = {s: v for s, v in by_strike.items() if "C" in v and "P" in v}
if not complete:
return None
atm = pick_atm_strike(list(complete.keys()), mark_px)
if atm is None:
return None
legs = complete[atm]
return OptionPair(
expiry_ymd=ymd,
expiry_ms=expiry_ms_from_ymd(ymd),
strike=atm,
call_inst_id=legs["C"],
put_inst_id=legs["P"],
)
+99
View File
@@ -0,0 +1,99 @@
"""OKX REST 只读行情。不调用任何交易类接口。"""
from __future__ import annotations
from typing import Any
import httpx
from .instruments import safe_float
from .types import BookLevel
class OkxRestClient:
def __init__(
self,
base_url: str = "https://www.okx.com",
timeout: float = 15.0,
proxy: str | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.proxy = (proxy or "").strip() or None
self._client = httpx.Client(
base_url=self.base_url,
timeout=timeout,
proxy=self.proxy,
headers={"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.1"},
)
def close(self) -> None:
self._client.close()
def __enter__(self) -> OkxRestClient:
return self
def __exit__(self, *args: object) -> None:
self.close()
def _get(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
r = self._client.get(path, params=params or {})
r.raise_for_status()
body = r.json()
if str(body.get("code")) != "0":
raise RuntimeError(f"OKX REST error code={body.get('code')} msg={body.get('msg')}")
data = body.get("data") or []
return [x for x in data if isinstance(x, dict)]
def fetch_instruments(self, *, inst_type: str, inst_family: str | None = None) -> list[dict[str, Any]]:
params: dict[str, Any] = {"instType": inst_type}
if inst_family:
params["instFamily"] = inst_family
return self._get("/api/v5/public/instruments", params)
def fetch_option_instruments(self, inst_family: str) -> list[dict[str, Any]]:
rows = self.fetch_instruments(inst_type="OPTION", inst_family=inst_family)
return [r for r in rows if str(r.get("state") or "").lower() == "live"]
def fetch_index_ticker(self, inst_id: str) -> float | None:
rows = self._get("/api/v5/market/index-tickers", {"instId": inst_id})
if not rows:
return None
return safe_float(rows[0].get("idxPx"))
def fetch_mark_price(self, inst_id: str) -> float | None:
rows = self._get("/api/v5/public/mark-price", {"instId": inst_id})
if not rows:
t = self._get("/api/v5/market/ticker", {"instId": inst_id})
if not t:
return None
return safe_float(t[0].get("markPx")) or safe_float(t[0].get("last"))
return safe_float(rows[0].get("markPx"))
def fetch_books(self, inst_id: str, sz: int = 5) -> tuple[list[BookLevel], list[BookLevel], int | None]:
rows = self._get(
"/api/v5/market/books",
{"instId": inst_id, "sz": str(max(1, min(int(sz), 400)))},
)
if not rows:
return [], [], None
row = rows[0]
ts = safe_float(row.get("ts"))
ts_ms = int(ts) if ts is not None else None
return (
_levels(row.get("bids") or []),
_levels(row.get("asks") or []),
ts_ms,
)
def _levels(raw: list[Any]) -> list[BookLevel]:
out: list[BookLevel] = []
for item in raw:
if not isinstance(item, (list, tuple)) or len(item) < 2:
continue
px = safe_float(item[0])
sz = safe_float(item[1])
if px is None or sz is None or px <= 0 or sz <= 0:
continue
out.append(BookLevel(px=px, sz=sz))
return out
+207
View File
@@ -0,0 +1,207 @@
"""OKX 公共 WebSocket:永续 + 期权 books5 / mark-price。只读。"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from urllib.parse import urlparse
import websockets
from websockets.asyncio.client import ClientConnection
from .book_cache import BookCache
from .instruments import safe_float
from .types import BookLevel
logger = logging.getLogger(__name__)
class OkxPublicWs:
def __init__(
self,
url: str,
cache: BookCache,
*,
proxy: str | None = None,
ping_interval: float = 20.0,
) -> None:
self.url = url
self.cache = cache
self.proxy = (proxy or "").strip() or None
self.ping_interval = ping_interval
self._inst_ids: list[str] = []
self._task: asyncio.Task[None] | None = None
self._stop = asyncio.Event()
self._subscribed: set[str] = set()
def set_instruments(self, inst_ids: list[str]) -> None:
self._inst_ids = [i for i in inst_ids if i]
async def start(self) -> None:
if self._task and not self._task.done():
return
self._stop.clear()
self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws")
async def stop(self) -> None:
self._stop.set()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self.cache.set_connected(False)
async def resubscribe(self, inst_ids: list[str]) -> None:
self.set_instruments(inst_ids)
self._stop.set()
await asyncio.sleep(0)
self._stop.clear()
if self._task and not self._task.done():
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = asyncio.create_task(self._run_forever(), name="okx-public-ws")
async def _open_connection(self) -> ClientConnection:
if not self.proxy:
return await websockets.connect(
self.url,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
from python_socks.async_.asyncio import Proxy
parsed = urlparse(self.url)
host = parsed.hostname or "ws.okx.com"
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
sock = await Proxy.from_url(self.proxy).connect(dest_host=host, dest_port=port)
return await websockets.connect(
self.url,
sock=sock,
server_hostname=host,
ping_interval=None,
max_size=2**22,
open_timeout=20,
)
async def _run_forever(self) -> None:
backoff = 1.0
while not self._stop.is_set():
try:
async with await self._open_connection() as ws:
self.cache.set_connected(True)
backoff = 1.0
await self._subscribe(ws)
waiter = asyncio.create_task(self._stop.wait())
reader = asyncio.create_task(self._read_loop(ws))
pinger = asyncio.create_task(self._ping_loop(ws))
done, pending = await asyncio.wait(
{waiter, reader, pinger},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
for t in done:
exc = t.exception()
if exc and not isinstance(exc, asyncio.CancelledError):
raise exc
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning("OKX WS disconnected: %s", e)
self.cache.set_connected(False)
try:
await asyncio.wait_for(self._stop.wait(), timeout=backoff)
break
except asyncio.TimeoutError:
backoff = min(backoff * 2, 30.0)
self.cache.set_connected(False)
async def _subscribe(self, ws: ClientConnection) -> None:
args: list[dict[str, str]] = []
for inst in self._inst_ids:
args.append({"channel": "books5", "instId": inst})
args.append({"channel": "mark-price", "instId": inst})
if not args:
return
payload = {"op": "subscribe", "args": args}
await ws.send(json.dumps(payload))
self._subscribed = {a["instId"] for a in args}
logger.info("OKX WS subscribed: %s", sorted(self._subscribed))
async def _ping_loop(self, ws: ClientConnection) -> None:
while True:
await asyncio.sleep(self.ping_interval)
await ws.send("ping")
async def _read_loop(self, ws: ClientConnection) -> None:
try:
async for raw in ws:
if raw == "pong":
continue
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="ignore")
if raw == "ping":
await ws.send("pong")
continue
try:
msg = json.loads(raw)
except json.JSONDecodeError:
continue
self._handle_message(msg)
except websockets.exceptions.ConnectionClosed:
return
def _handle_message(self, msg: dict[str, Any]) -> None:
if msg.get("event") in ("subscribe", "error", "channel-conn-count"):
if msg.get("event") == "error":
logger.error("OKX WS error: %s", msg)
return
arg = msg.get("arg") or {}
channel = str(arg.get("channel") or "")
inst_id = str(arg.get("instId") or "")
data = msg.get("data") or []
if not inst_id or not data:
return
row = data[0] if isinstance(data[0], dict) else None
if row is None:
return
if channel == "books5":
ts = safe_float(row.get("ts"))
self.cache.upsert_book(
inst_id,
bids=_levels(row.get("bids") or []),
asks=_levels(row.get("asks") or []),
ts_ms=int(ts) if ts is not None else None,
)
elif channel == "mark-price":
ts = safe_float(row.get("ts"))
self.cache.set_mark_px(
inst_id,
safe_float(row.get("markPx")),
ts_ms=int(ts) if ts is not None else None,
)
def _levels(raw: list[Any]) -> list[BookLevel]:
out: list[BookLevel] = []
for item in raw:
if not isinstance(item, (list, tuple)) or len(item) < 2:
continue
px = safe_float(item[0])
sz = safe_float(item[1])
if px is None or sz is None or px <= 0 or sz <= 0:
continue
out.append(BookLevel(px=px, sz=sz))
return out
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class BookLevel:
px: float
sz: float # OKX 张数 / 合约张数口径
@dataclass(slots=True)
class Quote:
inst_id: str
bid: float | None = None
ask: float | None = None
bid_sz: float | None = None
ask_sz: float | None = None
mark_px: float | None = None
ts_ms: int | None = None
bids: list[BookLevel] = field(default_factory=list)
asks: list[BookLevel] = field(default_factory=list)
def to_dict(self, *, depth: int = 5) -> dict[str, Any]:
return {
"inst_id": self.inst_id,
"bid": self.bid,
"ask": self.ask,
"bid_sz": self.bid_sz,
"ask_sz": self.ask_sz,
"mark_px": self.mark_px,
"ts_ms": self.ts_ms,
"bids": [{"px": x.px, "sz": x.sz} for x in self.bids[:depth]],
"asks": [{"px": x.px, "sz": x.sz} for x in self.asks[:depth]],
}
@dataclass(slots=True)
class OptionPair:
expiry_ymd: str # YYMMDD
expiry_ms: int
strike: float
call_inst_id: str
put_inst_id: str
def to_dict(self) -> dict[str, Any]:
return {
"expiry_ymd": self.expiry_ymd,
"expiry_ms": self.expiry_ms,
"strike": self.strike,
"call_inst_id": self.call_inst_id,
"put_inst_id": self.put_inst_id,
}
@dataclass(slots=True)
class MarketSnapshot:
perp: Quote | None
call: Quote | None
put: Quote | None
index_px: float | None
pair: OptionPair | None
connected: bool
updated_at_ms: int | None
def to_dict(self) -> dict[str, Any]:
return {
"connected": self.connected,
"updated_at_ms": self.updated_at_ms,
"index_px": self.index_px,
"pair": self.pair.to_dict() if self.pair else None,
"perp": self.perp.to_dict() if self.perp else None,
"call": self.call.to_dict() if self.call else None,
"put": self.put.to_dict() if self.put else None,
"ask_compare": {
"call_ask": self.call.ask if self.call else None,
"put_ask": self.put.ask if self.put else None,
"bias": _ask_bias(self.call, self.put),
},
}
def _ask_bias(call: Quote | None, put: Quote | None) -> str:
"""卖一比价仅用于选向展示;相等则 wait。"""
ca = call.ask if call else None
pa = put.ask if put else None
if ca is None or pa is None:
return "unknown"
if ca > pa:
return "call_ask_gt_put" # 永续多 + 期权空(腿待拍板)
if ca < pa:
return "put_ask_gt_call" # 永续空 + 期权多(腿待拍板)
return "equal"
+1
View File
@@ -0,0 +1 @@
# Placeholder: DB models (P1).
+1
View File
@@ -0,0 +1 @@
# Placeholder: application services (P1+).
+1
View File
@@ -0,0 +1 @@
# Placeholder packages for later phases (P1P5).
+1
View File
@@ -0,0 +1 @@
# Placeholder: strategy state machine (P2).
+1
View File
@@ -0,0 +1 @@
# Placeholder: frontend push WS (P3).
View File
+8
View File
@@ -0,0 +1,8 @@
from __future__ import annotations
import sys
from pathlib import Path
BACKEND = Path(__file__).resolve().parents[1]
if str(BACKEND) not in sys.path:
sys.path.insert(0, str(BACKEND))
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
from app.market.instruments import (
next_session_expiry_ymd,
parse_option_inst_id,
pick_atm_strike,
select_option_pair,
)
_SH = ZoneInfo("Asia/Shanghai")
def test_parse_option_inst_id() -> None:
y, s, o = parse_option_inst_id("ETH-USD_UM-260725-3500-C")
assert y == "260725"
assert s == 3500.0
assert o == "C"
def test_pick_atm_strike() -> None:
assert pick_atm_strike([3400, 3500, 3600], 3510) == 3500
def test_next_session_expiry_before_open() -> None:
now = datetime(2026, 7, 24, 15, 0, tzinfo=_SH)
assert next_session_expiry_ymd(now) == "260724"
def test_next_session_expiry_after_open() -> None:
now = datetime(2026, 7, 24, 16, 0, tzinfo=_SH)
assert next_session_expiry_ymd(now) == "260725"
def test_select_option_pair_atm() -> None:
rows = [
{"instId": "ETH-USD_UM-260725-3490-C", "state": "live"},
{"instId": "ETH-USD_UM-260725-3490-P", "state": "live"},
{"instId": "ETH-USD_UM-260725-3500-C", "state": "live"},
{"instId": "ETH-USD_UM-260725-3500-P", "state": "live"},
{"instId": "ETH-USD_UM-260726-3500-C", "state": "live"},
{"instId": "ETH-USD_UM-260726-3500-P", "state": "live"},
]
pair = select_option_pair(rows, mark_px=3502, expiry_ymd="260725")
assert pair is not None
assert pair.strike == 3500
assert pair.call_inst_id.endswith("-3500-C")
assert pair.put_inst_id.endswith("-3500-P")
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# 首次安装到 /opt/eth_hedge_sim(仅 git clone,后续一律 git pull
set -euo pipefail
REPO_URL="${REPO_URL:-https://git.bz121.com/dekun/eth_hedge_sim.git}"
ROOT="${INSTALL_ROOT:-/opt/eth_hedge_sim}"
if [[ ! -d "$ROOT/.git" ]]; then
mkdir -p "$(dirname "$ROOT")"
git clone "$REPO_URL" "$ROOT"
fi
bash "$ROOT/deploy/pull_and_restart.sh"
+17
View File
@@ -0,0 +1,17 @@
module.exports = {
apps: [
{
name: 'eth-hedge-api',
cwd: '/opt/eth_hedge_sim/backend',
script: '/opt/eth_hedge_sim/.venv/bin/uvicorn',
args: 'app.main:app --host 0.0.0.0 --port 5155',
interpreter: 'none',
env: {
MODE: 'SIM',
ENV_NAME: 'test',
TZ: 'Asia/Shanghai',
},
// 密钥与本地配置一律读 /opt/eth_hedge_sim/.env(勿写进本文件)
},
],
};
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# 一键更新/部署:仅 git pull,禁止 scp 传代码。
# 用法(服务器上): bash /opt/eth_hedge_sim/deploy/pull_and_restart.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
echo "[eth_hedge_sim] pull @ $ROOT"
git fetch --all --prune
git pull --ff-only
if [[ ! -f "$ROOT/.env" ]]; then
echo "[eth_hedge_sim] missing .env — copy from example"
cp "$ROOT/.env.example" "$ROOT/.env"
# 云上测试机默认直连 OKX
sed -i 's/^OKX_HTTP_PROXY=.*/OKX_HTTP_PROXY=/' "$ROOT/.env" || true
sed -i 's/^API_PORT=.*/API_PORT=5155/' "$ROOT/.env" || true
sed -i 's/^ENV_NAME=.*/ENV_NAME=test/' "$ROOT/.env" || true
echo "[eth_hedge_sim] wrote .env — please review AUTH_* secrets"
fi
if [[ ! -d "$ROOT/.venv" ]]; then
python3 -m venv "$ROOT/.venv"
fi
# shellcheck disable=SC1091
source "$ROOT/.venv/bin/activate"
pip install -U pip
pip install -r "$ROOT/requirements.txt"
if ! command -v npm >/dev/null 2>&1; then
echo "ERROR: npm not found. Install Node.js 18+ first."
exit 1
fi
(
cd "$ROOT/frontend"
if [[ -f package-lock.json ]]; then npm ci; else npm install; fi
npm run build
)
if ! command -v pm2 >/dev/null 2>&1; then
npm install -g pm2
fi
# 仅 reload 本项目进程,禁止 pm2 restart all
pm2 startOrReload "$ROOT/deploy/ecosystem.config.cjs" --update-env
pm2 save
echo "[eth_hedge_sim] done. health: http://127.0.0.1:5155/health"
+283
View File
@@ -0,0 +1,283 @@
# eth_hedge_sim — 代码结构
> 本文描述目标仓库目录与模块职责。当前阶段以文档为准;业务代码按阶段逐步添加。
> 可从 `crypto_monitor` **复制** 有用片段到本仓库后改造;**禁止修改** 现网仓库内文件。
---
## 1. 根目录总览
```text
eth_hedge_sim/
├── README.md
├── .gitignore
├── .env.example # 无密钥的示例;真 .env 不上库
├── package.json # 若前端/Node;或改用 Python 则 requirements.txt
├── requirements.txt # 后端若用 Python
├── docs/
│ ├── 开发方案.md # 业务与部署方案(本文档姐妹篇)
│ └── 代码结构.md # 本文件
├── deploy/
│ ├── ecosystem.config.cjs # PM2:仅本项目进程
│ └── pull_and_restart.sh # git pull + 构建 + pm2 reload(不碰现网)
├── backend/ # API + 行情 + 策略 + 本地撮合
│ ├── app/
│ │ ├── main.py # 或 index.ts:进程入口
│ │ ├── config.py
│ │ ├── api/ # HTTP 路由
│ │ ├── ws/ # 向前端推送
│ │ ├── market/ # OKX 只读行情
│ │ ├── strategy/ # 自动对冲状态机
│ │ ├── sim/ # 本地撮合与账本
│ │ ├── live/ # 实盘适配器(后期,默认关闭)
│ │ ├── models/ # DB 模型 / schema
│ │ └── services/ # 组、统计、设置等应用服务
│ ├── data/ # 本地 SQLite 等(gitignore 数据文件)
│ └── tests/
├── frontend/ # OKX 风格 Web
│ ├── index.html
│ ├── package.json
│ ├── src/
│ │ ├── main.tsx
│ │ ├── App.tsx
│ │ ├── layouts/ # 导航壳
│ │ ├── pages/
│ │ │ ├── Plan.tsx # 自动对冲计划
│ │ │ ├── Trades.tsx # 交易记录
│ │ │ ├── Stats.tsx # 统计
│ │ │ └── Settings.tsx # 系统设置
│ │ ├── components/ # 盘口、组标识、持仓条等
│ │ ├── api/ # 调后端
│ │ └── styles/ # OKX 深色交易台风
│ └── dist/ # 构建产物(可部署由 API 托管或 nginx)
└── scripts/ # 运维/一次性工具(可选)
└── smoke_market.py # 只读行情连通性检查
```
技术栈可在开工时二选一(建议尽快定一种,避免双栈):
- **推荐 A**:后端 PythonFastAPI+ 前端 React/Vite(与现网 Python 生态接近,便于复制行情代码)。
- **推荐 B**:全 NodeNest/Express + React)。
下文按 **推荐 A** 描述模块;若选 B,目录名对应平移即可。
---
## 2. 后端模块职责
### 2.1 `backend/app/market/` — 行情(OKX 实盘只读)
```text
market/
├── okx_rest.py # 合约列表、到期、启动对齐
├── okx_ws.py # 永续 + 期权盘口订阅
├── instruments.py # 解析「次日 16:00」到期、ATM 行权价
├── book_cache.py # 买一/卖一/深度内存缓存
└── types.py # Quote / Depth 结构
```
职责:
- 只拉行情,**不调用交易接口**。
- 对外提供:永续买卖一、Call/Put 买卖一与深度、标记价。
- 可选:行情快照落盘,供 P4 回放。
可参考现网 OKX WS/REST 实现,复制后改成本模块 API。
### 2.2 `backend/app/sim/` — 本地模拟
```text
sim/
├── matcher.py # 撮合:永续市价;期权吃买卖一;滑点=1×f;双边手续费
├── ledger.py # 虚拟资金、占用、流水
├── liquidity.py # 买一深度是否覆盖 2 ETH
└── pricing.py # 成交价公式(含 f)
```
成交价口径(与开发方案一致):
| 腿 | 动作 | 基准 | 成交价 |
|----|------|------|--------|
| 永续 | 开多 / 平空 | 卖一 | 基准 × (1+f) |
| 永续 | 开空 / 平多 | 买一 | 基准 × (1-f) |
| 期权 | 买入 | 卖一 | 基准 × (1+f) |
| 期权 | 卖出 | 买一 | 基准 × (1-f) |
另扣:`手续费 = 名义 × f`
### 2.3 `backend/app/strategy/` — 自动对冲
```text
strategy/
├── clock.py # 业务窗:D 16:00D+1 08:00;次数≤3
├── signal.py # Call 卖一 vs Put 卖一 → 方向
├── exits.py # 权利金覆盖 / 30 点
├── sizing.py # 永续 1 ETH、期权 2 ETH(写死)
├── group.py # 组 IDG-YYYYMMDD-NN
└── engine.py # 状态机:空仓→开仓→盯盘→全平→下一组
```
状态机要点:
- 同时最多 1 组。
- 平完才允许下一组;达 3 次或过 08:00 则停开。
- 每组锁定 `initial_premium`
### 2.4 `backend/app/live/` — 实盘(P5
```text
live/
├── okx_trade.py # 永续市价单;期权吃一对应下单
└── adapter.py # 与 sim.matcher 相同接口,便于切换
```
默认关闭;仅 `MODE=LIVE` 且设置页确认后启用。
### 2.5 `backend/app/api/` + `ws/`
```text
api/
├── plan.py # 当前组、策略启停、紧急全平
├── trades.py # 组列表、组成交明细
├── stats.py # 汇总统计
└── settings.py # 读写配置(脱敏)
ws/
└── push.py # market.snapshot / group.updated / fill.created / strategy.state
```
### 2.6 `backend/app/models/` — 数据
建议表:
| 表 | 用途 |
|----|------|
| `groups` | 组头:方向、开平时间、初始权利金、平仓原因、盈亏 |
| `fills` | 成交:腿、价、量、滑点、手续费 |
| `positions` | 当前仓(最多一组两腿) |
| `ledger_entries` | 资金流水 |
| `settings` | 配置 KV |
| `market_ticks` | 可选行情快照 |
---
## 3. 前端结构
```text
frontend/src/pages/
├── Plan.tsx # 自动对冲计划(主盘)
├── Trades.tsx # 交易记录(按组)
├── Stats.tsx # 统计
└── Settings.tsx # 系统设置
frontend/src/components/
├── Nav.tsx # 四项导航
├── GroupBadge.tsx # 组 ID + 状态色
├── PerpOrderBook.tsx # 永续盘口
├── OptionOrderBook.tsx # Call/Put;卖一选向高亮、买一平仓
├── PositionBar.tsx # 1 ETH + 2 ETH、浮盈、触发进度
└── EquityChart.tsx # 统计页曲线
```
导航文案固定:
1. 自动对冲计划
2. 交易记录
3. 统计
4. 系统设置
---
## 4. 部署相关文件
### 4.1 `deploy/ecosystem.config.cjs`(示意)
```js
module.exports = {
apps: [
{
name: 'eth-hedge-api',
cwd: '/opt/eth_hedge_sim/backend',
script: 'uvicorn',
args: 'app.main:app --host 0.0.0.0 --port 8100',
interpreter: 'python3',
env: { MODE: 'SIM' },
},
// 若前端独立静态服务可再加 eth-hedge-web;也可由 nginx 指到 frontend/dist
],
};
```
### 4.2 `deploy/pull_and_restart.sh`(原则)
-`cd /opt/eth_hedge_sim && git pull`
- 安装依赖 / `frontend` build
- `pm2 startOrReload deploy/ecosystem.config.cjs --update-env`
- **禁止** `pm2 restart all`**禁止** 调用现网 `crypto_monitor` 的部署脚本
---
## 5. 进程与端口(建议,可改)
| 服务 | 端口 | PM2 名 |
|------|------|--------|
| API + WS | `8100` | `eth-hedge-api` |
| 前端(若独立) | `8101` 或 nginx 反代 | `eth-hedge-web` |
与现网端口、进程名全部错开。
---
## 6. 配置与密钥
```text
.env.example # 提交到 git
.env # 仅服务器 / 本机,gitignore
```
关键项:
- `MODE=SIM|LIVE`
- `OKX_API_KEY / SECRET / PASSPHRASE`SIM 阶段只读权限即可)
- `FEE_RATE`(滑点自动 = 1 × FEE_RATE
- `INITIAL_EQUITY`
- `MAX_ROUNDS=3`
- `OPEN_HHMM=16:00` / `STOP_OPEN_HHMM=08:00`
- `TZ=Asia/Shanghai`
---
## 7. 从现网复制代码时的规则
| 允许 | 禁止 |
|------|------|
| 复制 OKX 行情订阅、签名、深度解析到本仓库 | 修改 `C:\Users\dekun\Desktop\crypto_monitor` 下任何文件 |
| 复制「买一流动性检查」思路后重写 | 把本项目塞进现网 monorepo 一起 PM2 |
| 新建本仓库的 `.env` | 默认使用现网交易 Key 做模拟(模拟不需要交易权限) |
复制后在本仓库内自由修改;现网保持原样。
---
## 8. 建议落地顺序(与开发方案分期对应)
1. 建 git 远程 → clone 到本机该目录与服务器 `/opt/eth_hedge_sim`
2. P0`market/` 通行情
3. P1`sim/` 手动开平
4. P2`strategy/` 自动化
5. P3`frontend/` 四页
6. `deploy/` PM2 上独立机
7. P5`live/` 实盘开关
---
## 9. 当前仓库已有文件
```text
eth_hedge_sim/
└── docs/
├── 开发方案.md
└── 代码结构.md
```
负责人创建远程仓库后,将本目录作为首批提交即可;业务代码按上表目录逐步添加。
+221
View File
@@ -0,0 +1,221 @@
# eth_hedge_sim — 自动对冲模拟盘开发方案
> 独立项目,与现网 `crypto_monitor` / `zk.hyf2.cc` **无部署、无进程、无密钥共用关系**。
> 可参考现有仓库的实现思路或复制片段到本仓库,但 **禁止修改** `crypto_monitor` 内任何文件。
> Git 仓库由负责人自行创建;本目录为本地工程骨架与文档。
---
## 1. 项目定位
| 项 | 约定 |
|----|------|
| 项目名 / 目录名 | `eth_hedge_sim` |
| 本机路径 | `C:\Users\dekun\Desktop\新建文件夹\eth_hedge_sim` |
| 云服务器 | **单独一台 Ubuntu**(或同机不同目录/不同 PM2 进程名,且与现网隔离) |
| 部署 | PM2 |
| 行情 | OKX **实盘只读** APIREST + WebSocket |
| 成交(默认) | **本地模拟撮合 + 本地虚拟资金**(非 OKX 模拟盘) |
| 后期 | 支持切换 **实盘下单**(显式开关 + 二次确认) |
### 1.1 硬边界
- 模拟阶段:**零交易类 API**(不下单、不撤单、不改单)。
- 永续:全部 **市价**(不做限价)。
- 期权:只吃 **买一 / 卖一**
- 仓位固定:永续 **1 ETH**,期权 **2 ETH** 名义(始终 2 倍,与权利金金额无关)。
- 卖一比对 **仅用于选方向**;平仓用买一,并检查买一流动性。
---
## 2. 业务规则
### 2.1 时间与次数
- 期权合约:选 **次日 16:00** 到期。
- 可开仓窗:业务日 **D 日 16:00** 起 → **D+1 日 08:00** 前。
- **D+1 08:00 起禁止新开仓**(已有持仓仍按平仓规则处理,不强制到点清仓——若改规则在设置中可配)。
- 每个业务窗最多 **3 轮**;同时最多 **1 组**仓;平完才能开下一组。
### 2.2 方向(Call 卖一 vs Put 卖一)
| 条件 | 永续 | 期权 |
|------|------|------|
| Call 卖一 > Put 卖一 | 市价做多 1 ETH | 做空 2 ETH 名义 |
| Call 卖一 < Put 卖一 | 市价做空 1 ETH | 做多 2 ETH 名义 |
| 相等 | 不开仓,等待 | — |
> **实现前待定稿**:期权「做多/做空」具体买卖 Call 还是 Put(或组合);行权价选择(建议默认 ATM / 最接近标记价的同一行权价)。
### 2.3 平仓(任一触发 → 该组全平)
1. **权利金覆盖**:永续浮盈 ≥ 该组开仓锁定的 **期权初始权利金总额**(建议触发口径 **不含手续费**;费用单独记账)。此时期权侧通常仍有盈余/剩余价值,属预期内。
2. **方向 30 点**:期权方向运行满 30 点 → 全平(**待定**:标的 ETH 点数 vs 权利金点数)。
平仓执行:
- 永续:本地市价平仓。
- 期权:吃买一;买一深度需覆盖 2 ETH 名义;不足则不成交并记「流动性不足」,默认继续等待。
### 2.4 组(Group)标识
每一轮开→平为一组,稳定 ID 例如:`G-YYYYMMDD-序号``G-20260724-01`)。
前端、交易记录、统计全部按组聚合。
---
## 3. 本地撮合与费用
### 3.1 手续费与滑点
- 永续、期权 **均收取手续费**,费率可配置,默认按 OKX taker 档位。
- **滑点 = 1 倍手续费**(费率 `f` 时,滑点幅度按 `f` 计入成交价;手续费另扣)。
### 3.2 永续(仅市价)
本地市价定义(无真实交易所市价单时):
- 开多 / 平空:基准 **卖一**,成交价 = 基准 × `(1 + f)`,再扣手续费。
- 开空 / 平多:基准 **买一**,成交价 = 基准 × `(1 - f)`,再扣手续费。
- UI 与引擎均不提供限价单。
### 3.3 期权(只吃买卖一)
- 买入:吃 **卖一**,成交价 = 卖一 × `(1 + f)`,再扣费。
- 卖出:吃 **买一**,成交价 = 买一 × `(1 - f)`,再扣费。
- 开仓成交后锁定该组 **初始权利金总额**(按成交价 × 2 ETH 名义)。
### 3.4 本地账本
虚拟权益、可用、占用、持仓、成交、滑点、手续费、按组盈亏;持久化 SQLite(或等价本地 DB)。
**不是** OKX 模拟盘余额。
---
## 4. 系统架构
```
OKX 实盘只读行情 (WS/REST)
market 行情网关(本地缓存/可选落盘)
strategy 策略状态机(时间窗 / 选向 / 开平 / 3 次 / 组 ID)
executor
├─ SIM(默认)→ LocalMatcher + Ledger
└─ LIVE(后期)→ OkxTradeAdapter
API + WebSocket 推送
FrontendOKX 风格四页)
```
### 4.1 与现网隔离
| | crypto_monitor(现网) | eth_hedge_sim(本项目) |
|--|----------------------|------------------------|
| 代码仓 | 独立 | 独立(负责人自建 remote) |
| 服务器目录 | 如 `/opt/crypto_monitor` | 如 `/opt/eth_hedge_sim`(另定) |
| PM2 进程名 | 现有一套 | **新名字**,如 `eth-hedge-api` / `eth-hedge-web` |
| 域名/端口 | 现网 | 独立端口或独立域名 |
| 密钥 | 现网 `.env` | 本项目独立 `.env`(先只读行情 Key |
允许:从现网 **复制** 盘口解析、OKX WS 订阅、流动性检查等代码到本仓库后改。
禁止:在现网仓库里改文件、共用 PM2 restart 脚本、共用生产 Key(除非只读 Key 故意共用且你知情)。
---
## 5. 前端
视觉:复刻 OKX 交易台观感(深色、盘口、持仓、紧凑数字)。
### 导航(4 项)
1. **自动对冲计划**
模式 SIM/LIVE、时间窗状态、当前组 ID、方向、1+2 仓位、初始权利金、永续浮盈、距触发差值、30 点进度、永续/期权盘口、启动暂停、紧急全平(模拟)。
2. **交易记录**
按组列表与组内成交明细;筛选日期、平仓原因、盈亏。
3. **统计**
组数、胜率、总盈亏、总手续费、总滑点、平仓原因分布、按组权益曲线。
4. **系统设置**
虚拟资金、费率 `f`(滑点自动 1×f)、轮次与时间、30 点定义、选约规则、OKX Key、SIM/LIVE 开关(LIVE 二次确认)。
每组必须有明确标识(组 ID + 状态标签:持仓中 / 已平 / 流动性等待等)。
---
## 6. 部署(Ubuntu + PM2
### 6.1 建议目录
```text
/opt/eth_hedge_sim # git clone 目标
.env # 仅本机/本项目,不进 git
deploy/
ecosystem.config.cjs # PM2 配置
pull_and_restart.sh # 仅重启本项目进程
```
### 6.2 PM2 进程(示例名,可改)
| 进程名 | 作用 |
|--------|------|
| `eth-hedge-api` | 行情网关 + 策略 + 本地撮合 + HTTP/WS API |
| `eth-hedge-web` | 前端静态或 Node 服务(若前后端合一可合并为一个进程) |
原则:**不要** `pm2 restart all` 误伤现网;脚本里写死本项目进程名。
### 6.3 发布流程(负责人自建仓库后)
```bash
cd /opt/eth_hedge_sim
git pull
# 安装依赖 / 构建前端(按实际栈)
pm2 startOrReload deploy/ecosystem.config.cjs --update-env
pm2 save
```
### 6.4 环境变量(示例)
```bash
MODE=SIM
OKX_API_KEY=... # 模拟阶段只需能拉行情的权限
OKX_API_SECRET=...
OKX_API_PASSPHRASE=...
FEE_RATE=0.0005 # 示例;滑点 = 1 × FEE_RATE
INITIAL_EQUITY=100000
TZ=Asia/Shanghai
```
---
## 7. 分期实施
| 阶段 | 内容 | 完成标准 |
|------|------|----------|
| P0 | 仓库骨架、依赖、OKX 只读行情(永续 + 次日期权盘口) | 能稳定收到真盘口 |
| P1 | 本地账本 + 撮合(永续市价、期权吃一、费+1×费滑点) | 手动开平一组账目正确 |
| P2 | 策略状态机(选向、两套平仓、3 次、08:00 停开、组 ID) | 无 UI 也能跑完业务窗 |
| P3 | 四页前端 | 可完整操作与复盘 |
| P4 | 行情落盘与回放 | 重放结果与当时一致 |
| P5 | 实盘适配器 | 显式开关下最小仓验证 |
---
## 8. 实现前待拍板
1. 期权多空的具体合约腿(Call / Put)。
2. 「30 点」定义(标的 vs 权利金)。
3. 行权价选择规则。
4. 初始权利金触发是否不含手续费(建议不含)。
5. Call 卖一 = Put 卖一时:跳过等待(建议)。
6. 云服务器路径、域名/端口、PM2 进程最终命名。
---
## 9. 一句话
**独立仓 `eth_hedge_sim`OKX 真行情只读 + 本地虚拟资金撮合(永续市价、期权只吃买卖一、滑点=1×手续费)+ OKX 风四页前端 + Ubuntu/PM2 单独部署;可抄现网思路,但不改现网代码、不共用现网进程。**
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>eth_hedge_sim</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1920
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "eth-hedge-sim-web",
"private": true,
"version": "0.2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.7.2",
"vite": "^6.0.0"
}
}
+91
View File
@@ -0,0 +1,91 @@
import type { ReactNode } from "react";
import { NavLink, Navigate, Route, Routes } from "react-router-dom";
import { clearSession, getToken, getUsername } from "./api/client";
import LoginPage from "./pages/Login";
import PlanPage from "./pages/Plan";
import TradesPage from "./pages/Trades";
import StatsPage from "./pages/Stats";
import SettingsPage from "./pages/Settings";
function Shell({ children }: { children: ReactNode }) {
const user = getUsername();
return (
<div className="app-shell">
<nav className="topnav">
<div className="brand">eth_hedge_sim</div>
<NavLink to="/plan" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<NavLink to="/trades" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<NavLink to="/settings" className={({ isActive }) => (isActive ? "active" : "")}>
</NavLink>
<div className="spacer" />
<span className="meta mono">{user}</span>
<button
className="btn ghost"
type="button"
onClick={() => {
clearSession();
window.location.href = "/login";
}}
>
退
</button>
</nav>
<main className="page">{children}</main>
</div>
);
}
function RequireAuth({ children }: { children: ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
return <Shell>{children}</Shell>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/plan"
element={
<RequireAuth>
<PlanPage />
</RequireAuth>
}
/>
<Route
path="/trades"
element={
<RequireAuth>
<TradesPage />
</RequireAuth>
}
/>
<Route
path="/stats"
element={
<RequireAuth>
<StatsPage />
</RequireAuth>
}
/>
<Route
path="/settings"
element={
<RequireAuth>
<SettingsPage />
</RequireAuth>
}
/>
<Route path="/" element={<Navigate to="/plan" replace />} />
<Route path="*" element={<Navigate to="/plan" replace />} />
</Routes>
);
}
+110
View File
@@ -0,0 +1,110 @@
const API_KEY = "eth_hedge_api_base";
const TOKEN_KEY = "eth_hedge_token";
const USER_KEY = "eth_hedge_user";
export function getApiBase(): string {
const saved = localStorage.getItem(API_KEY);
if (saved && saved.trim()) return saved.trim().replace(/\/$/, "");
// same-origin default when UI is served by backend on :5155
return window.location.origin;
}
export function setApiBase(url: string) {
localStorage.setItem(API_KEY, url.trim().replace(/\/$/, ""));
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setSession(token: string, username: string) {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, username);
}
export function clearSession() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export function getUsername(): string | null {
return localStorage.getItem(USER_KEY);
}
export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const base = getApiBase();
const headers = new Headers(options.headers || {});
if (!headers.has("Content-Type") && options.body) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch(`${base}${path}`, { ...options, headers });
if (res.status === 401) {
clearSession();
throw new Error("unauthorized");
}
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { detail: text };
}
if (!res.ok) {
const detail =
typeof data === "object" && data && "detail" in data
? String((data as { detail: unknown }).detail)
: res.statusText;
throw new Error(detail || `HTTP ${res.status}`);
}
return data as T;
}
export type LoginResult = {
token: string;
username: string;
expires_in: number;
env_name: string;
mode: string;
};
export async function login(username: string, password: string) {
return apiFetch<LoginResult>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
export type MarketSnapshot = {
connected: boolean;
updated_at_ms: number | null;
index_px: number | null;
pair: {
expiry_ymd: string;
strike: number;
call_inst_id: string;
put_inst_id: string;
} | null;
perp: Quote | null;
call: Quote | null;
put: Quote | null;
ask_compare: {
call_ask: number | null;
put_ask: number | null;
bias: string;
};
};
type Quote = {
inst_id: string;
bid: number | null;
ask: number | null;
bid_sz: number | null;
ask_sz: number | null;
mark_px: number | null;
};
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/app.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+79
View File
@@ -0,0 +1,79 @@
import { FormEvent, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getApiBase, login, setApiBase, setSession } from "../api/client";
export default function LoginPage() {
const nav = useNavigate();
const [apiBase, setApi] = useState(getApiBase());
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const hint = useMemo(
() => "默认同域 API。跨机访问时填写如 http://47.236.184.99:5155",
[],
);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setErr("");
setLoading(true);
try {
setApiBase(apiBase);
const res = await login(username.trim(), password);
setSession(res.token, res.username);
nav("/plan", { replace: true });
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
<h1>eth_hedge_sim</h1>
<p> · API </p>
{err ? <div className="err">{err}</div> : null}
<div className="field">
<label htmlFor="api">API </label>
<input
id="api"
className="mono"
value={apiBase}
onChange={(e) => setApi(e.target.value)}
placeholder="http://host:5155"
autoComplete="url"
/>
</div>
<div className="field">
<label htmlFor="user"></label>
<input
id="user"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</div>
<div className="field">
<label htmlFor="pass"></label>
<input
id="pass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<button className="btn block" type="submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
<div className="hint">{hint}</div>
</form>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from "react";
import { apiFetch, MarketSnapshot } from "../api/client";
function fmt(n: number | null | undefined, d = 2) {
if (n == null || Number.isNaN(n)) return "—";
return n.toFixed(d);
}
export default function PlanPage() {
const [snap, setSnap] = useState<MarketSnapshot | null>(null);
const [err, setErr] = useState("");
useEffect(() => {
let alive = true;
const load = async () => {
try {
const data = await apiFetch<MarketSnapshot>("/api/market/snapshot");
if (alive) {
setSnap(data);
setErr("");
}
} catch (e) {
if (alive) setErr(e instanceof Error ? e.message : String(e));
}
};
load();
const t = window.setInterval(load, 2000);
return () => {
alive = false;
window.clearInterval(t);
};
}, []);
const bias = snap?.ask_compare?.bias;
const biasTag =
bias === "call_ask_gt_put" ? (
<span className="tag up">Call卖一 &gt; Put卖一 +</span>
) : bias === "put_ask_gt_call" ? (
<span className="tag down">Put卖一 &gt; Call卖一 +</span>
) : (
<span className="tag"> / </span>
);
return (
<div>
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)", marginTop: -8 }}>
P0 ·
</p>
{err ? <div className="err">{err}</div> : null}
<div className="card" style={{ marginBottom: 12 }}>
<div className="kv">
<span></span>
<span className="mono">SIM · </span>
</div>
<div className="kv">
<span></span>
<span className="mono">{snap?.connected ? "WS 已连接" : "REST/未连"}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.index_px)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">
{snap?.pair
? `${snap.pair.expiry_ymd} @ ${snap.pair.strike}`
: "—"}
</span>
</div>
<div className="kv">
<span></span>
{biasTag}
</div>
</div>
<div className="grid-2">
<div className="card">
<h3 style={{ marginTop: 0 }}> ETH-USDT-SWAP</h3>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.bid)} × {fmt(snap?.perp?.bid_sz, 2)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.ask)} × {fmt(snap?.perp?.ask_sz, 2)}</span>
</div>
<div className="kv">
<span></span>
<span className="mono">{fmt(snap?.perp?.mark_px)}</span>
</div>
</div>
<div className="card">
<h3 style={{ marginTop: 0 }}> ATM</h3>
<div className="kv">
<span>Call </span>
<span className="mono">{fmt(snap?.call?.ask)} / {fmt(snap?.call?.bid)}</span>
</div>
<div className="kv">
<span>Put </span>
<span className="mono">{fmt(snap?.put?.ask)} / {fmt(snap?.put?.bid)}</span>
</div>
<div className="kv">
<span>Call</span>
<span className="mono" style={{ fontSize: 12 }}>
{snap?.pair?.call_inst_id || "—"}
</span>
</div>
<div className="kv">
<span>Put</span>
<span className="mono" style={{ fontSize: 12 }}>
{snap?.pair?.put_inst_id || "—"}
</span>
</div>
</div>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { FormEvent, useState } from "react";
import { getApiBase, setApiBase } from "../api/client";
export default function SettingsPage() {
const [apiBase, setApi] = useState(getApiBase());
const [saved, setSaved] = useState(false);
function onSave(e: FormEvent) {
e.preventDefault();
setApiBase(apiBase);
setSaved(true);
window.setTimeout(() => setSaved(false), 1500);
}
return (
<div className="card" style={{ maxWidth: 560 }}>
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)" }}>
API 5155
</p>
<form onSubmit={onSave}>
<div className="field">
<label htmlFor="api">API </label>
<input
id="api"
className="mono"
value={apiBase}
onChange={(e) => setApi(e.target.value)}
/>
</div>
<button className="btn" type="submit">
</button>
{saved ? <span style={{ marginLeft: 10, color: "var(--up)" }}></span> : null}
</form>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function StatsPage() {
return (
<div className="card">
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)" }}> / / 线</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function TradesPage() {
return (
<div className="card">
<h2 style={{ marginTop: 0 }}></h2>
<p style={{ color: "var(--muted)" }}> P1/P2 </p>
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
:root {
--bg: #0b0e11;
--bg-elev: #12161c;
--bg-panel: #151a21;
--line: #1e2630;
--text: #eaecef;
--muted: #848e9c;
--accent: #f0b90b;
--up: #0ecb81;
--down: #f6465d;
--input: #0f141a;
--danger: #f6465d;
font-family: "IBM Plex Sans", sans-serif;
color: var(--text);
background: var(--bg);
}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
background:
radial-gradient(1200px 600px at 10% -10%, rgba(240, 185, 11, 0.08), transparent 55%),
radial-gradient(900px 500px at 100% 0%, rgba(14, 203, 129, 0.05), transparent 50%),
var(--bg);
}
button,
input {
font: inherit;
}
.mono {
font-family: "IBM Plex Mono", monospace;
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
}
.topnav {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
background: rgba(11, 14, 17, 0.92);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
margin-right: 12px;
color: var(--accent);
}
.topnav a {
color: var(--muted);
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
}
.topnav a.active {
color: var(--text);
background: var(--bg-panel);
}
.topnav .spacer {
flex: 1;
}
.topnav .meta {
color: var(--muted);
font-size: 12px;
margin-right: 8px;
}
.page {
padding: 16px;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.card {
background: var(--bg-panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 16px;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 800px) {
.grid-2 {
grid-template-columns: 1fr;
}
}
.kv {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
font-size: 14px;
}
.kv:last-child {
border-bottom: 0;
}
.kv span:first-child {
color: var(--muted);
}
.login-wrap {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.login-box {
width: min(420px, 100%);
background: var(--bg-elev);
border: 1px solid var(--line);
border-radius: 14px;
padding: 28px 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.login-box h1 {
margin: 0 0 6px;
font-size: 22px;
}
.login-box p {
margin: 0 0 20px;
color: var(--muted);
font-size: 13px;
}
.field {
display: grid;
gap: 6px;
margin-bottom: 14px;
}
.field label {
font-size: 12px;
color: var(--muted);
}
.field input {
background: var(--input);
border: 1px solid var(--line);
color: var(--text);
border-radius: 8px;
padding: 10px 12px;
outline: none;
}
.field input:focus {
border-color: rgba(240, 185, 11, 0.55);
}
.btn {
border: 0;
border-radius: 8px;
padding: 10px 14px;
cursor: pointer;
background: var(--accent);
color: #111;
font-weight: 600;
}
.btn.ghost {
background: transparent;
color: var(--muted);
border: 1px solid var(--line);
}
.btn.block {
width: 100%;
}
.err {
color: var(--danger);
font-size: 13px;
margin: 0 0 12px;
}
.hint {
margin-top: 12px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
border: 1px solid var(--line);
color: var(--muted);
}
.tag.up {
color: var(--up);
border-color: rgba(14, 203, 129, 0.35);
}
.tag.down {
color: var(--down);
border-color: rgba(246, 70, 93, 0.35);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:5155",
"/health": "http://127.0.0.1:5155",
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});
+10
View File
@@ -0,0 +1,10 @@
fastapi>=0.115.0
uvicorn[standard]>=0.32.0
httpx>=0.27.0
websockets>=13.0
python-socks[asyncio]>=2.5.0
pydantic>=2.9.0
pydantic-settings>=2.5.0
python-dotenv>=1.0.1
tzdata>=2024.1
pytest>=8.3.0
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""从本机 SSH 触发服务器一键更新(服务器内 git pull,不 scp 代码)。
环境变量可选:
DEPLOY_HOST 默认 47.236.184.99
DEPLOY_USER 默认 root
DEPLOY_PASS 服务器密码
DEPLOY_ROOT 默认 /opt/eth_hedge_sim
REPO_URL 默认 https://git.bz121.com/dekun/eth_hedge_sim.git
"""
from __future__ import annotations
import os
import sys
HOST = os.environ.get("DEPLOY_HOST", "47.236.184.99")
USER = os.environ.get("DEPLOY_USER", "root")
PASSWORD = os.environ.get("DEPLOY_PASS", "")
ROOT = os.environ.get("DEPLOY_ROOT", "/opt/eth_hedge_sim")
REPO = os.environ.get("REPO_URL", "https://git.bz121.com/dekun/eth_hedge_sim.git")
def main() -> int:
if not PASSWORD:
print("Set DEPLOY_PASS env var (do not commit password).", file=sys.stderr)
return 2
try:
import paramiko
except ImportError:
print("pip install paramiko", file=sys.stderr)
return 2
remote = f"""
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if ! command -v git >/dev/null; then apt-get update -y && apt-get install -y git; fi
if ! command -v python3 >/dev/null; then apt-get update -y && apt-get install -y python3 python3-venv python3-pip; fi
if ! command -v node >/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
fi
if [[ ! -d '{ROOT}/.git' ]]; then
mkdir -p /opt
git clone '{REPO}' '{ROOT}'
fi
bash '{ROOT}/deploy/pull_and_restart.sh'
curl -fsS http://127.0.0.1:5155/health || true
"""
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
print(f"connecting {USER}@{HOST} ...")
client.connect(HOST, username=USER, password=PASSWORD, timeout=30)
print("running remote bootstrap/pull ...")
stdin, stdout, stderr = client.exec_command(remote, get_pty=True)
for line in iter(stdout.readline, ""):
print(line, end="")
err = stderr.read().decode("utf-8", errors="ignore")
code = stdout.channel.recv_exit_status()
if err:
print(err, file=sys.stderr)
client.close()
print(f"exit={code}")
return code
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""P0 行情连通性烟测:REST 对齐次日 ATM Call/Put,可选挂 WS 数秒。
用法仓库根目录:
python scripts/smoke_market.py
python scripts/smoke_market.py --ws-seconds 8
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BACKEND = ROOT / "backend"
sys.path.insert(0, str(BACKEND))
from app.config import get_settings # noqa: E402
from app.market import MarketGateway # noqa: E402
async def main() -> int:
parser = argparse.ArgumentParser(description="eth_hedge_sim P0 market smoke")
parser.add_argument("--ws-seconds", type=float, default=5.0, help="WS listen seconds")
args = parser.parse_args()
settings = get_settings()
print(f"MODE={settings.mode} REST={settings.okx_rest_base}")
print(f"perp={settings.perp_inst_id} family={settings.option_inst_family}")
gw = MarketGateway(settings)
t0 = time.time()
try:
pair = await asyncio.to_thread(gw.align_instruments)
except Exception as e:
print(f"FAIL align: {e}")
gw.rest.close()
return 1
print(f"aligned in {time.time() - t0:.2f}s")
print(json.dumps(pair.to_dict() if pair else None, ensure_ascii=False, indent=2))
snap = gw.snapshot_dict()
print("--- REST snapshot ---")
print(json.dumps(snap, ensure_ascii=False, indent=2))
if args.ws_seconds > 0:
print(f"--- WS listen {args.ws_seconds}s ---")
await gw.ws.start()
await asyncio.sleep(args.ws_seconds)
snap2 = gw.snapshot_dict()
print(json.dumps(snap2, ensure_ascii=False, indent=2))
await gw.ws.stop()
gw.rest.close()
ok = (
snap.get("perp", {}) or {}
).get("ask") is not None and (
snap.get("call", {}) or {}
).get("ask") is not None and (
snap.get("put", {}) or {}
).get("ask") is not None
if not ok:
print("FAIL: missing bid/ask on perp/call/put")
return 2
print("OK: market smoke passed")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))