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