Initial eth_hedge_sim: P0 market, auth UI, one-click deploy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""eth_hedge_sim backend package."""
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder: live OKX trade adapter (P5). Default off.
|
||||||
@@ -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)
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"],
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder: DB models (P1).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder: application services (P1+).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder packages for later phases (P1–P5).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder: strategy state machine (P2).
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Placeholder: frontend push WS (P3).
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -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(勿写进本文件)
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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
@@ -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**:后端 Python(FastAPI)+ 前端 React/Vite(与现网 Python 生态接近,便于复制行情代码)。
|
||||||
|
- **推荐 B**:全 Node(Nest/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:00~D+1 08:00;次数≤3
|
||||||
|
├── signal.py # Call 卖一 vs Put 卖一 → 方向
|
||||||
|
├── exits.py # 权利金覆盖 / 30 点
|
||||||
|
├── sizing.py # 永续 1 ETH、期权 2 ETH(写死)
|
||||||
|
├── group.py # 组 ID:G-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
@@ -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 **实盘只读** API(REST + 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 推送
|
||||||
|
↓
|
||||||
|
Frontend(OKX 风格四页)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 单独部署;可抄现网思路,但不改现网代码、不共用现网进程。**
|
||||||
@@ -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>
|
||||||
Generated
+1920
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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卖一 > Put卖一 → 永续多+期权空</span>
|
||||||
|
) : bias === "put_ask_gt_call" ? (
|
||||||
|
<span className="tag down">Put卖一 > 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default function StatsPage() {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2 style={{ marginTop: 0 }}>统计</h2>
|
||||||
|
<p style={{ color: "var(--muted)" }}>胜率 / 盈亏 / 手续费曲线将在有成交后展示。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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()))
|
||||||
Reference in New Issue
Block a user