first commit
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
# 比特骆驼行情采集分析 — 环境变量示例
|
||||||
|
# 复制为 .env 后按需修改。一键部署时:已有非空值不覆盖。
|
||||||
|
|
||||||
|
# ---- 服务 ----
|
||||||
|
MI_PORT=5170
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
AUTH_SECRET=change-me
|
||||||
|
ADMIN_PASSWORD=admin123
|
||||||
|
|
||||||
|
# ---- 采集(OKX 只读;公开行情可留空 Key)----
|
||||||
|
OKX_API_KEY=
|
||||||
|
OKX_API_SECRET=
|
||||||
|
OKX_API_PASSPHRASE=
|
||||||
|
OKX_BASE_URL=https://www.okx.com
|
||||||
|
OKX_PROXY=
|
||||||
|
INDEX_INST_ID=ETH-USD
|
||||||
|
OPTION_INST_FAMILY=ETH-USD_UM
|
||||||
|
UNDERLYING=ETH
|
||||||
|
SAMPLE_INTERVAL_SEC=30
|
||||||
|
INDEX_SAMPLE_INTERVAL_SEC=60
|
||||||
|
INSTRUMENTS_REFRESH_SEC=300
|
||||||
|
MIN_OPTION_HOURS=12
|
||||||
|
MIN_OPTION_LEVERAGE=100
|
||||||
|
|
||||||
|
# ---- 数据库 ----
|
||||||
|
# 容器内默认 /app/data/market_intel.db(volume 持久化)
|
||||||
|
MI_DB_PATH=/app/data/market_intel.db
|
||||||
|
|
||||||
|
# ---- 统计默认 ----
|
||||||
|
SETTLE_BACKFILL_INTERVAL_SEC=300
|
||||||
|
BUCKET_MINUTES=60
|
||||||
|
MONTH_RANGE_MODE=rolling_30
|
||||||
|
|
||||||
|
# ---- 企微告警(可选)----
|
||||||
|
WECOM_ENABLED=0
|
||||||
|
WECOM_WEBHOOK_URL=
|
||||||
|
WECOM_MACHINE_NAME=
|
||||||
|
ALERT_FAIL_THRESHOLD=5
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Env & secrets
|
||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/*.db
|
||||||
|
data/*.db-*
|
||||||
|
data/*.sqlite
|
||||||
|
data/*.sqlite-*
|
||||||
|
!data/.gitkeep
|
||||||
|
|
||||||
|
# Node / frontend
|
||||||
|
web/node_modules/
|
||||||
|
web/.vite/
|
||||||
|
# 保留 web/dist 静态看板以便无 npm 时也能 Docker 启动;Vite build 可覆盖
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.tar
|
||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app \
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends curl ca-certificates tzdata \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY apps ./apps
|
||||||
|
COPY packages ./packages
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY web/dist ./web/dist
|
||||||
|
COPY data/.gitkeep ./data/.gitkeep
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data /app/logs
|
||||||
|
|
||||||
|
EXPOSE 5170
|
||||||
|
|
||||||
|
# 默认 API;Compose 里 collector 覆盖 command
|
||||||
|
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "5170"]
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 比特骆驼行情采集分析(market_intel)
|
||||||
|
|
||||||
|
独立仓库:**只做行情采集 → 落库 → 分析统计 → 只读展示 / API**。
|
||||||
|
与 `eth_hedge_sim`(策略 / 中控)无代码共用、无进程共用、无交易密钥共用。
|
||||||
|
|
||||||
|
- 产品名:比特骆驼行情采集分析
|
||||||
|
- 安装目录:`/opt/market_intel`
|
||||||
|
- 默认端口:`5170`
|
||||||
|
- 时区:`Asia/Shanghai`
|
||||||
|
- 第一期:OKX ETH 只读行情
|
||||||
|
|
||||||
|
详细实现标准见 [开发方案.md](./开发方案.md)。
|
||||||
|
|
||||||
|
## 快速开始(Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d --build
|
||||||
|
curl -fsS http://127.0.0.1:5170/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 一键部署(Ubuntu 22.04)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.bz121.com/dekun/market_intel/raw/branch/main/deploy/manage.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
已安装:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/market_intel/deploy/manage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv
|
||||||
|
# Windows: .venv\Scripts\activate
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
# 本机 SQLite 路径
|
||||||
|
# MI_DB_PATH=./data/market_intel.db
|
||||||
|
|
||||||
|
set PYTHONPATH=.
|
||||||
|
python -m apps.collector.main # 终端 1
|
||||||
|
uvicorn apps.api.main:app --reload --port 5170 # 终端 2
|
||||||
|
```
|
||||||
|
|
||||||
|
前端(可选):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web && npm i && npm run build
|
||||||
|
# 产物挂到 API 静态目录;开发可用 npm run dev(代理到 5170)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心口径
|
||||||
|
|
||||||
|
1. **期权杠杆** = 标的指数 ÷ 期权卖一(`ask`)
|
||||||
|
2. **波动点数** = 到期指数 − 时段代表指数(未到期标记 `pending_expiry=true`)
|
||||||
|
3. 统计按上海自然日切分;桶默认 1 小时
|
||||||
|
|
||||||
|
## API(第一期)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/health` | 存活 + 采集延迟(公开) |
|
||||||
|
| GET | `/api/auth/status` | 是否需要登录 |
|
||||||
|
| POST | `/api/auth/login` | 密码换 token(Cookie + JSON) |
|
||||||
|
| GET | `/api/meta/latest` | 最新 Call/Put 杠杆(需鉴权) |
|
||||||
|
| GET | `/api/stats/leverage` | 日/周/月时段杠杆聚合 |
|
||||||
|
| GET | `/api/stats/move_points` | 时段→到期波动(signed/abs;未到期 pending) |
|
||||||
|
| GET | `/api/stats/ops-map` | 作战地图主接口(杠杆 + 波动) |
|
||||||
|
| GET | `/api/notify/wecom/status` | 企微配置状态 |
|
||||||
|
| POST | `/api/notify/wecom/test` | 企微测试推送 |
|
||||||
|
|
||||||
|
鉴权:`AUTH_SECRET=disabled` 关闭;否则 `Authorization: Bearer <token>` / Cookie `mi_token`。
|
||||||
|
企微:`WECOM_ENABLED=1` + `WECOM_WEBHOOK_URL`;连续失败 ≥ `ALERT_FAIL_THRESHOLD`(默认 5)推送。
|
||||||
|
|
||||||
|
## 分期
|
||||||
|
|
||||||
|
| 阶段 | 交付 |
|
||||||
|
|------|------|
|
||||||
|
| P0 | 骨架、Compose、manage.sh、健康检查 |
|
||||||
|
| P1 | OKX 指数 + ATM Call/Put 采样落库 |
|
||||||
|
| P2 | 杠杆日周月统计 + Web 图 |
|
||||||
|
| P3 | 到期回填 + 波动点数 |
|
||||||
|
| P4 | 鉴权加固、企微告警 |
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
内部项目。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Collector package
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# API package
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""简单 Token 鉴权(对齐策略仓:密码换 HMAC token)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from packages.config import get_settings
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
COOKIE_NAME = "mi_token"
|
||||||
|
|
||||||
|
|
||||||
|
def auth_disabled() -> bool:
|
||||||
|
s = get_settings()
|
||||||
|
return (s.auth_secret or "").strip().lower() in ("", "disabled", "off", "none")
|
||||||
|
|
||||||
|
|
||||||
|
def _token_for_password(password: str, secret: str) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
secret.encode("utf-8"),
|
||||||
|
password.encode("utf-8"),
|
||||||
|
hashlib.sha256,
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def expected_token() -> str:
|
||||||
|
s = get_settings()
|
||||||
|
return _token_for_password(s.admin_password, s.auth_secret)
|
||||||
|
|
||||||
|
|
||||||
|
def issue_token(password: str) -> str | None:
|
||||||
|
s = get_settings()
|
||||||
|
if not secrets.compare_digest(password, s.admin_password):
|
||||||
|
return None
|
||||||
|
return _token_for_password(password, s.auth_secret)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_token(
|
||||||
|
request: Request,
|
||||||
|
authorization: str | None,
|
||||||
|
x_mi_token: str | None,
|
||||||
|
creds: HTTPAuthorizationCredentials | None,
|
||||||
|
) -> str | None:
|
||||||
|
if creds and creds.credentials:
|
||||||
|
return creds.credentials.strip()
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
return authorization[7:].strip()
|
||||||
|
if x_mi_token:
|
||||||
|
return x_mi_token.strip()
|
||||||
|
# 查询参数兜底(方便内网脚本;生产建议只用 Header)
|
||||||
|
q = request.query_params.get("token")
|
||||||
|
if q:
|
||||||
|
return q.strip()
|
||||||
|
cookie = request.cookies.get(COOKIE_NAME)
|
||||||
|
if cookie:
|
||||||
|
return cookie.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(
|
||||||
|
request: Request,
|
||||||
|
authorization: Annotated[str | None, Header()] = None,
|
||||||
|
x_mi_token: Annotated[str | None, Header(alias="X-MI-Token")] = None,
|
||||||
|
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
AUTH_SECRET=disabled 时跳过。
|
||||||
|
否则需要 Bearer / X-MI-Token / Cookie / ?token=。
|
||||||
|
"""
|
||||||
|
if auth_disabled():
|
||||||
|
return
|
||||||
|
token = _extract_token(request, authorization, x_mi_token, creds)
|
||||||
|
if not token or not secrets.compare_digest(token, expected_token()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="unauthorized",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AuthDep = Depends(require_auth)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""FastAPI 入口:健康检查 + 只读 API + 静态看板。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from apps.api.routes import auth, health, meta, notify, samples, stats
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="比特骆驼行情采集分析",
|
||||||
|
description="market_intel — 只读行情采集与统计",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(auth.router, prefix="/api")
|
||||||
|
app.include_router(meta.router, prefix="/api")
|
||||||
|
app.include_router(samples.router, prefix="/api")
|
||||||
|
app.include_router(stats.router, prefix="/api")
|
||||||
|
app.include_router(notify.router, prefix="/api")
|
||||||
|
|
||||||
|
_WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
|
||||||
|
|
||||||
|
_FALLBACK_HTML = """<!doctype html><html lang="zh-CN"><head>
|
||||||
|
<meta charset="utf-8"/><title>比特骆驼行情采集分析</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui;background:#0f1419;color:#e7ecf1;padding:2rem}
|
||||||
|
a{color:#5b9fd4}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>比特骆驼行情采集分析</h1>
|
||||||
|
<p>API 已就绪。<a href="/health">/health</a> · <a href="/api/meta/latest">/api/meta/latest</a></p>
|
||||||
|
<p>构建前端:<code>cd web && npm i && npm run build</code></p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _index_response() -> Response:
|
||||||
|
index_html = _WEB_DIST / "index.html"
|
||||||
|
if index_html.is_file():
|
||||||
|
return FileResponse(index_html)
|
||||||
|
return HTMLResponse(_FALLBACK_HTML)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index() -> Response:
|
||||||
|
return _index_response()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ops-map")
|
||||||
|
def ops_map_page() -> Response:
|
||||||
|
"""SPA / 静态看板入口(hash 或 React Router)。"""
|
||||||
|
return _index_response()
|
||||||
|
|
||||||
|
|
||||||
|
if _WEB_DIST.is_dir():
|
||||||
|
assets = _WEB_DIST / "assets"
|
||||||
|
if assets.is_dir():
|
||||||
|
app.mount("/assets", StaticFiles(directory=str(assets)), name="assets")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# routes package
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""登录 / 鉴权状态。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Response
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from apps.api.auth import COOKIE_NAME, auth_disabled, issue_token
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
class LoginBody(BaseModel):
|
||||||
|
password: str = Field(min_length=1, max_length=256)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
def auth_status() -> dict:
|
||||||
|
return {
|
||||||
|
"auth_required": not auth_disabled(),
|
||||||
|
"product": "比特骆驼行情采集分析",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(body: LoginBody, response: Response) -> dict:
|
||||||
|
if auth_disabled():
|
||||||
|
return {"ok": True, "auth_required": False, "token": None}
|
||||||
|
token = issue_token(body.password)
|
||||||
|
if not token:
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid password")
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=token,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
max_age=7 * 24 * 3600,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return {"ok": True, "auth_required": True, "token": token}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(response: Response) -> dict:
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.db import Repository
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
repo = Repository(s.db_path)
|
||||||
|
try:
|
||||||
|
hb = repo.get_heartbeat()
|
||||||
|
last_ok = hb.get("last_ok_ts_ms")
|
||||||
|
lag_ms = None
|
||||||
|
if last_ok:
|
||||||
|
import time
|
||||||
|
|
||||||
|
lag_ms = max(0, int(time.time() * 1000) - int(last_ok))
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"service": "market_intel",
|
||||||
|
"product": "比特骆驼行情采集分析",
|
||||||
|
"collector_lag_ms": lag_ms,
|
||||||
|
"consecutive_failures": hb.get("consecutive_failures", 0),
|
||||||
|
"option_quotes": repo.count_option_quotes(),
|
||||||
|
"index_ticks": repo.count_index_ticks(),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from apps.api.auth import require_auth
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.db import Repository
|
||||||
|
from packages.domain import LEVERAGE_FORMULA_VERSION
|
||||||
|
|
||||||
|
router = APIRouter(tags=["meta"], dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meta/latest")
|
||||||
|
def latest() -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
repo = Repository(s.db_path)
|
||||||
|
try:
|
||||||
|
hb = repo.get_heartbeat()
|
||||||
|
by_side = repo.latest_quotes_by_side()
|
||||||
|
return {
|
||||||
|
"formula_version": LEVERAGE_FORMULA_VERSION,
|
||||||
|
"leverage_def": "index_px / ask",
|
||||||
|
"timezone": s.tz,
|
||||||
|
"underlying": s.underlying,
|
||||||
|
"min_option_leverage": s.min_option_leverage,
|
||||||
|
"heartbeat": {
|
||||||
|
"last_ok_ts_ms": hb.get("last_ok_ts_ms"),
|
||||||
|
"last_error": hb.get("last_error"),
|
||||||
|
"consecutive_failures": hb.get("consecutive_failures"),
|
||||||
|
"meta": hb.get("meta"),
|
||||||
|
},
|
||||||
|
"call": by_side.get("C"),
|
||||||
|
"put": by_side.get("P"),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""通知相关 API(需鉴权)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from apps.api.auth import require_auth
|
||||||
|
from packages.notify import wecom
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notify", tags=["notify"], dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/wecom/status")
|
||||||
|
def wecom_status() -> dict:
|
||||||
|
url = wecom.wecom_webhook_url()
|
||||||
|
masked = None
|
||||||
|
if url:
|
||||||
|
if len(url) > 24:
|
||||||
|
masked = url[:18] + "…" + url[-6:]
|
||||||
|
else:
|
||||||
|
masked = "***"
|
||||||
|
return {
|
||||||
|
"enabled": wecom.wecom_enabled(),
|
||||||
|
"webhook_configured": bool(url),
|
||||||
|
"webhook_url_masked": masked,
|
||||||
|
"machine_name": wecom.wecom_machine_name(),
|
||||||
|
"alert_fail_threshold": wecom.alert_fail_threshold(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/wecom/test")
|
||||||
|
def wecom_test() -> dict:
|
||||||
|
ok, msg = wecom.notify_test()
|
||||||
|
return {"ok": ok, "message": msg}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""原始样本调试接口。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from apps.api.auth import require_auth
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.db import Repository
|
||||||
|
|
||||||
|
router = APIRouter(tags=["samples"], dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/samples/recent")
|
||||||
|
def recent_samples(limit: int = Query(default=20, ge=1, le=200)) -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
repo = Repository(s.db_path)
|
||||||
|
try:
|
||||||
|
rows = repo.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM option_quotes
|
||||||
|
ORDER BY ts_ms DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return {"items": [dict(r) for r in rows]}
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""日/周/月统计 API。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from apps.api.auth import require_auth
|
||||||
|
from apps.worker.settle import ensure_settlements_for_ymds
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.db import Repository
|
||||||
|
from packages.domain.aggregate import leverage_stats_payload, move_points_stats_payload
|
||||||
|
from packages.domain.range import resolve_range
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/stats", tags=["stats"], dependencies=[Depends(require_auth)])
|
||||||
|
|
||||||
|
|
||||||
|
def _range_info(range_name: str, date: str | None) -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
try:
|
||||||
|
return resolve_range(
|
||||||
|
range_name,
|
||||||
|
date,
|
||||||
|
month_mode=s.month_range_mode,
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leverage")
|
||||||
|
def leverage_stats(
|
||||||
|
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
||||||
|
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD(上海)"),
|
||||||
|
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||||
|
bucket_minutes: int = Query(default=60, ge=15, le=120),
|
||||||
|
) -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
info = _range_info(range, date)
|
||||||
|
repo = Repository(s.db_path)
|
||||||
|
try:
|
||||||
|
rows = repo.fetch_option_quotes(
|
||||||
|
start_ms=info["start_ms"],
|
||||||
|
end_ms=info["end_ms"],
|
||||||
|
side=side,
|
||||||
|
underlying=s.underlying,
|
||||||
|
)
|
||||||
|
return leverage_stats_payload(
|
||||||
|
rows,
|
||||||
|
range_info=info,
|
||||||
|
bucket_minutes=bucket_minutes,
|
||||||
|
min_leverage=float(s.min_option_leverage),
|
||||||
|
side=side,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/move_points")
|
||||||
|
def move_points_stats(
|
||||||
|
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
||||||
|
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD"),
|
||||||
|
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||||
|
bucket_minutes: int = Query(default=60, ge=15, le=120),
|
||||||
|
) -> dict:
|
||||||
|
s = get_settings()
|
||||||
|
info = _range_info(range, date)
|
||||||
|
repo = Repository(s.db_path)
|
||||||
|
try:
|
||||||
|
rows = repo.fetch_option_quotes(
|
||||||
|
start_ms=info["start_ms"],
|
||||||
|
end_ms=info["end_ms"],
|
||||||
|
side=side,
|
||||||
|
underlying=s.underlying,
|
||||||
|
)
|
||||||
|
ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")})
|
||||||
|
# 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过)
|
||||||
|
try:
|
||||||
|
ensure_settlements_for_ymds(
|
||||||
|
repo,
|
||||||
|
ymds,
|
||||||
|
underlying=s.underlying,
|
||||||
|
index_inst_id=s.index_inst_id,
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — 回填失败不阻断统计
|
||||||
|
pass
|
||||||
|
settlements = repo.list_settlements(ymds)
|
||||||
|
return move_points_stats_payload(
|
||||||
|
rows,
|
||||||
|
settlements,
|
||||||
|
range_info=info,
|
||||||
|
bucket_minutes=bucket_minutes,
|
||||||
|
side=side,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ops-map")
|
||||||
|
def ops_map(
|
||||||
|
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
||||||
|
date: str | None = Query(default=None),
|
||||||
|
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||||
|
bucket_minutes: int = Query(default=60, ge=15, le=120),
|
||||||
|
) -> dict:
|
||||||
|
lev = leverage_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
|
||||||
|
mov = move_points_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
|
||||||
|
return {
|
||||||
|
"range": range,
|
||||||
|
"date": lev.get("date"),
|
||||||
|
"side": side,
|
||||||
|
"bucket_minutes": bucket_minutes,
|
||||||
|
"leverage": lev,
|
||||||
|
"move_points": mov,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# OKX market collector
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""采集入口:OKX 指数 + ATM Call/Put 周期采样落库。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from apps.collector.okx_rest import OkxRestClient
|
||||||
|
from apps.collector.selectors import rows_to_contracts, select_atm_pair
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.db import Repository
|
||||||
|
from packages.db.repository import OptionQuoteRow
|
||||||
|
from packages.domain import option_leverage
|
||||||
|
from packages.notify import wecom
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s [collector] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("collector")
|
||||||
|
|
||||||
|
_STOP = False
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_signal(signum: int, _frame: Any) -> None:
|
||||||
|
global _STOP
|
||||||
|
log.info("signal %s received, stopping…", signum)
|
||||||
|
_STOP = True
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_once(
|
||||||
|
client: OkxRestClient,
|
||||||
|
repo: Repository,
|
||||||
|
*,
|
||||||
|
contracts_cache: list[dict[str, Any]],
|
||||||
|
settings: Any,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ts_ms = _now_ms()
|
||||||
|
index_px = client.fetch_index_ticker(settings.index_inst_id)
|
||||||
|
if index_px is None or index_px <= 0:
|
||||||
|
raise RuntimeError(f"index unavailable: {settings.index_inst_id}")
|
||||||
|
|
||||||
|
repo.insert_index_tick(
|
||||||
|
ts_ms=ts_ms,
|
||||||
|
exchange="okx",
|
||||||
|
underlying=settings.underlying,
|
||||||
|
index_px=float(index_px),
|
||||||
|
)
|
||||||
|
|
||||||
|
pair = select_atm_pair(
|
||||||
|
contracts_cache,
|
||||||
|
index_px=float(index_px),
|
||||||
|
min_hours=float(settings.min_option_hours),
|
||||||
|
)
|
||||||
|
if pair is None:
|
||||||
|
raise RuntimeError("no eligible ATM option pair")
|
||||||
|
|
||||||
|
meta: dict[str, Any] = {
|
||||||
|
"index_px": index_px,
|
||||||
|
"expiry_ymd": pair.expiry_ymd,
|
||||||
|
"strike": pair.strike,
|
||||||
|
"call_inst_id": pair.call_inst_id,
|
||||||
|
"put_inst_id": pair.put_inst_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
for side, inst_id in (("C", pair.call_inst_id), ("P", pair.put_inst_id)):
|
||||||
|
ask, bid, ask_sz, bid_sz, book_ts = client.fetch_books(inst_id)
|
||||||
|
lev = option_leverage(float(index_px), ask)
|
||||||
|
repo.insert_option_quote(
|
||||||
|
OptionQuoteRow(
|
||||||
|
ts_ms=book_ts or ts_ms,
|
||||||
|
exchange="okx",
|
||||||
|
underlying=settings.underlying,
|
||||||
|
inst_id=inst_id,
|
||||||
|
expiry_ymd=pair.expiry_ymd,
|
||||||
|
strike=pair.strike,
|
||||||
|
side=side,
|
||||||
|
index_px=float(index_px),
|
||||||
|
ask=ask,
|
||||||
|
bid=bid,
|
||||||
|
ask_sz=ask_sz,
|
||||||
|
bid_sz=bid_sz,
|
||||||
|
leverage=lev,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
meta[f"{side}_ask"] = ask
|
||||||
|
meta[f"{side}_leverage"] = lev
|
||||||
|
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
log.info(
|
||||||
|
"start underlying=%s family=%s interval=%ss db=%s",
|
||||||
|
settings.underlying,
|
||||||
|
settings.option_inst_family,
|
||||||
|
settings.sample_interval_sec,
|
||||||
|
settings.db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
repo = Repository(settings.db_path)
|
||||||
|
client = OkxRestClient(
|
||||||
|
base_url=settings.okx_base_url,
|
||||||
|
proxy=settings.okx_proxy or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
contracts: list[dict[str, Any]] = []
|
||||||
|
last_instruments_at = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not _STOP:
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
now = time.monotonic()
|
||||||
|
if (
|
||||||
|
not contracts
|
||||||
|
or now - last_instruments_at >= float(settings.instruments_refresh_sec)
|
||||||
|
):
|
||||||
|
raw = client.fetch_option_instruments(settings.option_inst_family)
|
||||||
|
contracts = rows_to_contracts(raw)
|
||||||
|
last_instruments_at = now
|
||||||
|
log.info("instruments refreshed: %d contracts", len(contracts))
|
||||||
|
|
||||||
|
meta = sample_once(client, repo, contracts_cache=contracts, settings=settings)
|
||||||
|
repo.upsert_heartbeat(ok=True, meta=meta)
|
||||||
|
wecom.notify_collector_recovered()
|
||||||
|
log.info(
|
||||||
|
"sampled index=%.2f expiry=%s strike=%.0f C_lev=%s P_lev=%s",
|
||||||
|
meta["index_px"],
|
||||||
|
meta["expiry_ymd"],
|
||||||
|
meta["strike"],
|
||||||
|
f"{meta.get('C_leverage'):.1f}" if meta.get("C_leverage") else "-",
|
||||||
|
f"{meta.get('P_leverage'):.1f}" if meta.get("P_leverage") else "-",
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — 单次失败记日志并跳过
|
||||||
|
log.exception("sample failed: %s", e)
|
||||||
|
repo.upsert_heartbeat(ok=False, error=str(e))
|
||||||
|
hb = repo.get_heartbeat()
|
||||||
|
wecom.notify_collector_fault(
|
||||||
|
error=str(e),
|
||||||
|
consecutive_failures=int(hb.get("consecutive_failures") or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
sleep_for = max(1.0, float(settings.sample_interval_sec) - elapsed)
|
||||||
|
# 可中断 sleep
|
||||||
|
end = time.monotonic() + sleep_for
|
||||||
|
while not _STOP and time.monotonic() < end:
|
||||||
|
time.sleep(min(0.5, end - time.monotonic()))
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
repo.close()
|
||||||
|
log.info("stopped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
sys.exit(run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""OKX REST 只读行情。禁止任何交易类接口。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
def safe_float(v: Any) -> float | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class OkxRestClient:
|
||||||
|
"""仅调用公开行情 / 公共接口。"""
|
||||||
|
|
||||||
|
# 硬黑名单:防止误用交易路径
|
||||||
|
_FORBIDDEN_PREFIXES = (
|
||||||
|
"/api/v5/trade",
|
||||||
|
"/api/v5/account",
|
||||||
|
"/api/v5/asset",
|
||||||
|
"/api/v5/users",
|
||||||
|
)
|
||||||
|
|
||||||
|
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": "market_intel/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]]:
|
||||||
|
for bad in self._FORBIDDEN_PREFIXES:
|
||||||
|
if path.startswith(bad):
|
||||||
|
raise RuntimeError(f"forbidden trading path: {path}")
|
||||||
|
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 _get_raw(self, path: str, params: dict[str, Any] | None = None) -> list[Any]:
|
||||||
|
for bad in self._FORBIDDEN_PREFIXES:
|
||||||
|
if path.startswith(bad):
|
||||||
|
raise RuntimeError(f"forbidden trading path: {path}")
|
||||||
|
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 data if isinstance(data, list) else []
|
||||||
|
|
||||||
|
def fetch_option_instruments(self, inst_family: str) -> list[dict[str, Any]]:
|
||||||
|
rows = self._get(
|
||||||
|
"/api/v5/public/instruments",
|
||||||
|
{"instType": "OPTION", "instFamily": 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_index_at(
|
||||||
|
self, inst_id: str, target_ts_ms: int
|
||||||
|
) -> tuple[float | None, int | None]:
|
||||||
|
"""
|
||||||
|
用 1m 历史指数 K 线取最接近 target 的收盘价。
|
||||||
|
OKX: /api/v5/market/history-index-candles
|
||||||
|
candle: [ts, o, h, l, c, confirm, ...]
|
||||||
|
"""
|
||||||
|
# before = 请求此时间戳之前的数据;取到期前后窗口
|
||||||
|
before = int(target_ts_ms) + 60_000
|
||||||
|
after = int(target_ts_ms) - 10 * 60_000
|
||||||
|
rows = self._get_raw(
|
||||||
|
"/api/v5/market/history-index-candles",
|
||||||
|
{
|
||||||
|
"instId": inst_id,
|
||||||
|
"bar": "1m",
|
||||||
|
"before": str(before),
|
||||||
|
"after": str(after),
|
||||||
|
"limit": "20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
best_px: float | None = None
|
||||||
|
best_ts: int | None = None
|
||||||
|
best_delta: int | None = None
|
||||||
|
for row in rows:
|
||||||
|
if not isinstance(row, (list, tuple)) or len(row) < 5:
|
||||||
|
continue
|
||||||
|
ts = safe_float(row[0])
|
||||||
|
close = safe_float(row[4])
|
||||||
|
if ts is None or close is None:
|
||||||
|
continue
|
||||||
|
ts_i = int(ts)
|
||||||
|
delta = abs(ts_i - int(target_ts_ms))
|
||||||
|
if best_delta is None or delta < best_delta:
|
||||||
|
best_delta = delta
|
||||||
|
best_px = close
|
||||||
|
best_ts = ts_i
|
||||||
|
if best_delta is not None and best_delta > 5 * 60_000:
|
||||||
|
return None, None
|
||||||
|
return best_px, best_ts
|
||||||
|
|
||||||
|
def fetch_books(
|
||||||
|
self, inst_id: str, sz: int = 5
|
||||||
|
) -> tuple[float | None, float | None, float | None, float | None, int | None]:
|
||||||
|
"""返回 ask, bid, ask_sz, bid_sz, ts_ms。"""
|
||||||
|
rows = self._get(
|
||||||
|
"/api/v5/market/books",
|
||||||
|
{"instId": inst_id, "sz": str(max(1, min(int(sz), 400)))},
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None, None, None, None, None
|
||||||
|
row = rows[0]
|
||||||
|
ts = safe_float(row.get("ts"))
|
||||||
|
ts_ms = int(ts) if ts is not None else None
|
||||||
|
asks = row.get("asks") or []
|
||||||
|
bids = row.get("bids") or []
|
||||||
|
ask = ask_sz = bid = bid_sz = None
|
||||||
|
if asks and isinstance(asks[0], (list, tuple)) and len(asks[0]) >= 2:
|
||||||
|
ask = safe_float(asks[0][0])
|
||||||
|
ask_sz = safe_float(asks[0][1])
|
||||||
|
if bids and isinstance(bids[0], (list, tuple)) and len(bids[0]) >= 2:
|
||||||
|
bid = safe_float(bids[0][0])
|
||||||
|
bid_sz = safe_float(bids[0][1])
|
||||||
|
return ask, bid, ask_sz, bid_sz, ts_ms
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""WebSocket 占位(P1 用 REST;WS 后期可接)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# 第一期采集走 REST 轮询;此模块预留多路订阅入口。
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""ATM / 合资格到期选择。规则:最接近指数的行权价;最近剩余时长 ≥ min_hours 的到期。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from packages.domain.expiry import expiry_ms_from_ymd
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
_DATE_RE = re.compile(r"^\d{6}$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OptionPair:
|
||||||
|
expiry_ymd: str
|
||||||
|
expiry_ms: int
|
||||||
|
strike: float
|
||||||
|
call_inst_id: str
|
||||||
|
put_inst_id: str
|
||||||
|
|
||||||
|
|
||||||
|
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 rows_to_contracts(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
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)
|
||||||
|
exp_ms: int | None = None
|
||||||
|
if y is None or stk is None or opt is None:
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
|
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")
|
||||||
|
exp_ms = ms
|
||||||
|
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 not y or stk is None or opt not in ("C", "P"):
|
||||||
|
continue
|
||||||
|
if exp_ms is None:
|
||||||
|
exp_ms = expiry_ms_from_ymd(y)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"inst_id": inst_id,
|
||||||
|
"expiry_ymd": y,
|
||||||
|
"expiry_ms": int(exp_ms),
|
||||||
|
"strike": float(stk),
|
||||||
|
"side": opt,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def hours_until_ms(expiry_ms: int, now: datetime | None = None) -> float:
|
||||||
|
n = (now or datetime.now(tz=_SH)).astimezone(_SH)
|
||||||
|
return (int(expiry_ms) - int(n.timestamp() * 1000)) / 3_600_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def pick_atm_strike(strikes: list[float], index_px: float) -> float | None:
|
||||||
|
"""最接近指数的行权价(平值)。"""
|
||||||
|
if not strikes or index_px <= 0:
|
||||||
|
return None
|
||||||
|
return min(strikes, key=lambda s: (abs(s - index_px), s))
|
||||||
|
|
||||||
|
|
||||||
|
def _complete_by_expiry(
|
||||||
|
contracts: list[dict[str, Any]],
|
||||||
|
) -> dict[str, tuple[int, dict[float, dict[str, str]]]]:
|
||||||
|
by_exp: dict[str, dict[float, dict[str, str]]] = {}
|
||||||
|
ms_map: dict[str, int] = {}
|
||||||
|
for c in contracts:
|
||||||
|
y = str(c.get("expiry_ymd") or "")
|
||||||
|
stk = c.get("strike")
|
||||||
|
opt = str(c.get("side") or "").upper()
|
||||||
|
inst_id = str(c.get("inst_id") or "")
|
||||||
|
if not y or stk is None or opt not in ("C", "P") or not inst_id:
|
||||||
|
continue
|
||||||
|
by_exp.setdefault(y, {}).setdefault(float(stk), {})[opt] = inst_id
|
||||||
|
if c.get("expiry_ms") is not None:
|
||||||
|
ms_map[y] = int(c["expiry_ms"])
|
||||||
|
out: dict[str, tuple[int, dict[float, dict[str, str]]]] = {}
|
||||||
|
for ymd, strikes in by_exp.items():
|
||||||
|
complete = {s: v for s, v in strikes.items() if "C" in v and "P" in v}
|
||||||
|
if not complete:
|
||||||
|
continue
|
||||||
|
ems = ms_map.get(ymd) or expiry_ms_from_ymd(ymd)
|
||||||
|
out[ymd] = (ems, complete)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def select_atm_pair(
|
||||||
|
contracts: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
index_px: float,
|
||||||
|
min_hours: float = 12.0,
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> OptionPair | None:
|
||||||
|
"""
|
||||||
|
选最近合资格到期(剩余 ≥ min_hours)+ ATM Call/Put。
|
||||||
|
ATM = 行权价最接近指数。
|
||||||
|
"""
|
||||||
|
complete = _complete_by_expiry(contracts)
|
||||||
|
if not complete or index_px <= 0:
|
||||||
|
return None
|
||||||
|
eligible = [
|
||||||
|
ymd
|
||||||
|
for ymd, (ems, _) in complete.items()
|
||||||
|
if hours_until_ms(ems, now) + 1e-9 >= float(min_hours)
|
||||||
|
]
|
||||||
|
if not eligible:
|
||||||
|
return None
|
||||||
|
eligible.sort(key=lambda y: complete[y][0])
|
||||||
|
ymd = eligible[0]
|
||||||
|
ems, strikes_map = complete[ymd]
|
||||||
|
strike = pick_atm_strike(list(strikes_map.keys()), index_px)
|
||||||
|
if strike is None:
|
||||||
|
return None
|
||||||
|
legs = strikes_map[strike]
|
||||||
|
return OptionPair(
|
||||||
|
expiry_ymd=ymd,
|
||||||
|
expiry_ms=ems,
|
||||||
|
strike=float(strike),
|
||||||
|
call_inst_id=legs["C"],
|
||||||
|
put_inst_id=legs["P"],
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# worker package — 到期回填 / 日终聚合
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Worker 入口:周期回填到期结算指数。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from apps.collector.okx_rest import OkxRestClient
|
||||||
|
from apps.worker.settle import backfill_settlements
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.db import Repository
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s [worker] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("worker")
|
||||||
|
|
||||||
|
_STOP = False
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_signal(signum: int, _frame: Any) -> None:
|
||||||
|
global _STOP
|
||||||
|
log.info("signal %s received, stopping…", signum)
|
||||||
|
_STOP = True
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> int:
|
||||||
|
settings = get_settings()
|
||||||
|
interval = max(60, int(settings.settle_backfill_interval_sec))
|
||||||
|
log.info(
|
||||||
|
"start settle backfill interval=%ss db=%s",
|
||||||
|
interval,
|
||||||
|
settings.db_path,
|
||||||
|
)
|
||||||
|
repo = Repository(settings.db_path)
|
||||||
|
client = OkxRestClient(
|
||||||
|
base_url=settings.okx_base_url,
|
||||||
|
proxy=settings.okx_proxy or None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
while not _STOP:
|
||||||
|
try:
|
||||||
|
result = backfill_settlements(
|
||||||
|
repo,
|
||||||
|
underlying=settings.underlying,
|
||||||
|
index_inst_id=settings.index_inst_id,
|
||||||
|
client=client,
|
||||||
|
)
|
||||||
|
log.info(
|
||||||
|
"backfill filled=%s skipped=%s errors=%s",
|
||||||
|
len(result["filled"]),
|
||||||
|
len(result["skipped"]),
|
||||||
|
len(result["errors"]),
|
||||||
|
)
|
||||||
|
for err in result["errors"][:5]:
|
||||||
|
log.warning(" %s", err)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
log.exception("backfill loop failed: %s", e)
|
||||||
|
|
||||||
|
end = time.monotonic() + interval
|
||||||
|
while not _STOP and time.monotonic() < end:
|
||||||
|
time.sleep(min(1.0, end - time.monotonic()))
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
repo.close()
|
||||||
|
log.info("stopped")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
sys.exit(run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""到期结算回填:从本地指数或 OKX 历史指数锚定 settle_index_px。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from apps.collector.okx_rest import OkxRestClient, safe_float
|
||||||
|
from packages.db.repository import Repository
|
||||||
|
from packages.domain.expiry import expiry_ms_from_ymd
|
||||||
|
|
||||||
|
log = logging.getLogger("worker.settle")
|
||||||
|
|
||||||
|
# 本地 index_ticks 与到期时刻的最大偏离
|
||||||
|
_LOCAL_MAX_DELTA_MS = 15 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
|
def list_expiry_ymds_needing_settle(repo: Repository, *, now_ms: int | None = None) -> list[str]:
|
||||||
|
"""option_quotes 中已到期且尚未写入 settlements 的 expiry_ymd。"""
|
||||||
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
rows = repo.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT expiry_ymd FROM option_quotes
|
||||||
|
WHERE expiry_ymd IS NOT NULL AND expiry_ymd != ''
|
||||||
|
ORDER BY expiry_ymd ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
out: list[str] = []
|
||||||
|
for r in rows:
|
||||||
|
ymd = str(r["expiry_ymd"])
|
||||||
|
try:
|
||||||
|
settle_ts = expiry_ms_from_ymd(ymd)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if settle_ts > now:
|
||||||
|
continue
|
||||||
|
if repo.get_settlement(ymd) is not None:
|
||||||
|
continue
|
||||||
|
out.append(ymd)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_settle_index(
|
||||||
|
repo: Repository,
|
||||||
|
*,
|
||||||
|
expiry_ymd: str,
|
||||||
|
underlying: str,
|
||||||
|
index_inst_id: str,
|
||||||
|
exchange: str = "okx",
|
||||||
|
client: OkxRestClient | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
解析到期指数。优先本地 index_ticks 最近点;否则 OKX 历史指数 K 线。
|
||||||
|
"""
|
||||||
|
settle_ts = expiry_ms_from_ymd(expiry_ymd)
|
||||||
|
local = repo.nearest_index_tick(
|
||||||
|
underlying=underlying,
|
||||||
|
target_ts_ms=settle_ts,
|
||||||
|
max_delta_ms=_LOCAL_MAX_DELTA_MS,
|
||||||
|
)
|
||||||
|
if local is not None:
|
||||||
|
return {
|
||||||
|
"expiry_ymd": expiry_ymd,
|
||||||
|
"settle_ts_ms": settle_ts,
|
||||||
|
"settle_index_px": float(local["index_px"]),
|
||||||
|
"exchange": exchange,
|
||||||
|
"underlying": underlying,
|
||||||
|
"source": "index_ticks",
|
||||||
|
"source_ts_ms": int(local["ts_ms"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
own_client = client is None
|
||||||
|
cli = client or OkxRestClient()
|
||||||
|
try:
|
||||||
|
px, src_ts = cli.fetch_index_at(index_inst_id, settle_ts)
|
||||||
|
if px is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"expiry_ymd": expiry_ymd,
|
||||||
|
"settle_ts_ms": settle_ts,
|
||||||
|
"settle_index_px": float(px),
|
||||||
|
"exchange": exchange,
|
||||||
|
"underlying": underlying,
|
||||||
|
"source": "okx_history_index",
|
||||||
|
"source_ts_ms": src_ts,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if own_client:
|
||||||
|
cli.close()
|
||||||
|
|
||||||
|
|
||||||
|
def backfill_settlements(
|
||||||
|
repo: Repository,
|
||||||
|
*,
|
||||||
|
underlying: str,
|
||||||
|
index_inst_id: str,
|
||||||
|
client: OkxRestClient | None = None,
|
||||||
|
ymds: list[str] | None = None,
|
||||||
|
now_ms: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""回填到期锚点;返回 {filled, skipped, pending, errors}。"""
|
||||||
|
targets = ymds if ymds is not None else list_expiry_ymds_needing_settle(repo, now_ms=now_ms)
|
||||||
|
filled: list[str] = []
|
||||||
|
skipped: list[str] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
own_client = client is None
|
||||||
|
cli = client
|
||||||
|
try:
|
||||||
|
for ymd in targets:
|
||||||
|
if repo.get_settlement(ymd) is not None:
|
||||||
|
skipped.append(ymd)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if cli is None:
|
||||||
|
cli = OkxRestClient()
|
||||||
|
row = resolve_settle_index(
|
||||||
|
repo,
|
||||||
|
expiry_ymd=ymd,
|
||||||
|
underlying=underlying,
|
||||||
|
index_inst_id=index_inst_id,
|
||||||
|
client=cli,
|
||||||
|
)
|
||||||
|
if row is None:
|
||||||
|
errors.append(f"{ymd}: settle index unavailable")
|
||||||
|
continue
|
||||||
|
repo.upsert_settlement(
|
||||||
|
expiry_ymd=row["expiry_ymd"],
|
||||||
|
settle_ts_ms=int(row["settle_ts_ms"]),
|
||||||
|
settle_index_px=float(row["settle_index_px"]),
|
||||||
|
exchange=str(row["exchange"]),
|
||||||
|
underlying=str(row["underlying"]),
|
||||||
|
)
|
||||||
|
filled.append(ymd)
|
||||||
|
log.info(
|
||||||
|
"settled %s index=%.4f source=%s",
|
||||||
|
ymd,
|
||||||
|
row["settle_index_px"],
|
||||||
|
row.get("source"),
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
errors.append(f"{ymd}: {e}")
|
||||||
|
log.exception("backfill %s failed", ymd)
|
||||||
|
finally:
|
||||||
|
if own_client and cli is not None:
|
||||||
|
cli.close()
|
||||||
|
|
||||||
|
return {"filled": filled, "skipped": skipped, "errors": errors, "targets": targets}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_settlements_for_ymds(
|
||||||
|
repo: Repository,
|
||||||
|
ymds: list[str],
|
||||||
|
*,
|
||||||
|
underlying: str,
|
||||||
|
index_inst_id: str,
|
||||||
|
client: OkxRestClient | None = None,
|
||||||
|
now_ms: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""对给定到期日尽量回填(未到期的跳过)。"""
|
||||||
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
due = []
|
||||||
|
for ymd in sorted(set(ymds)):
|
||||||
|
try:
|
||||||
|
if expiry_ms_from_ymd(ymd) <= now:
|
||||||
|
due.append(ymd)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return backfill_settlements(
|
||||||
|
repo,
|
||||||
|
underlying=underlying,
|
||||||
|
index_inst_id=index_inst_id,
|
||||||
|
client=client,
|
||||||
|
ymds=due,
|
||||||
|
now_ms=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# re-export for typing clarity
|
||||||
|
__all__ = [
|
||||||
|
"backfill_settlements",
|
||||||
|
"ensure_settlements_for_ymds",
|
||||||
|
"list_expiry_ymds_needing_settle",
|
||||||
|
"resolve_settle_index",
|
||||||
|
"safe_float",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 兼容入口:转发到 manage.sh
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
exec bash "${ROOT}/deploy/manage.sh" "$@"
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy/lib/common.sh — market_intel 部署公共函数(Docker Compose)
|
||||||
|
# 目标系统: Ubuntu 22.04 LTS
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
INSTALL_ROOT="${INSTALL_ROOT:-/opt/market_intel}"
|
||||||
|
GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/market_intel.git}"
|
||||||
|
GIT_BRANCH="${GIT_BRANCH:-main}"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/root/backups/market_intel}"
|
||||||
|
TZ_NAME="${MI_TZ:-Asia/Shanghai}"
|
||||||
|
MI_PORT_DEFAULT="${MI_PORT_DEFAULT:-5170}"
|
||||||
|
|
||||||
|
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
DEPLOY_DIR="$(cd "${LIB_DIR}/.." && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
log() { printf '[%s] %s\n' "$(TZ="${TZ_NAME}" date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||||||
|
step() { echo ""; log "==> $*"; }
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "错误: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_root() {
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
die "请使用 root 执行(推荐: sudo -i 后运行)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_apt_lock_holders() {
|
||||||
|
ps -eo pid,cmd 2>/dev/null | grep -E '[u]nattended-upgr|[a]pt-get|[a]pt |[d]pkg ' | head -n 8 || true
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
fuser -v /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock 2>&1 | head -n 12 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_stop_auto_apt_for_deploy() {
|
||||||
|
if [[ "${APT_STOP_AUTO:-1}" != "1" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
|
log "临时停止 unattended-upgrades / apt-daily,避免占锁…"
|
||||||
|
systemctl stop unattended-upgrades.service 2>/dev/null || true
|
||||||
|
systemctl stop apt-daily.service apt-daily-upgrade.service 2>/dev/null || true
|
||||||
|
systemctl kill --kill-who=all unattended-upgrades.service 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [[ "${APT_FORCE_UNLOCK:-0}" == "1" ]]; then
|
||||||
|
log "APT_FORCE_UNLOCK=1:结束残留 apt/dpkg 进程…"
|
||||||
|
pkill -9 -x unattended-upgr 2>/dev/null || true
|
||||||
|
pkill -9 -x apt-get 2>/dev/null || true
|
||||||
|
pkill -9 -x apt 2>/dev/null || true
|
||||||
|
pkill -9 -x dpkg 2>/dev/null || true
|
||||||
|
sleep 2
|
||||||
|
dpkg --configure -a 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_apt_lock() {
|
||||||
|
local max_wait="${1:-600}"
|
||||||
|
local waited=0
|
||||||
|
local tried_stop=0
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
while true; do
|
||||||
|
local busy=0
|
||||||
|
if pgrep -x unattended-upgr >/dev/null 2>&1 \
|
||||||
|
|| pgrep -x apt-get >/dev/null 2>&1 \
|
||||||
|
|| pgrep -x apt >/dev/null 2>&1 \
|
||||||
|
|| pgrep -x dpkg >/dev/null 2>&1; then
|
||||||
|
busy=1
|
||||||
|
fi
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
if fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
|
||||||
|
|| fuser /var/lib/dpkg/lock >/dev/null 2>&1 \
|
||||||
|
|| fuser /var/lib/apt/lists/lock >/dev/null 2>&1; then
|
||||||
|
busy=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "${busy}" -eq 0 ]]; then
|
||||||
|
[[ "${waited}" -gt 0 ]] && log "apt 锁已释放,继续安装"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "${waited}" -eq 0 ]]; then
|
||||||
|
log "检测到 apt/dpkg 正被占用,等待释放…"
|
||||||
|
_apt_lock_holders | while IFS= read -r line; do log " ${line}"; done
|
||||||
|
elif [[ "${tried_stop}" -eq 0 && "${waited}" -ge 15 ]]; then
|
||||||
|
tried_stop=1
|
||||||
|
_stop_auto_apt_for_deploy
|
||||||
|
elif [[ $((waited % 60)) -eq 0 ]]; then
|
||||||
|
log "仍在等待 apt 锁…已等 ${waited}s / ${max_wait}s"
|
||||||
|
fi
|
||||||
|
if [[ "${waited}" -ge "${max_wait}" ]]; then
|
||||||
|
die "等待 apt 锁超时(${max_wait}s)"
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
waited=$((waited + 5))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_update() {
|
||||||
|
wait_for_apt_lock
|
||||||
|
apt-get update -qq
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_install() {
|
||||||
|
wait_for_apt_lock
|
||||||
|
apt-get install -y "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_server_ip() {
|
||||||
|
local ip=""
|
||||||
|
if command -v hostname >/dev/null 2>&1; then
|
||||||
|
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
fi
|
||||||
|
[[ -z "${ip}" ]] && ip="127.0.0.1"
|
||||||
|
echo "${ip}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cm_read() {
|
||||||
|
local __var="$1"
|
||||||
|
local __prompt="${2:-}"
|
||||||
|
local __line=""
|
||||||
|
if [[ -n "${__prompt}" ]]; then
|
||||||
|
printf '%s' "${__prompt}" >/dev/tty 2>/dev/null || printf '%s' "${__prompt}"
|
||||||
|
fi
|
||||||
|
if [[ -r /dev/tty ]]; then
|
||||||
|
IFS= read -r __line </dev/tty || true
|
||||||
|
else
|
||||||
|
IFS= read -r __line || true
|
||||||
|
fi
|
||||||
|
printf -v "${__var}" '%s' "${__line}"
|
||||||
|
}
|
||||||
|
|
||||||
|
confirm_yes() {
|
||||||
|
local msg="$1"
|
||||||
|
local ans=""
|
||||||
|
cm_read ans "${msg} [y/N] "
|
||||||
|
[[ "${ans}" == [yY] || "${ans}" == [yY][eE][sS] ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
repo_ready() {
|
||||||
|
[[ -f "${1}/deploy/manage.sh" && -f "${1}/docker-compose.yml" && -d "${1}/deploy/lib" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_repo_root() {
|
||||||
|
if repo_ready "${INSTALL_ROOT}"; then
|
||||||
|
echo "${INSTALL_ROOT}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -n "${REPO_ROOT:-}" ]] && repo_ready "${REPO_ROOT}"; then
|
||||||
|
echo "${REPO_ROOT}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local file="$1"
|
||||||
|
local key="$2"
|
||||||
|
local val=""
|
||||||
|
if [[ -f "${file}" ]]; then
|
||||||
|
val="$(grep -E "^${key}=" "${file}" 2>/dev/null | tail -n1 | cut -d= -f2- || true)"
|
||||||
|
fi
|
||||||
|
printf '%s' "${val}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 已有非空不覆盖;空或缺省则写入/询问
|
||||||
|
ensure_env_key() {
|
||||||
|
local file="$1"
|
||||||
|
local key="$2"
|
||||||
|
local prompt="$3"
|
||||||
|
local default="$4"
|
||||||
|
local current
|
||||||
|
current="$(read_env_value "${file}" "${key}")"
|
||||||
|
if [[ -n "${current}" ]]; then
|
||||||
|
log "保留 ${key}=***(已有非空值)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local input=""
|
||||||
|
if [[ -n "${prompt}" ]]; then
|
||||||
|
cm_read input "${prompt} [${default}]: "
|
||||||
|
fi
|
||||||
|
if [[ -z "${input}" ]]; then
|
||||||
|
input="${default}"
|
||||||
|
fi
|
||||||
|
if grep -qE "^${key}=" "${file}" 2>/dev/null; then
|
||||||
|
# 替换空值行
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
awk -v k="${key}" -v v="${input}" '
|
||||||
|
BEGIN{FS=OFS="="}
|
||||||
|
$1==k {$0=k"="v}
|
||||||
|
{print}
|
||||||
|
' "${file}" >"${tmp}" && mv "${tmp}" "${file}"
|
||||||
|
else
|
||||||
|
printf '%s=%s\n' "${key}" "${input}" >>"${file}"
|
||||||
|
fi
|
||||||
|
log "已设置 ${key}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_dotenv() {
|
||||||
|
local root="$1"
|
||||||
|
local envf="${root}/.env"
|
||||||
|
if [[ ! -f "${envf}" ]]; then
|
||||||
|
if [[ -f "${root}/.env.example" ]]; then
|
||||||
|
cp -a "${root}/.env.example" "${envf}"
|
||||||
|
log "已从 .env.example 生成 .env"
|
||||||
|
else
|
||||||
|
touch "${envf}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "配置 .env(回车采用默认;已有非空值不覆盖)"
|
||||||
|
ensure_env_key "${envf}" "MI_PORT" "HTTP 端口" "${MI_PORT_DEFAULT}"
|
||||||
|
ensure_env_key "${envf}" "SAMPLE_INTERVAL_SEC" "采样间隔秒" "30"
|
||||||
|
ensure_env_key "${envf}" "MIN_OPTION_LEVERAGE" "杠杆达标线" "100"
|
||||||
|
ensure_env_key "${envf}" "AUTH_SECRET" "鉴权密钥(disabled 关闭)" "change-me"
|
||||||
|
ensure_env_key "${envf}" "ADMIN_PASSWORD" "管理员密码" "admin123"
|
||||||
|
ensure_env_key "${envf}" "OKX_API_KEY" "OKX API Key(可空)" ""
|
||||||
|
ensure_env_key "${envf}" "OKX_API_SECRET" "OKX API Secret(可空)" ""
|
||||||
|
ensure_env_key "${envf}" "OKX_API_PASSPHRASE" "OKX Passphrase(可空)" ""
|
||||||
|
ensure_env_key "${envf}" "WECOM_ENABLED" "企微告警 1/0" "0"
|
||||||
|
ensure_env_key "${envf}" "WECOM_WEBHOOK_URL" "企微 Webhook(可空)" ""
|
||||||
|
ensure_env_key "${envf}" "WECOM_MACHINE_NAME" "机器名(可空)" ""
|
||||||
|
ensure_env_key "${envf}" "ALERT_FAIL_THRESHOLD" "连续失败告警阈值" "5"
|
||||||
|
# 固定库路径(容器内)
|
||||||
|
if [[ -z "$(read_env_value "${envf}" "MI_DB_PATH")" ]]; then
|
||||||
|
ensure_env_key "${envf}" "MI_DB_PATH" "" "/app/data/market_intel.db"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
docker_ok() {
|
||||||
|
command -v docker >/dev/null 2>&1 || return 1
|
||||||
|
docker compose version >/dev/null 2>&1 || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker() {
|
||||||
|
step "环境检测 (Docker / Compose)"
|
||||||
|
if docker_ok; then
|
||||||
|
log "Docker 已就绪: $(docker --version 2>&1)"
|
||||||
|
log "Compose: $(docker compose version 2>&1)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
die "未找到 Docker,且无 apt-get,请手动安装 Docker + Compose 插件"
|
||||||
|
fi
|
||||||
|
step "安装 Docker Engine + Compose 插件"
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt_update
|
||||||
|
apt_install ca-certificates curl gnupg
|
||||||
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
|
if [[ ! -f /etc/apt/keyrings/docker.gpg ]]; then
|
||||||
|
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||||
|
chmod a+r /etc/apt/keyrings/docker.gpg
|
||||||
|
fi
|
||||||
|
local codename
|
||||||
|
codename="$(. /etc/os-release && echo "${VERSION_CODENAME}")"
|
||||||
|
echo \
|
||||||
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu ${codename} stable" \
|
||||||
|
>/etc/apt/sources.list.d/docker.list
|
||||||
|
apt_update
|
||||||
|
apt_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
|
||||||
|
systemctl enable --now docker 2>/dev/null || true
|
||||||
|
if ! docker_ok; then
|
||||||
|
die "Docker 安装后仍不可用"
|
||||||
|
fi
|
||||||
|
log "Docker 安装完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose() {
|
||||||
|
local root="${REPO_ROOT:-${INSTALL_ROOT}}"
|
||||||
|
(cd "${root}" && docker compose "$@")
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_up_build() {
|
||||||
|
step "docker compose up -d --build"
|
||||||
|
compose up -d --build
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_stop() {
|
||||||
|
require_root
|
||||||
|
if ! REPO_ROOT="$(resolve_repo_root)"; then
|
||||||
|
die "未找到安装目录 ${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
step "停止服务"
|
||||||
|
compose stop
|
||||||
|
log "已停止"
|
||||||
|
}
|
||||||
|
|
||||||
|
compose_start() {
|
||||||
|
require_root
|
||||||
|
if ! REPO_ROOT="$(resolve_repo_root)"; then
|
||||||
|
die "未找到安装目录 ${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
step "启动服务"
|
||||||
|
compose up -d
|
||||||
|
verify_health || true
|
||||||
|
}
|
||||||
|
|
||||||
|
read_mi_port() {
|
||||||
|
local root="${1:-${REPO_ROOT:-${INSTALL_ROOT}}}"
|
||||||
|
local p
|
||||||
|
p="$(read_env_value "${root}/.env" "MI_PORT")"
|
||||||
|
if [[ -z "${p}" ]]; then
|
||||||
|
p="${MI_PORT_DEFAULT}"
|
||||||
|
fi
|
||||||
|
echo "${p}"
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_health() {
|
||||||
|
local port
|
||||||
|
port="$(read_mi_port)"
|
||||||
|
local url="http://127.0.0.1:${port}/health"
|
||||||
|
step "健康检查 ${url}"
|
||||||
|
local i
|
||||||
|
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
|
||||||
|
if curl -fsS "${url}" >/dev/null 2>&1; then
|
||||||
|
log "health OK"
|
||||||
|
curl -fsS "${url}" || true
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
log "警告: health 暂未就绪,请检查: docker compose -f ${REPO_ROOT}/docker-compose.yml logs"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
show_status() {
|
||||||
|
if ! REPO_ROOT="$(resolve_repo_root)"; then
|
||||||
|
die "未找到安装目录 ${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
step "容器状态"
|
||||||
|
compose ps || true
|
||||||
|
verify_health || true
|
||||||
|
}
|
||||||
|
|
||||||
|
print_post_install_guide() {
|
||||||
|
local ip port
|
||||||
|
ip="$(detect_server_ip)"
|
||||||
|
port="$(read_mi_port)"
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo " 比特骆驼行情采集分析 部署完成"
|
||||||
|
echo " 目录: ${INSTALL_ROOT}"
|
||||||
|
echo " 本机: http://${ip}:${port}/health"
|
||||||
|
echo " 看板: http://${ip}:${port}/"
|
||||||
|
echo " 管理: bash ${INSTALL_ROOT}/deploy/manage.sh"
|
||||||
|
echo " 配置: ${INSTALL_ROOT}/.env"
|
||||||
|
echo "══════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy/lib/install.sh — 一键部署 market_intel(Docker Compose)
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source "${LIB_DIR}/common.sh"
|
||||||
|
|
||||||
|
run_pipeline() {
|
||||||
|
local root="$1"
|
||||||
|
REPO_ROOT="${root}"
|
||||||
|
ensure_dotenv "${root}"
|
||||||
|
compose_up_build
|
||||||
|
verify_health || true
|
||||||
|
print_post_install_guide
|
||||||
|
}
|
||||||
|
|
||||||
|
install_fresh() {
|
||||||
|
require_root
|
||||||
|
step "一键部署 — Docker 环境检测"
|
||||||
|
ensure_docker
|
||||||
|
# 基础包
|
||||||
|
if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt_update
|
||||||
|
apt_install ca-certificates git curl
|
||||||
|
fi
|
||||||
|
step "克隆仓库 → ${INSTALL_ROOT}"
|
||||||
|
if [[ -d "${INSTALL_ROOT}" ]]; then
|
||||||
|
die "目录已存在: ${INSTALL_ROOT},请先卸载或选修复"
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "${INSTALL_ROOT}")"
|
||||||
|
git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}"
|
||||||
|
run_pipeline "${INSTALL_ROOT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
install_repair() {
|
||||||
|
require_root
|
||||||
|
step "修复部署 — Docker 环境检测"
|
||||||
|
ensure_docker
|
||||||
|
if ! REPO_ROOT="$(resolve_repo_root)"; then
|
||||||
|
die "未找到安装目录 ${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
step "修复环境(保留 .env 与 data volume)"
|
||||||
|
if [[ -d "${REPO_ROOT}/.git" ]]; then
|
||||||
|
git -C "${REPO_ROOT}" pull --ff-only origin "${GIT_BRANCH}" 2>/dev/null \
|
||||||
|
|| git -C "${REPO_ROOT}" pull --ff-only 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
run_pipeline "${REPO_ROOT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_existing() {
|
||||||
|
echo ""
|
||||||
|
echo "检测到已部署: ${INSTALL_ROOT}"
|
||||||
|
echo " a) 取消"
|
||||||
|
echo " b) 修复/重装环境(保留 .env 与数据 volume)"
|
||||||
|
local choice=""
|
||||||
|
cm_read choice "请选择 [a/b]: "
|
||||||
|
case "${choice}" in
|
||||||
|
b|B) install_repair ;;
|
||||||
|
*) log "已取消" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main_install() {
|
||||||
|
require_root
|
||||||
|
if repo_ready "${INSTALL_ROOT}"; then
|
||||||
|
handle_existing
|
||||||
|
else
|
||||||
|
install_fresh
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main_install "$@"
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy/lib/uninstall.sh — 停容器;可选删 volume;备份 .env;删除安装目录
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source "${LIB_DIR}/common.sh"
|
||||||
|
|
||||||
|
main_uninstall() {
|
||||||
|
require_root
|
||||||
|
|
||||||
|
local root=""
|
||||||
|
if ! root="$(resolve_repo_root)"; then
|
||||||
|
if [[ -d "${INSTALL_ROOT}" ]]; then
|
||||||
|
root="${INSTALL_ROOT}"
|
||||||
|
else
|
||||||
|
die "未找到安装目录 ${INSTALL_ROOT}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
REPO_ROOT="${root}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "将卸载 比特骆驼行情采集分析 (market_intel):"
|
||||||
|
echo " - 停止并移除 Compose 容器"
|
||||||
|
echo " - 备份 .env 到 ${BACKUP_ROOT}"
|
||||||
|
echo " - 删除安装目录: ${INSTALL_ROOT}"
|
||||||
|
echo " - 可选:删除 data volume"
|
||||||
|
echo ""
|
||||||
|
if ! confirm_yes "确认卸载并删除 ${INSTALL_ROOT}?"; then
|
||||||
|
log "已取消卸载"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local remove_volume=0
|
||||||
|
if confirm_yes "是否同时删除 Docker data volume (mi_data)?"; then
|
||||||
|
remove_volume=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local stamp backup_dir
|
||||||
|
stamp="$(TZ="${TZ_NAME}" date +%Y%m%d-%H%M%S)"
|
||||||
|
backup_dir="${BACKUP_ROOT}/pre-uninstall-${stamp}"
|
||||||
|
mkdir -p "${backup_dir}"
|
||||||
|
|
||||||
|
step "备份 .env"
|
||||||
|
if [[ -f "${REPO_ROOT}/.env" ]]; then
|
||||||
|
cp -a "${REPO_ROOT}/.env" "${backup_dir}/.env"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
echo "created_at=${stamp}"
|
||||||
|
echo "install_root=${INSTALL_ROOT}"
|
||||||
|
echo "remove_volume=${remove_volume}"
|
||||||
|
echo "action=rm_rf_install_root"
|
||||||
|
} >"${backup_dir}/uninstall.manifest"
|
||||||
|
|
||||||
|
step "停止并移除容器"
|
||||||
|
if command -v docker >/dev/null 2>&1 && [[ -f "${REPO_ROOT}/docker-compose.yml" ]]; then
|
||||||
|
if [[ "${remove_volume}" -eq 1 ]]; then
|
||||||
|
(cd "${REPO_ROOT}" && docker compose down -v) || true
|
||||||
|
else
|
||||||
|
(cd "${REPO_ROOT}" && docker compose down) || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "删除安装目录 ${INSTALL_ROOT}"
|
||||||
|
if [[ -d "${INSTALL_ROOT}" ]]; then
|
||||||
|
case "${INSTALL_ROOT}" in
|
||||||
|
/opt/market_intel|/opt/market_intel/)
|
||||||
|
rm -rf "${INSTALL_ROOT}"
|
||||||
|
log "已删除: ${INSTALL_ROOT}"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
if [[ "${ALLOW_UNSAFE_UNINSTALL:-}" == "1" ]]; then
|
||||||
|
rm -rf "${INSTALL_ROOT}"
|
||||||
|
log "已删除(ALLOW_UNSAFE_UNINSTALL=1): ${INSTALL_ROOT}"
|
||||||
|
else
|
||||||
|
die "拒绝删除非默认路径 ${INSTALL_ROOT};若确认,设置 ALLOW_UNSAFE_UNINSTALL=1"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
log "安装目录不存在,跳过删除"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local leftover
|
||||||
|
for leftover in /opt/market_intel.removed.* /opt/market_intel.old.*; do
|
||||||
|
if [[ -e "${leftover}" ]]; then
|
||||||
|
rm -rf "${leftover}"
|
||||||
|
log "已清理残留: ${leftover}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "卸载完成."
|
||||||
|
echo " 配置备份: ${backup_dir}"
|
||||||
|
echo " volume 已删除: $([[ ${remove_volume} -eq 1 ]] && echo yes || echo no)"
|
||||||
|
echo ""
|
||||||
|
echo "重新部署:"
|
||||||
|
echo " curl -fsSL https://git.bz121.com/dekun/market_intel/raw/branch/main/deploy/manage.sh | bash"
|
||||||
|
}
|
||||||
|
|
||||||
|
main_uninstall "$@"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy/lib/update.sh — git pull + compose build/up;保留 .env 与 volume
|
||||||
|
set -e
|
||||||
|
set -u
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=common.sh
|
||||||
|
source "${LIB_DIR}/common.sh"
|
||||||
|
|
||||||
|
main_update() {
|
||||||
|
require_root
|
||||||
|
if ! REPO_ROOT="$(resolve_repo_root)"; then
|
||||||
|
die "未找到安装目录 ${INSTALL_ROOT},请先执行「1) 一键部署」"
|
||||||
|
fi
|
||||||
|
if ! repo_ready "${REPO_ROOT}"; then
|
||||||
|
die "安装不完整,请先执行「1) 一键部署」"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "更新 — Docker 环境检测"
|
||||||
|
ensure_docker
|
||||||
|
step "git pull"
|
||||||
|
if [[ -d "${REPO_ROOT}/.git" ]]; then
|
||||||
|
git -C "${REPO_ROOT}" fetch --all --prune
|
||||||
|
git -C "${REPO_ROOT}" checkout "${GIT_BRANCH}" 2>/dev/null || true
|
||||||
|
git -C "${REPO_ROOT}" pull --ff-only origin "${GIT_BRANCH}" \
|
||||||
|
|| git -C "${REPO_ROOT}" pull --ff-only
|
||||||
|
else
|
||||||
|
log "警告: 非 git 目录,跳过 pull"
|
||||||
|
fi
|
||||||
|
# 补全缺失 env key,不覆盖已有
|
||||||
|
ensure_dotenv "${REPO_ROOT}"
|
||||||
|
compose_up_build
|
||||||
|
verify_health || true
|
||||||
|
echo ""
|
||||||
|
log "更新完成(.env 与 data volume 已保留)"
|
||||||
|
}
|
||||||
|
|
||||||
|
main_update "$@"
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 比特骆驼行情采集分析 部署管理器(工程 market_intel)
|
||||||
|
# 部署环境: Ubuntu 22.04 LTS · Docker Compose
|
||||||
|
#
|
||||||
|
# 新服务器(免克隆):
|
||||||
|
# curl -fsSL https://git.bz121.com/dekun/market_intel/raw/branch/main/deploy/manage.sh | bash
|
||||||
|
#
|
||||||
|
# 已安装:
|
||||||
|
# bash /opt/market_intel/deploy/manage.sh
|
||||||
|
#
|
||||||
|
set -e
|
||||||
|
if [ -n "${BASH_VERSION:-}" ]; then
|
||||||
|
set -o pipefail
|
||||||
|
fi
|
||||||
|
|
||||||
|
INSTALL_ROOT="${INSTALL_ROOT:-/opt/market_intel}"
|
||||||
|
GIT_URL="${GIT_URL:-https://git.bz121.com/dekun/market_intel.git}"
|
||||||
|
GIT_BRANCH="${GIT_BRANCH:-main}"
|
||||||
|
|
||||||
|
_script_src="${BASH_SOURCE[0]:-}"
|
||||||
|
if [[ -n "${_script_src}" && -f "${_script_src}" ]]; then
|
||||||
|
DEPLOY_DIR="$(cd "$(dirname "${_script_src}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${DEPLOY_DIR}/.." && pwd)"
|
||||||
|
LIB_DIR="${DEPLOY_DIR}/lib"
|
||||||
|
else
|
||||||
|
DEPLOY_DIR=""
|
||||||
|
REPO_ROOT=""
|
||||||
|
LIB_DIR=""
|
||||||
|
fi
|
||||||
|
unset _script_src
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
repo_ready() {
|
||||||
|
[[ -f "${1}/deploy/manage.sh" && -f "${1}/docker-compose.yml" && -d "${1}/deploy/lib" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_git_cli() {
|
||||||
|
if command -v git >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
local waited=0
|
||||||
|
while pgrep -x unattended-upgr >/dev/null 2>&1 \
|
||||||
|
|| pgrep -x apt-get >/dev/null 2>&1 \
|
||||||
|
|| pgrep -x apt >/dev/null 2>&1 \
|
||||||
|
|| pgrep -x dpkg >/dev/null 2>&1 \
|
||||||
|
|| { command -v fuser >/dev/null 2>&1 && fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; }; do
|
||||||
|
if [[ "${waited}" -eq 0 ]]; then
|
||||||
|
echo "等待 apt 锁释放(unattended-upgrades)…"
|
||||||
|
fi
|
||||||
|
if [[ "${waited}" -ge 600 ]]; then
|
||||||
|
echo "错误: 等待 apt 锁超时,请稍后再试" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
waited=$((waited + 5))
|
||||||
|
done
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y git ca-certificates curl
|
||||||
|
else
|
||||||
|
echo "错误: 未找到 git" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
sync_repo_if_present() {
|
||||||
|
local root="$1"
|
||||||
|
if [[ -d "${root}/.git" ]] && command -v git >/dev/null 2>&1; then
|
||||||
|
git -C "${root}" fetch --all --prune 2>/dev/null || true
|
||||||
|
git -C "${root}" checkout "${GIT_BRANCH}" 2>/dev/null || true
|
||||||
|
git -C "${root}" pull --ff-only origin "${GIT_BRANCH}" 2>/dev/null \
|
||||||
|
|| git -C "${root}" pull --ff-only 2>/dev/null \
|
||||||
|
|| true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
heal_existing_install() {
|
||||||
|
local root="$1"
|
||||||
|
echo "检测到已有目录但缺少管理脚本: ${root}"
|
||||||
|
echo "尝试同步最新代码…"
|
||||||
|
ensure_git_cli
|
||||||
|
|
||||||
|
if [[ -d "${root}/.git" ]]; then
|
||||||
|
sync_repo_if_present "${root}"
|
||||||
|
if repo_ready "${root}"; then
|
||||||
|
echo "同步成功,切换到仓库内 manage.sh"
|
||||||
|
exec bash "${root}/deploy/manage.sh" "$@" </dev/tty
|
||||||
|
fi
|
||||||
|
echo "git pull 后仍不完整,尝试强制对齐 origin/${GIT_BRANCH} …"
|
||||||
|
git -C "${root}" fetch origin "${GIT_BRANCH}" || true
|
||||||
|
git -C "${root}" reset --hard "origin/${GIT_BRANCH}" || true
|
||||||
|
if repo_ready "${root}"; then
|
||||||
|
echo "对齐成功,切换到仓库内 manage.sh"
|
||||||
|
exec bash "${root}/deploy/manage.sh" "$@" </dev/tty
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "将备份配置并重新克隆到 ${root}"
|
||||||
|
local stamp backup_dir
|
||||||
|
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
backup_dir="/root/backups/market_intel/heal-${stamp}"
|
||||||
|
mkdir -p "${backup_dir}"
|
||||||
|
[[ -f "${root}/.env" ]] && cp -a "${root}/.env" "${backup_dir}/.env"
|
||||||
|
echo "备份目录: ${backup_dir}"
|
||||||
|
|
||||||
|
local removed="${root}.old.${stamp}"
|
||||||
|
mv "${root}" "${removed}"
|
||||||
|
echo "旧目录已移至: ${removed}"
|
||||||
|
mkdir -p "$(dirname "${root}")"
|
||||||
|
git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${root}"
|
||||||
|
if [[ -f "${backup_dir}/.env" ]]; then
|
||||||
|
cp -a "${backup_dir}/.env" "${root}/.env"
|
||||||
|
echo "已恢复 .env"
|
||||||
|
fi
|
||||||
|
exec bash "${root}/deploy/manage.sh" "$@" </dev/tty
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap_repo() {
|
||||||
|
if repo_ready "${INSTALL_ROOT}"; then
|
||||||
|
REPO_ROOT="${INSTALL_ROOT}"
|
||||||
|
DEPLOY_DIR="${REPO_ROOT}/deploy"
|
||||||
|
LIB_DIR="${DEPLOY_DIR}/lib"
|
||||||
|
sync_repo_if_present "${REPO_ROOT}"
|
||||||
|
if ! repo_ready "${REPO_ROOT}"; then
|
||||||
|
heal_existing_install "${INSTALL_ROOT}" "$@"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -n "${REPO_ROOT}" ]] && repo_ready "${REPO_ROOT}"; then
|
||||||
|
DEPLOY_DIR="${REPO_ROOT}/deploy"
|
||||||
|
LIB_DIR="${DEPLOY_DIR}/lib"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -d "${INSTALL_ROOT}" ]]; then
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "错误: 请使用 root 执行" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
heal_existing_install "${INSTALL_ROOT}" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "比特骆驼行情采集分析 部署管理器 — 首次自举"
|
||||||
|
echo "将克隆到: ${INSTALL_ROOT}"
|
||||||
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
|
echo "错误: 请使用 root 执行" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ensure_git_cli
|
||||||
|
mkdir -p "$(dirname "${INSTALL_ROOT}")"
|
||||||
|
git clone -b "${GIT_BRANCH}" "${GIT_URL}" "${INSTALL_ROOT}"
|
||||||
|
exec bash "${INSTALL_ROOT}/deploy/manage.sh" "$@" </dev/tty
|
||||||
|
}
|
||||||
|
|
||||||
|
show_banner() {
|
||||||
|
local path
|
||||||
|
path="${INSTALL_ROOT}"
|
||||||
|
if [[ -n "${REPO_ROOT}" ]] && repo_ready "${REPO_ROOT}"; then
|
||||||
|
path="${REPO_ROOT}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════╗"
|
||||||
|
echo "║ 比特骆驼行情采集分析 部署管理器 ║"
|
||||||
|
echo "║ 路径: ${path}"
|
||||||
|
echo "╚══════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
show_menu() {
|
||||||
|
echo " 1) 一键部署"
|
||||||
|
echo " 2) 更新"
|
||||||
|
echo " 3) 停止"
|
||||||
|
echo " 4) 启动"
|
||||||
|
echo " 5) 查看状态"
|
||||||
|
echo " 6) 一键卸载"
|
||||||
|
echo " 0) 退出"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
cm_read() {
|
||||||
|
local __var="$1"
|
||||||
|
local __prompt="${2:-}"
|
||||||
|
local __line=""
|
||||||
|
if [[ -n "${__prompt}" ]]; then
|
||||||
|
printf '%s' "${__prompt}" >/dev/tty 2>/dev/null || printf '%s' "${__prompt}"
|
||||||
|
fi
|
||||||
|
if [[ -r /dev/tty ]]; then
|
||||||
|
IFS= read -r __line </dev/tty || true
|
||||||
|
else
|
||||||
|
IFS= read -r __line || true
|
||||||
|
fi
|
||||||
|
printf -v "${__var}" '%s' "${__line}"
|
||||||
|
}
|
||||||
|
|
||||||
|
main_menu() {
|
||||||
|
bootstrap_repo
|
||||||
|
# shellcheck source=lib/common.sh
|
||||||
|
source "${LIB_DIR}/common.sh"
|
||||||
|
REPO_ROOT="$(resolve_repo_root || echo "${INSTALL_ROOT}")"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
show_banner
|
||||||
|
show_menu
|
||||||
|
local choice=""
|
||||||
|
cm_read choice "请选择 [0-6]: "
|
||||||
|
case "${choice}" in
|
||||||
|
1) bash "${LIB_DIR}/install.sh" ;;
|
||||||
|
2) bash "${LIB_DIR}/update.sh" ;;
|
||||||
|
3) compose_stop ;;
|
||||||
|
4) compose_start ;;
|
||||||
|
5) show_status ;;
|
||||||
|
6) bash "${LIB_DIR}/uninstall.sh" ;;
|
||||||
|
0)
|
||||||
|
echo "再见."
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "无效选项,请输入 0-6"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo ""
|
||||||
|
cm_read _ "按 Enter 返回菜单..."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
main_menu "$@"
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 比特骆驼行情采集分析 — Docker Compose
|
||||||
|
# 用法: docker compose up -d --build
|
||||||
|
services:
|
||||||
|
collector:
|
||||||
|
build: .
|
||||||
|
image: market_intel:latest
|
||||||
|
container_name: mi_collector
|
||||||
|
command: ["python", "-m", "apps.collector.main"]
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
MI_DB_PATH: /app/data/market_intel.db
|
||||||
|
volumes:
|
||||||
|
- mi_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
image: market_intel:latest
|
||||||
|
container_name: mi_api
|
||||||
|
command: ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "5170"]
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
MI_DB_PATH: /app/data/market_intel.db
|
||||||
|
ports:
|
||||||
|
- "${MI_PORT:-5170}:5170"
|
||||||
|
volumes:
|
||||||
|
- mi_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- collector
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:5170/health"]
|
||||||
|
interval: 15s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build: .
|
||||||
|
image: market_intel:latest
|
||||||
|
container_name: mi_worker
|
||||||
|
command: ["python", "-m", "apps.worker.main"]
|
||||||
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
MI_DB_PATH: /app/data/market_intel.db
|
||||||
|
volumes:
|
||||||
|
- mi_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- collector
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mi_data:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""配置:从环境变量加载。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packages.config.settings import Settings, get_settings
|
||||||
|
|
||||||
|
__all__ = ["Settings", "get_settings"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from packages.config.settings import Settings, get_settings
|
||||||
|
|
||||||
|
__all__ = ["Settings", "get_settings"]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""集中配置(环境变量可覆盖)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 服务
|
||||||
|
mi_port: int = Field(default=5170, alias="MI_PORT")
|
||||||
|
tz: str = Field(default="Asia/Shanghai", alias="TZ")
|
||||||
|
auth_secret: str = Field(default="change-me", alias="AUTH_SECRET")
|
||||||
|
admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
# OKX
|
||||||
|
okx_api_key: str = Field(default="", alias="OKX_API_KEY")
|
||||||
|
okx_api_secret: str = Field(default="", alias="OKX_API_SECRET")
|
||||||
|
okx_api_passphrase: str = Field(default="", alias="OKX_API_PASSPHRASE")
|
||||||
|
okx_base_url: str = Field(default="https://www.okx.com", alias="OKX_BASE_URL")
|
||||||
|
okx_proxy: str = Field(default="", alias="OKX_PROXY")
|
||||||
|
|
||||||
|
index_inst_id: str = Field(default="ETH-USD", alias="INDEX_INST_ID")
|
||||||
|
option_inst_family: str = Field(default="ETH-USD_UM", alias="OPTION_INST_FAMILY")
|
||||||
|
underlying: str = Field(default="ETH", alias="UNDERLYING")
|
||||||
|
|
||||||
|
sample_interval_sec: int = Field(default=30, alias="SAMPLE_INTERVAL_SEC", ge=15, le=120)
|
||||||
|
index_sample_interval_sec: int = Field(default=60, alias="INDEX_SAMPLE_INTERVAL_SEC", ge=15)
|
||||||
|
instruments_refresh_sec: int = Field(default=300, alias="INSTRUMENTS_REFRESH_SEC", ge=60)
|
||||||
|
min_option_hours: float = Field(default=12.0, alias="MIN_OPTION_HOURS", ge=0)
|
||||||
|
min_option_leverage: float = Field(default=100.0, alias="MIN_OPTION_LEVERAGE", ge=1)
|
||||||
|
|
||||||
|
mi_db_path: str = Field(default="./data/market_intel.db", alias="MI_DB_PATH")
|
||||||
|
bucket_minutes: int = Field(default=60, alias="BUCKET_MINUTES")
|
||||||
|
month_range_mode: str = Field(default="rolling_30", alias="MONTH_RANGE_MODE")
|
||||||
|
settle_backfill_interval_sec: int = Field(
|
||||||
|
default=300, alias="SETTLE_BACKFILL_INTERVAL_SEC", ge=60
|
||||||
|
)
|
||||||
|
|
||||||
|
# 企微告警
|
||||||
|
wecom_enabled: bool = Field(default=False, alias="WECOM_ENABLED")
|
||||||
|
wecom_webhook_url: str = Field(default="", alias="WECOM_WEBHOOK_URL")
|
||||||
|
wecom_machine_name: str = Field(default="", alias="WECOM_MACHINE_NAME")
|
||||||
|
alert_fail_threshold: int = Field(default=5, alias="ALERT_FAIL_THRESHOLD", ge=1)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_path(self) -> Path:
|
||||||
|
return Path(self.mi_db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from packages.db.schema import init_db
|
||||||
|
from packages.db.repository import Repository
|
||||||
|
|
||||||
|
__all__ = ["init_db", "Repository"]
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""数据访问。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from packages.db.schema import init_db
|
||||||
|
|
||||||
|
|
||||||
|
def _now_ms() -> int:
|
||||||
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OptionQuoteRow:
|
||||||
|
ts_ms: int
|
||||||
|
exchange: str
|
||||||
|
underlying: str
|
||||||
|
inst_id: str
|
||||||
|
expiry_ymd: str
|
||||||
|
strike: float
|
||||||
|
side: str
|
||||||
|
index_px: float
|
||||||
|
ask: float | None
|
||||||
|
bid: float | None
|
||||||
|
ask_sz: float | None
|
||||||
|
bid_sz: float | None
|
||||||
|
leverage: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class Repository:
|
||||||
|
def __init__(self, db_path: str | Path) -> None:
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.conn = init_db(self.db_path)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def insert_option_quote(self, row: OptionQuoteRow) -> int:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO option_quotes (
|
||||||
|
ts_ms, exchange, underlying, inst_id, expiry_ymd, strike, side,
|
||||||
|
index_px, ask, bid, ask_sz, bid_sz, leverage, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
row.ts_ms,
|
||||||
|
row.exchange,
|
||||||
|
row.underlying,
|
||||||
|
row.inst_id,
|
||||||
|
row.expiry_ymd,
|
||||||
|
row.strike,
|
||||||
|
row.side,
|
||||||
|
row.index_px,
|
||||||
|
row.ask,
|
||||||
|
row.bid,
|
||||||
|
row.ask_sz,
|
||||||
|
row.bid_sz,
|
||||||
|
row.leverage,
|
||||||
|
_now_ms(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
def insert_index_tick(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ts_ms: int,
|
||||||
|
exchange: str,
|
||||||
|
underlying: str,
|
||||||
|
index_px: float,
|
||||||
|
) -> int:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO index_ticks (ts_ms, exchange, underlying, index_px, created_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(ts_ms, exchange, underlying, index_px, _now_ms()),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
def upsert_heartbeat(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
ok: bool,
|
||||||
|
error: str | None = None,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
now = _now_ms()
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT consecutive_failures FROM collector_heartbeat WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
fails = int(row["consecutive_failures"] if row else 0)
|
||||||
|
if ok:
|
||||||
|
fails = 0
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE collector_heartbeat
|
||||||
|
SET last_ok_ts_ms = ?, last_error = NULL, consecutive_failures = 0,
|
||||||
|
meta_json = COALESCE(?, meta_json)
|
||||||
|
WHERE id = 1
|
||||||
|
""",
|
||||||
|
(now, json.dumps(meta, ensure_ascii=False) if meta else None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
fails += 1
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE collector_heartbeat
|
||||||
|
SET last_error = ?, last_error_ts_ms = ?, consecutive_failures = ?,
|
||||||
|
meta_json = COALESCE(?, meta_json)
|
||||||
|
WHERE id = 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
(error or "unknown")[:2000],
|
||||||
|
now,
|
||||||
|
fails,
|
||||||
|
json.dumps(meta, ensure_ascii=False) if meta else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_heartbeat(self) -> dict[str, Any]:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM collector_heartbeat WHERE id = 1"
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {}
|
||||||
|
d = dict(row)
|
||||||
|
meta = d.get("meta_json")
|
||||||
|
if meta:
|
||||||
|
try:
|
||||||
|
d["meta"] = json.loads(meta)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
d["meta"] = None
|
||||||
|
else:
|
||||||
|
d["meta"] = None
|
||||||
|
return d
|
||||||
|
|
||||||
|
def latest_quotes_by_side(self) -> dict[str, dict[str, Any]]:
|
||||||
|
"""返回 side -> 最新一条。"""
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
for side in ("C", "P"):
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM option_quotes
|
||||||
|
WHERE side = ?
|
||||||
|
ORDER BY ts_ms DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(side,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
out[side] = dict(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def count_option_quotes(self) -> int:
|
||||||
|
row = self.conn.execute("SELECT COUNT(*) AS n FROM option_quotes").fetchone()
|
||||||
|
return int(row["n"] if row else 0)
|
||||||
|
|
||||||
|
def count_index_ticks(self) -> int:
|
||||||
|
row = self.conn.execute("SELECT COUNT(*) AS n FROM index_ticks").fetchone()
|
||||||
|
return int(row["n"] if row else 0)
|
||||||
|
|
||||||
|
def fetch_option_quotes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_ms: int,
|
||||||
|
end_ms: int,
|
||||||
|
side: str = "both",
|
||||||
|
underlying: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""[start_ms, end_ms) 半开区间。"""
|
||||||
|
clauses = ["ts_ms >= ?", "ts_ms < ?"]
|
||||||
|
params: list[Any] = [int(start_ms), int(end_ms)]
|
||||||
|
want = (side or "both").upper()
|
||||||
|
if want in ("C", "P"):
|
||||||
|
clauses.append("side = ?")
|
||||||
|
params.append(want)
|
||||||
|
if underlying:
|
||||||
|
clauses.append("underlying = ?")
|
||||||
|
params.append(underlying)
|
||||||
|
sql = f"""
|
||||||
|
SELECT ts_ms, exchange, underlying, inst_id, expiry_ymd, strike, side,
|
||||||
|
index_px, ask, bid, ask_sz, bid_sz, leverage
|
||||||
|
FROM option_quotes
|
||||||
|
WHERE {' AND '.join(clauses)}
|
||||||
|
ORDER BY ts_ms ASC, id ASC
|
||||||
|
"""
|
||||||
|
rows = self.conn.execute(sql, params).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
def get_settlement(self, expiry_ymd: str) -> dict[str, Any] | None:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"SELECT * FROM expiry_settlements WHERE expiry_ymd = ?",
|
||||||
|
(expiry_ymd,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def list_settlements(self, ymds: list[str] | None = None) -> dict[str, dict[str, Any]]:
|
||||||
|
if ymds is not None and not ymds:
|
||||||
|
return {}
|
||||||
|
if ymds is None:
|
||||||
|
rows = self.conn.execute("SELECT * FROM expiry_settlements").fetchall()
|
||||||
|
else:
|
||||||
|
placeholders = ",".join("?" for _ in ymds)
|
||||||
|
rows = self.conn.execute(
|
||||||
|
f"SELECT * FROM expiry_settlements WHERE expiry_ymd IN ({placeholders})",
|
||||||
|
list(ymds),
|
||||||
|
).fetchall()
|
||||||
|
return {str(r["expiry_ymd"]): dict(r) for r in rows}
|
||||||
|
|
||||||
|
def upsert_settlement(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
expiry_ymd: str,
|
||||||
|
settle_ts_ms: int,
|
||||||
|
settle_index_px: float,
|
||||||
|
exchange: str,
|
||||||
|
underlying: str,
|
||||||
|
) -> None:
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO expiry_settlements (
|
||||||
|
expiry_ymd, settle_ts_ms, settle_index_px, exchange, underlying, created_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(expiry_ymd) DO UPDATE SET
|
||||||
|
settle_ts_ms = excluded.settle_ts_ms,
|
||||||
|
settle_index_px = excluded.settle_index_px,
|
||||||
|
exchange = excluded.exchange,
|
||||||
|
underlying = excluded.underlying
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
expiry_ymd,
|
||||||
|
int(settle_ts_ms),
|
||||||
|
float(settle_index_px),
|
||||||
|
exchange,
|
||||||
|
underlying,
|
||||||
|
_now_ms(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def nearest_index_tick(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
underlying: str,
|
||||||
|
target_ts_ms: int,
|
||||||
|
max_delta_ms: int,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
row = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ts_ms, index_px, ABS(ts_ms - ?) AS delta
|
||||||
|
FROM index_ticks
|
||||||
|
WHERE underlying = ?
|
||||||
|
AND ts_ms BETWEEN ? AND ?
|
||||||
|
ORDER BY delta ASC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(target_ts_ms),
|
||||||
|
underlying,
|
||||||
|
int(target_ts_ms) - int(max_delta_ms),
|
||||||
|
int(target_ts_ms) + int(max_delta_ms),
|
||||||
|
),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""SQLite schema(可迁移设计)。时间戳存 UTC ms。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCHEMA_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS option_quotes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts_ms INTEGER NOT NULL,
|
||||||
|
exchange TEXT NOT NULL,
|
||||||
|
underlying TEXT NOT NULL,
|
||||||
|
inst_id TEXT NOT NULL,
|
||||||
|
expiry_ymd TEXT NOT NULL,
|
||||||
|
strike REAL NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
index_px REAL NOT NULL,
|
||||||
|
ask REAL,
|
||||||
|
bid REAL,
|
||||||
|
ask_sz REAL,
|
||||||
|
bid_sz REAL,
|
||||||
|
leverage REAL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oq_ts ON option_quotes(ts_ms);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oq_side_ts ON option_quotes(side, ts_ms);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oq_expiry ON option_quotes(expiry_ymd);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS index_ticks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts_ms INTEGER NOT NULL,
|
||||||
|
exchange TEXT NOT NULL,
|
||||||
|
underlying TEXT NOT NULL,
|
||||||
|
index_px REAL NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_it_ts ON index_ticks(ts_ms);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_it_u_ts ON index_ticks(underlying, ts_ms);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS expiry_settlements (
|
||||||
|
expiry_ymd TEXT PRIMARY KEY,
|
||||||
|
settle_ts_ms INTEGER NOT NULL,
|
||||||
|
settle_index_px REAL NOT NULL,
|
||||||
|
exchange TEXT NOT NULL,
|
||||||
|
underlying TEXT NOT NULL,
|
||||||
|
created_at_ms INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS collector_heartbeat (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_ok_ts_ms INTEGER,
|
||||||
|
last_error TEXT,
|
||||||
|
last_error_ts_ms INTEGER,
|
||||||
|
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||||
|
meta_json TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: str | Path) -> sqlite3.Connection:
|
||||||
|
path = Path(db_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL;")
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL;")
|
||||||
|
conn.executescript(SCHEMA_SQL)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO collector_heartbeat (id, consecutive_failures) VALUES (1, 0)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""领域口径:杠杆、时段桶、波动点数。变更需升版本。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from packages.domain.aggregate import (
|
||||||
|
aggregate_leverage,
|
||||||
|
aggregate_move_points,
|
||||||
|
summarize_values,
|
||||||
|
)
|
||||||
|
from packages.domain.buckets import shanghai_bucket, shanghai_day
|
||||||
|
from packages.domain.expiry import expiry_ms_from_ymd
|
||||||
|
from packages.domain.leverage import LEVERAGE_FORMULA_VERSION, option_leverage
|
||||||
|
from packages.domain.move_points import MOVE_POINTS_FORMULA_VERSION, move_points
|
||||||
|
from packages.domain.range import resolve_range, today_shanghai
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LEVERAGE_FORMULA_VERSION",
|
||||||
|
"MOVE_POINTS_FORMULA_VERSION",
|
||||||
|
"option_leverage",
|
||||||
|
"move_points",
|
||||||
|
"expiry_ms_from_ymd",
|
||||||
|
"shanghai_day",
|
||||||
|
"shanghai_bucket",
|
||||||
|
"resolve_range",
|
||||||
|
"today_shanghai",
|
||||||
|
"aggregate_leverage",
|
||||||
|
"aggregate_move_points",
|
||||||
|
"summarize_values",
|
||||||
|
]
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
"""杠杆按时段桶聚合。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Iterable, Sequence
|
||||||
|
|
||||||
|
from packages.domain.buckets import shanghai_bucket
|
||||||
|
from packages.domain.leverage import LEVERAGE_FORMULA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(sorted_vals: Sequence[float], p: float) -> float | None:
|
||||||
|
"""线性插值百分位;p in [0,100]。"""
|
||||||
|
if not sorted_vals:
|
||||||
|
return None
|
||||||
|
if len(sorted_vals) == 1:
|
||||||
|
return float(sorted_vals[0])
|
||||||
|
p = max(0.0, min(100.0, float(p)))
|
||||||
|
k = (len(sorted_vals) - 1) * (p / 100.0)
|
||||||
|
f = math.floor(k)
|
||||||
|
c = math.ceil(k)
|
||||||
|
if f == c:
|
||||||
|
return float(sorted_vals[int(k)])
|
||||||
|
d0 = sorted_vals[f] * (c - k)
|
||||||
|
d1 = sorted_vals[c] * (k - f)
|
||||||
|
return float(d0 + d1)
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_values(values: Sequence[float], *, min_leverage: float) -> dict[str, Any]:
|
||||||
|
if not values:
|
||||||
|
return {
|
||||||
|
"n": 0,
|
||||||
|
"mean": None,
|
||||||
|
"median": None,
|
||||||
|
"p25": None,
|
||||||
|
"p75": None,
|
||||||
|
"min": None,
|
||||||
|
"max": None,
|
||||||
|
"pct_ge_min": None,
|
||||||
|
}
|
||||||
|
xs = sorted(float(v) for v in values)
|
||||||
|
n = len(xs)
|
||||||
|
ge = sum(1 for v in xs if v >= float(min_leverage))
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"mean": sum(xs) / n,
|
||||||
|
"median": _percentile(xs, 50),
|
||||||
|
"p25": _percentile(xs, 25),
|
||||||
|
"p75": _percentile(xs, 75),
|
||||||
|
"min": xs[0],
|
||||||
|
"max": xs[-1],
|
||||||
|
"pct_ge_min": ge / n,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def bucket_label(bucket_start_min: int, bucket_minutes: int) -> str:
|
||||||
|
"""如 14:00 或 14:00-14:30。"""
|
||||||
|
h, m = divmod(int(bucket_start_min), 60)
|
||||||
|
start = f"{h:02d}:{m:02d}"
|
||||||
|
if bucket_minutes >= 60 and bucket_minutes % 60 == 0 and m == 0:
|
||||||
|
return f"{h:02d}:00"
|
||||||
|
end_min = bucket_start_min + bucket_minutes
|
||||||
|
eh, em = divmod(end_min % (24 * 60), 60)
|
||||||
|
return f"{start}-{eh:02d}:{em:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def all_bucket_starts(bucket_minutes: int) -> list[int]:
|
||||||
|
if bucket_minutes <= 0 or 1440 % bucket_minutes != 0:
|
||||||
|
# 允许非整除:仍按步进生成到 <1440
|
||||||
|
out = []
|
||||||
|
t = 0
|
||||||
|
while t < 1440:
|
||||||
|
out.append(t)
|
||||||
|
t += bucket_minutes
|
||||||
|
return out
|
||||||
|
return list(range(0, 1440, bucket_minutes))
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_leverage(
|
||||||
|
rows: Iterable[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
bucket_minutes: int = 60,
|
||||||
|
min_leverage: float = 100.0,
|
||||||
|
side: str = "both",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
rows: 需含 ts_ms, leverage, side。
|
||||||
|
返回按桶排序的聚合列表(含空桶)。
|
||||||
|
"""
|
||||||
|
want = (side or "both").upper()
|
||||||
|
by_bucket: dict[int, list[float]] = defaultdict(list)
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
lev = r.get("leverage")
|
||||||
|
if lev is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
lev_f = float(lev)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not math.isfinite(lev_f) or lev_f <= 0:
|
||||||
|
continue
|
||||||
|
s = str(r.get("side") or "").upper()
|
||||||
|
if want in ("C", "P") and s != want:
|
||||||
|
continue
|
||||||
|
if want == "BOTH" and s not in ("C", "P"):
|
||||||
|
continue
|
||||||
|
b = shanghai_bucket(int(r["ts_ms"]), bucket_minutes)
|
||||||
|
by_bucket[b].append(lev_f)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for b in all_bucket_starts(bucket_minutes):
|
||||||
|
stats = summarize_values(by_bucket.get(b, []), min_leverage=min_leverage)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"bucket_start_min": b,
|
||||||
|
"bucket_hour": b // 60 if bucket_minutes >= 60 else None,
|
||||||
|
"label": bucket_label(b, bucket_minutes),
|
||||||
|
**stats,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_distribution(values: Sequence[float]) -> dict[str, Any]:
|
||||||
|
"""通用分布摘要(无达标线)。"""
|
||||||
|
if not values:
|
||||||
|
return {
|
||||||
|
"n": 0,
|
||||||
|
"mean": None,
|
||||||
|
"median": None,
|
||||||
|
"p25": None,
|
||||||
|
"p75": None,
|
||||||
|
"min": None,
|
||||||
|
"max": None,
|
||||||
|
}
|
||||||
|
xs = sorted(float(v) for v in values)
|
||||||
|
n = len(xs)
|
||||||
|
return {
|
||||||
|
"n": n,
|
||||||
|
"mean": sum(xs) / n,
|
||||||
|
"median": _percentile(xs, 50),
|
||||||
|
"p25": _percentile(xs, 25),
|
||||||
|
"p75": _percentile(xs, 75),
|
||||||
|
"min": xs[0],
|
||||||
|
"max": xs[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_move_points(
|
||||||
|
samples: Iterable[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
bucket_minutes: int = 60,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
samples: {ts_ms, move_signed, move_abs}
|
||||||
|
桶内同时给出 signed / abs 分布。
|
||||||
|
"""
|
||||||
|
by_signed: dict[int, list[float]] = defaultdict(list)
|
||||||
|
by_abs: dict[int, list[float]] = defaultdict(list)
|
||||||
|
for s in samples:
|
||||||
|
ts = s.get("ts_ms")
|
||||||
|
signed = s.get("move_signed")
|
||||||
|
if ts is None or signed is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
signed_f = float(signed)
|
||||||
|
abs_f = float(s.get("move_abs", abs(signed_f)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if not math.isfinite(signed_f):
|
||||||
|
continue
|
||||||
|
b = shanghai_bucket(int(ts), bucket_minutes)
|
||||||
|
by_signed[b].append(signed_f)
|
||||||
|
by_abs[b].append(abs_f)
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for b in all_bucket_starts(bucket_minutes):
|
||||||
|
signed_stats = summarize_distribution(by_signed.get(b, []))
|
||||||
|
abs_stats = summarize_distribution(by_abs.get(b, []))
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"bucket_start_min": b,
|
||||||
|
"bucket_hour": b // 60 if bucket_minutes >= 60 else None,
|
||||||
|
"label": bucket_label(b, bucket_minutes),
|
||||||
|
"n": signed_stats["n"],
|
||||||
|
"signed": signed_stats,
|
||||||
|
"abs": abs_stats,
|
||||||
|
# 便捷字段(看板默认用 abs 均值)
|
||||||
|
"mean_signed": signed_stats["mean"],
|
||||||
|
"median_signed": signed_stats["median"],
|
||||||
|
"mean_abs": abs_stats["mean"],
|
||||||
|
"median_abs": abs_stats["median"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_move_samples(
|
||||||
|
rows: Iterable[dict[str, Any]],
|
||||||
|
settlements: dict[str, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
side: str = "both",
|
||||||
|
now_ms: int | None = None,
|
||||||
|
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
对期权样本计算波动点数。
|
||||||
|
返回 (settled_samples, meta)。
|
||||||
|
meta: pending_expiry, pending_count, settled_count, pending_ymds, settled_ymds
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from packages.domain.move_points import move_points as calc_move
|
||||||
|
|
||||||
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
want = (side or "both").upper()
|
||||||
|
settled: list[dict[str, Any]] = []
|
||||||
|
pending_ymds: set[str] = set()
|
||||||
|
settled_ymds: set[str] = set()
|
||||||
|
pending_count = 0
|
||||||
|
settled_count = 0
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
s = str(r.get("side") or "").upper()
|
||||||
|
if want in ("C", "P") and s != want:
|
||||||
|
continue
|
||||||
|
if want == "BOTH" and s not in ("C", "P"):
|
||||||
|
continue
|
||||||
|
ymd = str(r.get("expiry_ymd") or "")
|
||||||
|
idx = r.get("index_px")
|
||||||
|
ts = r.get("ts_ms")
|
||||||
|
if not ymd or idx is None or ts is None:
|
||||||
|
continue
|
||||||
|
settle = settlements.get(ymd)
|
||||||
|
if settle is None or int(settle.get("settle_ts_ms") or 0) > now:
|
||||||
|
pending_ymds.add(ymd)
|
||||||
|
pending_count += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
signed = calc_move(float(settle["settle_index_px"]), float(idx))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pending_ymds.add(ymd)
|
||||||
|
pending_count += 1
|
||||||
|
continue
|
||||||
|
settled_ymds.add(ymd)
|
||||||
|
settled_count += 1
|
||||||
|
settled.append(
|
||||||
|
{
|
||||||
|
"ts_ms": int(ts),
|
||||||
|
"expiry_ymd": ymd,
|
||||||
|
"side": s,
|
||||||
|
"index_at_t": float(idx),
|
||||||
|
"settle_index_px": float(settle["settle_index_px"]),
|
||||||
|
"move_signed": signed,
|
||||||
|
"move_abs": abs(signed),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"pending_expiry": pending_count > 0,
|
||||||
|
"pending_count": pending_count,
|
||||||
|
"settled_count": settled_count,
|
||||||
|
"pending_ymds": sorted(pending_ymds),
|
||||||
|
"settled_ymds": sorted(settled_ymds),
|
||||||
|
}
|
||||||
|
return settled, meta
|
||||||
|
|
||||||
|
|
||||||
|
def move_points_stats_payload(
|
||||||
|
rows: Iterable[dict[str, Any]],
|
||||||
|
settlements: dict[str, dict[str, Any]],
|
||||||
|
*,
|
||||||
|
range_info: dict[str, Any],
|
||||||
|
bucket_minutes: int,
|
||||||
|
side: str,
|
||||||
|
now_ms: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from packages.domain.move_points import MOVE_POINTS_FORMULA_VERSION
|
||||||
|
|
||||||
|
samples, meta = build_move_samples(
|
||||||
|
rows, settlements, side=side, now_ms=now_ms
|
||||||
|
)
|
||||||
|
buckets = aggregate_move_points(samples, bucket_minutes=bucket_minutes)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"formula_version": MOVE_POINTS_FORMULA_VERSION,
|
||||||
|
"move_def": "settle_index_px - index_at(t)",
|
||||||
|
"range": range_info["range"],
|
||||||
|
"date": range_info["anchor"],
|
||||||
|
"start_ymd": range_info["start_ymd"],
|
||||||
|
"end_ymd": range_info["end_ymd"],
|
||||||
|
"days": range_info["days"],
|
||||||
|
"month_mode": range_info.get("month_mode"),
|
||||||
|
"side": side,
|
||||||
|
"bucket_minutes": bucket_minutes,
|
||||||
|
"pending_expiry": meta["pending_expiry"],
|
||||||
|
"pending_count": meta["pending_count"],
|
||||||
|
"settled_count": meta["settled_count"],
|
||||||
|
"pending_ymds": meta["pending_ymds"],
|
||||||
|
"settled_ymds": meta["settled_ymds"],
|
||||||
|
"sample_count": meta["settled_count"],
|
||||||
|
"buckets": buckets,
|
||||||
|
"message": (
|
||||||
|
"部分样本未到期或缺少结算锚点,已排除出分布"
|
||||||
|
if meta["pending_expiry"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def leverage_stats_payload(
|
||||||
|
rows: Iterable[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
range_info: dict[str, Any],
|
||||||
|
bucket_minutes: int,
|
||||||
|
min_leverage: float,
|
||||||
|
side: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
buckets = aggregate_leverage(
|
||||||
|
rows,
|
||||||
|
bucket_minutes=bucket_minutes,
|
||||||
|
min_leverage=min_leverage,
|
||||||
|
side=side,
|
||||||
|
)
|
||||||
|
total_n = sum(int(b["n"]) for b in buckets)
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"formula_version": LEVERAGE_FORMULA_VERSION,
|
||||||
|
"leverage_def": "index_px / ask",
|
||||||
|
"range": range_info["range"],
|
||||||
|
"date": range_info["anchor"],
|
||||||
|
"start_ymd": range_info["start_ymd"],
|
||||||
|
"end_ymd": range_info["end_ymd"],
|
||||||
|
"days": range_info["days"],
|
||||||
|
"month_mode": range_info.get("month_mode"),
|
||||||
|
"side": side,
|
||||||
|
"bucket_minutes": bucket_minutes,
|
||||||
|
"min_leverage": min_leverage,
|
||||||
|
"sample_count": total_n,
|
||||||
|
"buckets": buckets,
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Asia/Shanghai 日切与时段桶。时间戳一律 UTC ms 入,上海出。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_shanghai(ts_ms: int) -> datetime:
|
||||||
|
return datetime.fromtimestamp(int(ts_ms) / 1000.0, tz=_SH)
|
||||||
|
|
||||||
|
|
||||||
|
def shanghai_day(ts_ms: int) -> str:
|
||||||
|
"""上海自然日 YYYY-MM-DD。"""
|
||||||
|
return _to_shanghai(ts_ms).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def shanghai_bucket(ts_ms: int, bucket_minutes: int = 60) -> int:
|
||||||
|
"""
|
||||||
|
日内时段桶起点(分钟,0–1439)。
|
||||||
|
默认 60 → 0,60,120,...,1380(即小时 0–23)。
|
||||||
|
"""
|
||||||
|
if bucket_minutes <= 0:
|
||||||
|
raise ValueError("bucket_minutes must be > 0")
|
||||||
|
dt = _to_shanghai(ts_ms)
|
||||||
|
minutes = dt.hour * 60 + dt.minute
|
||||||
|
return (minutes // bucket_minutes) * bucket_minutes
|
||||||
|
|
||||||
|
|
||||||
|
def shanghai_bucket_hour(ts_ms: int) -> int:
|
||||||
|
"""0–23 小时桶。"""
|
||||||
|
return shanghai_bucket(ts_ms, 60) // 60
|
||||||
|
|
||||||
|
|
||||||
|
def rolling_day_start(anchor_ymd: str, days: int) -> str:
|
||||||
|
"""锚点日(含)往前 days-1 天的起始 YYYY-MM-DD。"""
|
||||||
|
d = datetime.strptime(anchor_ymd, "%Y-%m-%d").date()
|
||||||
|
start = d - timedelta(days=max(0, days - 1))
|
||||||
|
return start.strftime("%Y-%m-%d")
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"""OKX / 欧式期权惯例:到期日当日 08:00 UTC(上海 16:00)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def expiry_ms_from_ymd(ymd: str) -> int:
|
||||||
|
"""YYMMDD → 到期毫秒时间戳(UTC 08:00)。"""
|
||||||
|
ymd = (ymd or "").strip()
|
||||||
|
if len(ymd) != 6 or not ymd.isdigit():
|
||||||
|
raise ValueError(f"invalid expiry ymd: {ymd!r}")
|
||||||
|
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)
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""杠杆口径 v1:杠杆 = 标的指数 ÷ 期权卖一(ask)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
LEVERAGE_FORMULA_VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def option_leverage(index_px: float, ask: float | None) -> float | None:
|
||||||
|
"""index_px / ask;ask 无效时返回 None。"""
|
||||||
|
if index_px is None or index_px <= 0:
|
||||||
|
return None
|
||||||
|
if ask is None or ask <= 0:
|
||||||
|
return None
|
||||||
|
return float(index_px) / float(ask)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""波动点数口径 v1:到期指数 − 时段代表指数。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
MOVE_POINTS_FORMULA_VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def move_points(settle_index_px: float, index_at_t: float) -> float:
|
||||||
|
"""signed 点数;绝对值由调用方取 abs。"""
|
||||||
|
return float(settle_index_px) - float(index_at_t)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""日/周/月时间范围解析(Asia/Shanghai)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def today_shanghai(now: datetime | None = None) -> str:
|
||||||
|
n = (now or datetime.now(tz=_SH)).astimezone(_SH)
|
||||||
|
return n.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ymd(ymd: str) -> date:
|
||||||
|
return datetime.strptime(ymd, "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
|
||||||
|
def day_bounds_ms(ymd: str) -> tuple[int, int]:
|
||||||
|
"""上海自然日 [start_ms, end_ms),end 为次日 00:00。"""
|
||||||
|
d = parse_ymd(ymd)
|
||||||
|
start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=_SH)
|
||||||
|
end = start + timedelta(days=1)
|
||||||
|
return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_range(
|
||||||
|
range_name: str,
|
||||||
|
anchor_ymd: str | None = None,
|
||||||
|
*,
|
||||||
|
month_mode: str = "rolling_30",
|
||||||
|
now: datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
返回:
|
||||||
|
anchor, start_ymd, end_ymd (含), start_ms, end_ms (半开区间), days
|
||||||
|
"""
|
||||||
|
anchor = anchor_ymd or today_shanghai(now)
|
||||||
|
parse_ymd(anchor) # validate
|
||||||
|
|
||||||
|
name = (range_name or "day").strip().lower()
|
||||||
|
if name == "day":
|
||||||
|
start_ymd = end_ymd = anchor
|
||||||
|
days = 1
|
||||||
|
elif name == "week":
|
||||||
|
end_ymd = anchor
|
||||||
|
start = parse_ymd(anchor) - timedelta(days=6)
|
||||||
|
start_ymd = start.strftime("%Y-%m-%d")
|
||||||
|
days = 7
|
||||||
|
elif name == "month":
|
||||||
|
end_ymd = anchor
|
||||||
|
mode = (month_mode or "rolling_30").strip().lower()
|
||||||
|
if mode in ("calendar", "natural", "natural_month"):
|
||||||
|
d = parse_ymd(anchor)
|
||||||
|
start_ymd = d.replace(day=1).strftime("%Y-%m-%d")
|
||||||
|
days = (parse_ymd(end_ymd) - parse_ymd(start_ymd)).days + 1
|
||||||
|
else:
|
||||||
|
# rolling_30
|
||||||
|
start = parse_ymd(anchor) - timedelta(days=29)
|
||||||
|
start_ymd = start.strftime("%Y-%m-%d")
|
||||||
|
days = 30
|
||||||
|
else:
|
||||||
|
raise ValueError(f"invalid range: {range_name!r}")
|
||||||
|
|
||||||
|
start_ms, _ = day_bounds_ms(start_ymd)
|
||||||
|
_, end_ms = day_bounds_ms(end_ymd)
|
||||||
|
return {
|
||||||
|
"range": name,
|
||||||
|
"anchor": anchor,
|
||||||
|
"start_ymd": start_ymd,
|
||||||
|
"end_ymd": end_ymd,
|
||||||
|
"start_ms": start_ms,
|
||||||
|
"end_ms": end_ms,
|
||||||
|
"days": days,
|
||||||
|
"month_mode": month_mode if name == "month" else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from packages.notify import wecom
|
||||||
|
|
||||||
|
__all__ = ["wecom"]
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""企业微信群机器人通知(独立实现,不依赖策略仓)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from packages.config import get_settings
|
||||||
|
|
||||||
|
log = logging.getLogger("notify.wecom")
|
||||||
|
|
||||||
|
_last_fault_key: str | None = None
|
||||||
|
_last_fault_ms: float = 0.0
|
||||||
|
_fault_active: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(raw: Any, default: bool = False) -> bool:
|
||||||
|
if raw is None or raw == "":
|
||||||
|
return default
|
||||||
|
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def wecom_enabled() -> bool:
|
||||||
|
s = get_settings()
|
||||||
|
return _as_bool(getattr(s, "wecom_enabled", False))
|
||||||
|
|
||||||
|
|
||||||
|
def wecom_webhook_url() -> str:
|
||||||
|
s = get_settings()
|
||||||
|
return (getattr(s, "wecom_webhook_url", "") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def wecom_machine_name() -> str:
|
||||||
|
s = get_settings()
|
||||||
|
return (getattr(s, "wecom_machine_name", "") or "").strip()[:64]
|
||||||
|
|
||||||
|
|
||||||
|
def alert_fail_threshold() -> int:
|
||||||
|
s = get_settings()
|
||||||
|
try:
|
||||||
|
return max(1, int(getattr(s, "alert_fail_threshold", 5) or 5))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 5
|
||||||
|
|
||||||
|
|
||||||
|
def build_markdown(*, tag: str, title: str, lines: list[str] | None = None) -> str:
|
||||||
|
machine = wecom_machine_name()
|
||||||
|
prefix = f"【{machine}】" if machine else ""
|
||||||
|
parts = [
|
||||||
|
f"## {prefix}{title}",
|
||||||
|
f"> **标识**: `{tag}`",
|
||||||
|
f"> **系统**: 比特骆驼行情采集分析",
|
||||||
|
f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
]
|
||||||
|
if machine:
|
||||||
|
parts.append(f"> **机器**: {machine}")
|
||||||
|
if lines:
|
||||||
|
parts.append("")
|
||||||
|
for ln in lines:
|
||||||
|
parts.append(f"> {ln}" if not ln.startswith(">") else ln)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def post_markdown(content: str) -> tuple[bool, str]:
|
||||||
|
if not wecom_enabled():
|
||||||
|
return False, "未开启企业微信通知"
|
||||||
|
url = wecom_webhook_url()
|
||||||
|
if not url:
|
||||||
|
return False, "未配置 Webhook"
|
||||||
|
raw = content.encode("utf-8")
|
||||||
|
if len(raw) > 4000:
|
||||||
|
content = raw[:3900].decode("utf-8", errors="ignore") + "\n…"
|
||||||
|
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=10.0) as client:
|
||||||
|
r = client.post(url, json=payload)
|
||||||
|
body = r.json() if r.content else {}
|
||||||
|
if r.status_code != 200 or str(body.get("errcode", 0)) not in ("0", "0.0"):
|
||||||
|
return False, f"webhook failed status={r.status_code} body={body}"
|
||||||
|
return True, "ok"
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_test() -> tuple[bool, str]:
|
||||||
|
md = build_markdown(
|
||||||
|
tag="TEST",
|
||||||
|
title="行情采集分析 · 测试推送",
|
||||||
|
lines=["这是一条测试消息,说明企微 Webhook 可用。"],
|
||||||
|
)
|
||||||
|
return post_markdown(md)
|
||||||
|
|
||||||
|
|
||||||
|
def notify_collector_fault(
|
||||||
|
*,
|
||||||
|
error: str,
|
||||||
|
consecutive_failures: int,
|
||||||
|
dedup_sec: float = 300.0,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""连续失败告警;同错误键在 dedup_sec 内不重复推。"""
|
||||||
|
global _last_fault_key, _last_fault_ms, _fault_active
|
||||||
|
threshold = alert_fail_threshold()
|
||||||
|
if consecutive_failures < threshold:
|
||||||
|
return False, f"below threshold ({consecutive_failures}<{threshold})"
|
||||||
|
|
||||||
|
key = f"{consecutive_failures // threshold}:{(error or '')[:120]}"
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
_last_fault_key == key
|
||||||
|
and (now - _last_fault_ms) < dedup_sec
|
||||||
|
):
|
||||||
|
return False, "dedup"
|
||||||
|
_last_fault_key = key
|
||||||
|
_last_fault_ms = now
|
||||||
|
_fault_active = True
|
||||||
|
|
||||||
|
# 脱敏:避免日志/推送里出现完整密钥形态串
|
||||||
|
err_show = (error or "unknown").replace("\n", " ")[:300]
|
||||||
|
md = build_markdown(
|
||||||
|
tag="FAULT",
|
||||||
|
title="行情采集异常",
|
||||||
|
lines=[
|
||||||
|
f"**连续失败**: {consecutive_failures}(阈值 {threshold})",
|
||||||
|
f"**错误**: {err_show}",
|
||||||
|
"请检查 OKX 连通性 / 代理 / 合约是否可交易。",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
ok, msg = post_markdown(md)
|
||||||
|
if ok:
|
||||||
|
log.info("wecom fault notified failures=%s", consecutive_failures)
|
||||||
|
else:
|
||||||
|
log.warning("wecom fault notify failed: %s", msg)
|
||||||
|
return ok, msg
|
||||||
|
|
||||||
|
|
||||||
|
def notify_collector_recovered(*, consecutive_failures: int = 0) -> tuple[bool, str]:
|
||||||
|
global _fault_active, _last_fault_key
|
||||||
|
if not _fault_active:
|
||||||
|
return False, "no active fault"
|
||||||
|
_fault_active = False
|
||||||
|
_last_fault_key = None
|
||||||
|
md = build_markdown(
|
||||||
|
tag="RECOVER",
|
||||||
|
title="行情采集已恢复",
|
||||||
|
lines=["采样已恢复正常。"],
|
||||||
|
)
|
||||||
|
ok, msg = post_markdown(md)
|
||||||
|
if ok:
|
||||||
|
log.info("wecom recovered notified")
|
||||||
|
else:
|
||||||
|
log.warning("wecom recover notify failed: %s", msg)
|
||||||
|
return ok, msg
|
||||||
|
|
||||||
|
|
||||||
|
def reset_alert_state() -> None:
|
||||||
|
"""测试用。"""
|
||||||
|
global _last_fault_key, _last_fault_ms, _fault_active
|
||||||
|
_last_fault_key = None
|
||||||
|
_last_fault_ms = 0.0
|
||||||
|
_fault_active = False
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.111.0,<1.0
|
||||||
|
uvicorn[standard]>=0.30.0,<1.0
|
||||||
|
httpx>=0.27.0,<1.0
|
||||||
|
pydantic>=2.7.0,<3.0
|
||||||
|
pydantic-settings>=2.3.0,<3.0
|
||||||
|
python-dotenv>=1.0.0,<2.0
|
||||||
|
pytest>=8.0.0,<9.0
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""可选:手动回填到期结算 / 历史指数。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.chdir(ROOT)
|
||||||
|
|
||||||
|
from apps.worker.settle import backfill_settlements
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.db import Repository
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
s = get_settings()
|
||||||
|
repo = Repository(s.db_path)
|
||||||
|
try:
|
||||||
|
result = backfill_settlements(
|
||||||
|
repo,
|
||||||
|
underlying=s.underlying,
|
||||||
|
index_inst_id=s.index_inst_id,
|
||||||
|
)
|
||||||
|
print(result)
|
||||||
|
return 0 if not result["errors"] else 1
|
||||||
|
finally:
|
||||||
|
repo.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""冒烟:拉一次 OKX 指数 + 选 ATM 并打印(不强制写库)。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
os.chdir(ROOT)
|
||||||
|
|
||||||
|
from apps.collector.okx_rest import OkxRestClient
|
||||||
|
from apps.collector.selectors import rows_to_contracts, select_atm_pair
|
||||||
|
from packages.config import get_settings
|
||||||
|
from packages.domain import option_leverage
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
s = get_settings()
|
||||||
|
with OkxRestClient(base_url=s.okx_base_url, proxy=s.okx_proxy or None) as client:
|
||||||
|
idx = client.fetch_index_ticker(s.index_inst_id)
|
||||||
|
print(f"index {s.index_inst_id} = {idx}")
|
||||||
|
raw = client.fetch_option_instruments(s.option_inst_family)
|
||||||
|
contracts = rows_to_contracts(raw)
|
||||||
|
print(f"live contracts = {len(contracts)}")
|
||||||
|
pair = select_atm_pair(contracts, index_px=float(idx or 0), min_hours=s.min_option_hours)
|
||||||
|
if not pair:
|
||||||
|
print("no ATM pair")
|
||||||
|
return 1
|
||||||
|
print(
|
||||||
|
f"ATM expiry={pair.expiry_ymd} strike={pair.strike} "
|
||||||
|
f"C={pair.call_inst_id} P={pair.put_inst_id}"
|
||||||
|
)
|
||||||
|
for side, inst in (("C", pair.call_inst_id), ("P", pair.put_inst_id)):
|
||||||
|
ask, bid, *_ = client.fetch_books(inst)
|
||||||
|
lev = option_leverage(float(idx), ask)
|
||||||
|
print(f" {side}: ask={ask} bid={bid} leverage={lev}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
from packages.domain.aggregate import aggregate_leverage, summarize_values
|
||||||
|
from packages.domain.range import resolve_range
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_values():
|
||||||
|
s = summarize_values([100, 200, 300, 400], min_leverage=200)
|
||||||
|
assert s["n"] == 4
|
||||||
|
assert s["mean"] == 250
|
||||||
|
assert s["median"] == 250
|
||||||
|
assert s["pct_ge_min"] == 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def test_summarize_empty():
|
||||||
|
s = summarize_values([], min_leverage=100)
|
||||||
|
assert s["n"] == 0
|
||||||
|
assert s["mean"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_range_day():
|
||||||
|
info = resolve_range("day", "2026-07-31")
|
||||||
|
assert info["start_ymd"] == "2026-07-31"
|
||||||
|
assert info["end_ymd"] == "2026-07-31"
|
||||||
|
assert info["days"] == 1
|
||||||
|
assert info["end_ms"] > info["start_ms"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_range_week():
|
||||||
|
info = resolve_range("week", "2026-07-31")
|
||||||
|
assert info["start_ymd"] == "2026-07-25"
|
||||||
|
assert info["end_ymd"] == "2026-07-31"
|
||||||
|
assert info["days"] == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_range_month_rolling():
|
||||||
|
info = resolve_range("month", "2026-07-31", month_mode="rolling_30")
|
||||||
|
assert info["start_ymd"] == "2026-07-02"
|
||||||
|
assert info["days"] == 30
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_range_month_calendar():
|
||||||
|
info = resolve_range("month", "2026-07-31", month_mode="calendar")
|
||||||
|
assert info["start_ymd"] == "2026-07-01"
|
||||||
|
assert info["end_ymd"] == "2026-07-31"
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_leverage_buckets():
|
||||||
|
# 2026-07-31 14:30 Asia/Shanghai
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
sh = ZoneInfo("Asia/Shanghai")
|
||||||
|
ts = int(datetime(2026, 7, 31, 14, 30, tzinfo=sh).timestamp() * 1000)
|
||||||
|
rows = [
|
||||||
|
{"ts_ms": ts, "side": "C", "leverage": 120},
|
||||||
|
{"ts_ms": ts, "side": "P", "leverage": 80},
|
||||||
|
{"ts_ms": ts, "side": "C", "leverage": 180},
|
||||||
|
]
|
||||||
|
buckets = aggregate_leverage(rows, bucket_minutes=60, min_leverage=100, side="C")
|
||||||
|
assert len(buckets) == 24
|
||||||
|
b14 = next(b for b in buckets if b["bucket_start_min"] == 14 * 60)
|
||||||
|
assert b14["n"] == 2
|
||||||
|
assert b14["mean"] == 150
|
||||||
|
assert b14["label"] == "14:00"
|
||||||
|
empty = next(b for b in buckets if b["bucket_start_min"] == 0)
|
||||||
|
assert empty["n"] == 0
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from packages.config.settings import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _with_env(**kwargs):
|
||||||
|
old = {}
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
old[k] = os.environ.get(k)
|
||||||
|
os.environ[k] = v
|
||||||
|
get_settings.cache_clear()
|
||||||
|
return old
|
||||||
|
|
||||||
|
|
||||||
|
def _restore(old: dict):
|
||||||
|
for k, v in old.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_token_ok():
|
||||||
|
from apps.api.auth import expected_token, issue_token
|
||||||
|
|
||||||
|
old = _with_env(AUTH_SECRET="unit-secret", ADMIN_PASSWORD="pass123")
|
||||||
|
try:
|
||||||
|
tok = issue_token("pass123")
|
||||||
|
assert tok is not None
|
||||||
|
assert tok == expected_token()
|
||||||
|
assert issue_token("wrong") is None
|
||||||
|
finally:
|
||||||
|
_restore(old)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_disabled():
|
||||||
|
from apps.api.auth import auth_disabled
|
||||||
|
|
||||||
|
old = _with_env(AUTH_SECRET="disabled")
|
||||||
|
try:
|
||||||
|
assert auth_disabled() is True
|
||||||
|
finally:
|
||||||
|
_restore(old)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from packages.domain.buckets import shanghai_bucket, shanghai_bucket_hour, shanghai_day
|
||||||
|
|
||||||
|
_SH = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
def _ms(y, m, d, hh, mm=0):
|
||||||
|
return int(datetime(y, m, d, hh, mm, tzinfo=_SH).timestamp() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shanghai_day():
|
||||||
|
# UTC 2026-07-31 16:00 = 上海 2026-08-01 00:00
|
||||||
|
ts = int(datetime(2026, 7, 31, 16, 0, tzinfo=ZoneInfo("UTC")).timestamp() * 1000)
|
||||||
|
assert shanghai_day(ts) == "2026-08-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucket_hour():
|
||||||
|
ts = _ms(2026, 7, 31, 14, 35)
|
||||||
|
assert shanghai_bucket(ts, 60) == 14 * 60
|
||||||
|
assert shanghai_bucket_hour(ts) == 14
|
||||||
|
|
||||||
|
|
||||||
|
def test_bucket_30m():
|
||||||
|
ts = _ms(2026, 7, 31, 14, 35)
|
||||||
|
assert shanghai_bucket(ts, 30) == 14 * 60 + 30
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from packages.domain import option_leverage
|
||||||
|
|
||||||
|
|
||||||
|
def test_leverage_basic():
|
||||||
|
assert option_leverage(2000.0, 20.0) == 100.0
|
||||||
|
assert option_leverage(3500.0, 35.0) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_leverage_invalid():
|
||||||
|
assert option_leverage(0, 10) is None
|
||||||
|
assert option_leverage(100, 0) is None
|
||||||
|
assert option_leverage(100, None) is None
|
||||||
|
assert option_leverage(-1, 1) is None
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from packages.domain.aggregate import aggregate_move_points, build_move_samples, move_points_stats_payload
|
||||||
|
from packages.domain.range import resolve_range
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_move_samples_settled_and_pending():
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
sh = ZoneInfo("Asia/Shanghai")
|
||||||
|
ts = int(datetime(2026, 7, 30, 10, 0, tzinfo=sh).timestamp() * 1000)
|
||||||
|
rows = [
|
||||||
|
{"ts_ms": ts, "side": "C", "expiry_ymd": "260720", "index_px": 3400}, # settled
|
||||||
|
{"ts_ms": ts, "side": "P", "expiry_ymd": "260720", "index_px": 3450},
|
||||||
|
{"ts_ms": ts, "side": "C", "expiry_ymd": "991231", "index_px": 3500}, # pending far
|
||||||
|
]
|
||||||
|
settlements = {
|
||||||
|
"260720": {"settle_ts_ms": ts - 1000, "settle_index_px": 3500},
|
||||||
|
}
|
||||||
|
samples, meta = build_move_samples(rows, settlements, side="both", now_ms=ts)
|
||||||
|
assert meta["settled_count"] == 2
|
||||||
|
assert meta["pending_count"] == 1
|
||||||
|
assert meta["pending_expiry"] is True
|
||||||
|
assert samples[0]["move_signed"] == 100.0 # 3500-3400
|
||||||
|
assert samples[1]["move_abs"] == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_aggregate_move_points():
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
sh = ZoneInfo("Asia/Shanghai")
|
||||||
|
ts = int(datetime(2026, 7, 30, 14, 20, tzinfo=sh).timestamp() * 1000)
|
||||||
|
samples = [
|
||||||
|
{"ts_ms": ts, "move_signed": 100, "move_abs": 100},
|
||||||
|
{"ts_ms": ts, "move_signed": -40, "move_abs": 40},
|
||||||
|
]
|
||||||
|
buckets = aggregate_move_points(samples, bucket_minutes=60)
|
||||||
|
b14 = next(b for b in buckets if b["bucket_start_min"] == 14 * 60)
|
||||||
|
assert b14["n"] == 2
|
||||||
|
assert b14["mean_signed"] == 30.0
|
||||||
|
assert b14["mean_abs"] == 70.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_points_payload_pending_message():
|
||||||
|
info = resolve_range("day", "2026-07-30")
|
||||||
|
payload = move_points_stats_payload(
|
||||||
|
[{"ts_ms": info["start_ms"] + 3600_000, "side": "C", "expiry_ymd": "991231", "index_px": 1}],
|
||||||
|
{},
|
||||||
|
range_info=info,
|
||||||
|
bucket_minutes=60,
|
||||||
|
side="both",
|
||||||
|
now_ms=info["start_ms"],
|
||||||
|
)
|
||||||
|
assert payload["status"] == "ok"
|
||||||
|
assert payload["pending_expiry"] is True
|
||||||
|
assert payload["settled_count"] == 0
|
||||||
|
assert payload["sample_count"] == 0
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from packages.domain import move_points
|
||||||
|
from packages.domain.expiry import expiry_ms_from_ymd
|
||||||
|
|
||||||
|
|
||||||
|
def test_move_points_signed():
|
||||||
|
assert move_points(3600.0, 3500.0) == 100.0
|
||||||
|
assert move_points(3400.0, 3500.0) == -100.0
|
||||||
|
assert abs(move_points(3400.0, 3500.0)) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_expiry_ms_utc8():
|
||||||
|
# 260731 → 2026-07-31 08:00 UTC
|
||||||
|
ms = expiry_ms_from_ymd("260731")
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
||||||
|
assert dt.year == 2026 and dt.month == 7 and dt.day == 31
|
||||||
|
assert dt.hour == 8 and dt.minute == 0
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from apps.collector.selectors import pick_atm_strike, select_atm_pair
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_atm_strike():
|
||||||
|
assert pick_atm_strike([3490, 3500, 3510], 3502) == 3500
|
||||||
|
assert pick_atm_strike([3490, 3510], 3500) == 3490 # 等距取较小
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_atm_pair():
|
||||||
|
# 构造远到期,避免 min_hours 过滤
|
||||||
|
contracts = []
|
||||||
|
for k in (3490.0, 3500.0, 3510.0):
|
||||||
|
for side in ("C", "P"):
|
||||||
|
contracts.append(
|
||||||
|
{
|
||||||
|
"inst_id": f"ETH-USD_UM-991231-{int(k)}-{side}",
|
||||||
|
"expiry_ymd": "991231",
|
||||||
|
"expiry_ms": 4102358400000, # 远未来
|
||||||
|
"strike": k,
|
||||||
|
"side": side,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pair = select_atm_pair(contracts, index_px=3501.0, min_hours=0)
|
||||||
|
assert pair is not None
|
||||||
|
assert pair.strike == 3500.0
|
||||||
|
assert pair.call_inst_id.endswith("-C")
|
||||||
|
assert pair.put_inst_id.endswith("-P")
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from packages.config.settings import get_settings
|
||||||
|
from packages.notify import wecom
|
||||||
|
|
||||||
|
|
||||||
|
def _with_env(**kwargs):
|
||||||
|
old = {}
|
||||||
|
for k, v in kwargs.items():
|
||||||
|
old[k] = os.environ.get(k)
|
||||||
|
os.environ[k] = v
|
||||||
|
get_settings.cache_clear()
|
||||||
|
return old
|
||||||
|
|
||||||
|
|
||||||
|
def _restore(old: dict):
|
||||||
|
for k, v in old.items():
|
||||||
|
if v is None:
|
||||||
|
os.environ.pop(k, None)
|
||||||
|
else:
|
||||||
|
os.environ[k] = v
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_markdown_contains_tag():
|
||||||
|
md = wecom.build_markdown(tag="FAULT", title="测试", lines=["a", "b"])
|
||||||
|
assert "`FAULT`" in md
|
||||||
|
assert "比特骆驼行情采集分析" in md
|
||||||
|
assert "测试" in md
|
||||||
|
|
||||||
|
|
||||||
|
def test_fault_below_threshold():
|
||||||
|
wecom.reset_alert_state()
|
||||||
|
old = _with_env(WECOM_ENABLED="0", ALERT_FAIL_THRESHOLD="5")
|
||||||
|
try:
|
||||||
|
ok, msg = wecom.notify_collector_fault(error="x", consecutive_failures=2)
|
||||||
|
assert ok is False
|
||||||
|
assert "below threshold" in msg
|
||||||
|
finally:
|
||||||
|
_restore(old)
|
||||||
|
wecom.reset_alert_state()
|
||||||
|
|
||||||
|
|
||||||
|
def test_fault_when_disabled():
|
||||||
|
wecom.reset_alert_state()
|
||||||
|
old = _with_env(WECOM_ENABLED="0", ALERT_FAIL_THRESHOLD="3")
|
||||||
|
try:
|
||||||
|
ok, msg = wecom.notify_collector_fault(error="boom", consecutive_failures=3)
|
||||||
|
assert ok is False
|
||||||
|
assert "未开启" in msg
|
||||||
|
ok2, msg2 = wecom.notify_collector_fault(error="boom", consecutive_failures=3)
|
||||||
|
assert ok2 is False
|
||||||
|
assert msg2 == "dedup"
|
||||||
|
finally:
|
||||||
|
_restore(old)
|
||||||
|
wecom.reset_alert_state()
|
||||||
Vendored
+343
@@ -0,0 +1,343 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>比特骆驼行情采集分析</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0c1117; --panel: #151b24; --text: #e8eef5; --muted: #8b9aab;
|
||||||
|
--accent: #3d8fd1; --ok: #3ecf8e; --warn: #e6a23c; --bad: #e85d5d;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; min-height: 100vh; color: var(--text);
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: radial-gradient(1200px 600px at 10% -10%, #1a2a3d 0%, transparent 55%), var(--bg);
|
||||||
|
}
|
||||||
|
header { padding: 1.5rem 2rem 0.75rem; border-bottom: 1px solid #243041; }
|
||||||
|
.brand { font-size: 1.35rem; font-weight: 700; }
|
||||||
|
.sub { color: var(--muted); margin-top: 0.35rem; font-size: 0.9rem; }
|
||||||
|
.nav { display: flex; gap: 1rem; padding: 0.75rem 2rem; }
|
||||||
|
.nav a { color: var(--muted); text-decoration: none; cursor: pointer; }
|
||||||
|
.nav a.active, .nav a:hover { color: var(--accent); }
|
||||||
|
main { padding: 1rem 2rem 3rem; max-width: 980px; }
|
||||||
|
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||||||
|
.tile { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem 1.1rem; }
|
||||||
|
.label { color: var(--muted); font-size: 0.8rem; }
|
||||||
|
.value { margin-top: 0.4rem; font-size: 1.5rem; font-weight: 650; font-variant-numeric: tabular-nums; }
|
||||||
|
.hint { margin-top: 0.35rem; color: var(--muted); font-size: 0.78rem; }
|
||||||
|
.status-ok { color: var(--ok); } .status-bad { color: var(--bad); } .status-warn { color: var(--warn); }
|
||||||
|
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin: 0.5rem 0 1rem; }
|
||||||
|
.seg { display: inline-flex; background: var(--panel); border: 1px solid #243041; border-radius: 8px; overflow: hidden; }
|
||||||
|
.seg button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.45rem 0.85rem; cursor: pointer; }
|
||||||
|
.seg button.active { background: #1e2a3a; color: var(--text); }
|
||||||
|
.date-field { color: var(--muted); font-size: 0.85rem; display: inline-flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.date-field input { background: var(--panel); border: 1px solid #243041; color: var(--text); border-radius: 6px; padding: 0.35rem 0.5rem; }
|
||||||
|
.chart-wrap { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 0.75rem 0.5rem; margin-top: 0.5rem; }
|
||||||
|
.chart-title { padding: 0 0.75rem 0.25rem; color: var(--muted); font-size: 0.85rem; }
|
||||||
|
.chart-svg { width: 100%; height: auto; display: block; }
|
||||||
|
.hidden { display: none; }
|
||||||
|
pre { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 1rem; overflow: auto; font-size: 0.78rem; color: #b7c5d4; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brand">比特骆驼行情采集分析</div>
|
||||||
|
<div class="sub">只读采集 · 杠杆 = 指数 ÷ 卖一 · Asia/Shanghai</div>
|
||||||
|
</header>
|
||||||
|
<div class="nav">
|
||||||
|
<a id="nav-dash" class="active" data-page="dash">总览</a>
|
||||||
|
<a id="nav-ops" data-page="ops">作战地图</a>
|
||||||
|
<a id="nav-logout" class="hidden">退出</a>
|
||||||
|
</div>
|
||||||
|
<main>
|
||||||
|
<section id="page-login" class="hidden">
|
||||||
|
<div class="tile" style="max-width:360px">
|
||||||
|
<div class="label">管理员密码</div>
|
||||||
|
<input id="loginPass" type="password" style="width:100%;margin-top:0.5rem;padding:0.55rem;border-radius:6px;border:1px solid #243041;background:#0c1117;color:#e8eef5" />
|
||||||
|
<p id="loginErr" style="color:var(--bad);font-size:0.85rem"></p>
|
||||||
|
<button id="loginBtn" type="button" style="margin-top:0.5rem;padding:0.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;cursor:pointer">登录</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="page-dash">
|
||||||
|
<div class="row">
|
||||||
|
<div class="tile"><div class="label">采集状态</div><div class="value" id="status">…</div><div class="hint" id="lag">—</div></div>
|
||||||
|
<div class="tile"><div class="label">ATM Call 杠杆</div><div class="value" id="callLev">—</div><div class="hint" id="callMeta">—</div></div>
|
||||||
|
<div class="tile"><div class="label">ATM Put 杠杆</div><div class="value" id="putLev">—</div><div class="hint" id="putMeta">—</div></div>
|
||||||
|
<div class="tile"><div class="label">指数</div><div class="value" id="indexPx">—</div><div class="hint" id="expiry">—</div></div>
|
||||||
|
</div>
|
||||||
|
<pre id="raw" style="margin-top:1rem">加载中…</pre>
|
||||||
|
</section>
|
||||||
|
<section id="page-ops" class="hidden">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="seg" id="rangeSeg">
|
||||||
|
<button type="button" data-range="day" class="active">日</button>
|
||||||
|
<button type="button" data-range="week">周</button>
|
||||||
|
<button type="button" data-range="month">月</button>
|
||||||
|
</div>
|
||||||
|
<div class="seg" id="sideSeg">
|
||||||
|
<button type="button" data-side="both" class="active">双边</button>
|
||||||
|
<button type="button" data-side="C">Call</button>
|
||||||
|
<button type="button" data-side="P">Put</button>
|
||||||
|
</div>
|
||||||
|
<label class="date-field">锚点日 <input type="date" id="anchorDate" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="tile"><div class="label">范围</div><div class="value" id="opsRange" style="font-size:1.05rem">—</div><div class="hint" id="opsSamples">—</div></div>
|
||||||
|
<div class="tile"><div class="label">达标线</div><div class="value" id="opsMin">—</div><div class="hint">杠杆 = 指数 ÷ 卖一</div></div>
|
||||||
|
<div class="tile"><div class="label">波动样本</div><div class="value" id="opsMoveN">—</div><div class="hint" id="opsPending">—</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title" id="chartTitle">上图 · 时段杠杆均值</div>
|
||||||
|
<svg id="levChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar" style="margin-top:1rem">
|
||||||
|
<div class="seg" id="moveModeSeg">
|
||||||
|
<button type="button" data-mode="abs" class="active">绝对波动</button>
|
||||||
|
<button type="button" data-mode="signed">带符号</button>
|
||||||
|
</div>
|
||||||
|
<span id="pendingBadge" style="color:var(--warn);font-size:0.85rem"></span>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap">
|
||||||
|
<div class="chart-title" id="moveTitle">下图 · 时段→到期波动</div>
|
||||||
|
<svg id="moveChart" class="chart-svg" viewBox="0 0 880 260" role="img"></svg>
|
||||||
|
</div>
|
||||||
|
<p id="moveHint" style="color:var(--muted);font-size:0.85rem;margin-top:0.75rem"></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const TOKEN_KEY = "mi_token";
|
||||||
|
const state = { page: "dash", range: "day", side: "both", moveMode: "abs", lastMov: null, authRequired: false };
|
||||||
|
function getToken() { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } }
|
||||||
|
function setToken(t) { try { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } catch {} }
|
||||||
|
function authHeaders() {
|
||||||
|
const t = getToken();
|
||||||
|
return t ? { Authorization: "Bearer " + t } : {};
|
||||||
|
}
|
||||||
|
async function apiFetch(url, init) {
|
||||||
|
const headers = Object.assign({}, (init && init.headers) || {}, authHeaders());
|
||||||
|
const r = await fetch(url, Object.assign({}, init || {}, { headers, credentials: "include" }));
|
||||||
|
if (r.status === 401) { setToken(null); showLogin(); }
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
function fmt(n, d=1) {
|
||||||
|
if (n == null || Number.isNaN(n)) return "—";
|
||||||
|
return Number(n).toFixed(d);
|
||||||
|
}
|
||||||
|
function todayYmd() {
|
||||||
|
const d = new Date();
|
||||||
|
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
|
||||||
|
}
|
||||||
|
document.getElementById("anchorDate").value = todayYmd();
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
document.getElementById("page-login").classList.remove("hidden");
|
||||||
|
document.getElementById("page-dash").classList.add("hidden");
|
||||||
|
document.getElementById("page-ops").classList.add("hidden");
|
||||||
|
document.getElementById("nav-logout").classList.add("hidden");
|
||||||
|
}
|
||||||
|
function hideLogin() {
|
||||||
|
document.getElementById("page-login").classList.add("hidden");
|
||||||
|
document.getElementById("nav-logout").classList.toggle("hidden", !state.authRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPage(p) {
|
||||||
|
if (state.authRequired && !getToken() && !document.cookie.includes("mi_token=")) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.page = p;
|
||||||
|
hideLogin();
|
||||||
|
document.getElementById("page-dash").classList.toggle("hidden", p !== "dash");
|
||||||
|
document.getElementById("page-ops").classList.toggle("hidden", p !== "ops");
|
||||||
|
document.getElementById("nav-dash").classList.toggle("active", p === "dash");
|
||||||
|
document.getElementById("nav-ops").classList.toggle("active", p === "ops");
|
||||||
|
location.hash = p === "ops" ? "ops-map" : "";
|
||||||
|
if (p === "ops") loadOps();
|
||||||
|
if (p === "dash") refreshDash();
|
||||||
|
}
|
||||||
|
if (location.pathname === "/ops-map" || location.hash === "#ops-map") state.page = "ops";
|
||||||
|
document.querySelectorAll(".nav a[data-page]").forEach(a => a.addEventListener("click", () => showPage(a.dataset.page)));
|
||||||
|
document.getElementById("nav-logout").addEventListener("click", async () => {
|
||||||
|
setToken(null);
|
||||||
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
showLogin();
|
||||||
|
});
|
||||||
|
document.getElementById("loginBtn").addEventListener("click", async () => {
|
||||||
|
const password = document.getElementById("loginPass").value;
|
||||||
|
document.getElementById("loginErr").textContent = "";
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/auth/login", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error("密码错误");
|
||||||
|
const body = await r.json();
|
||||||
|
if (body.token) setToken(body.token);
|
||||||
|
showPage(state.page || "dash");
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("loginErr").textContent = String(e.message || e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("#rangeSeg button").forEach(b => b.addEventListener("click", () => {
|
||||||
|
state.range = b.dataset.range;
|
||||||
|
document.querySelectorAll("#rangeSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||||||
|
loadOps();
|
||||||
|
}));
|
||||||
|
document.querySelectorAll("#sideSeg button").forEach(b => b.addEventListener("click", () => {
|
||||||
|
state.side = b.dataset.side;
|
||||||
|
document.querySelectorAll("#sideSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||||||
|
loadOps();
|
||||||
|
}));
|
||||||
|
document.querySelectorAll("#moveModeSeg button").forEach(b => b.addEventListener("click", () => {
|
||||||
|
state.moveMode = b.dataset.mode;
|
||||||
|
document.querySelectorAll("#moveModeSeg button").forEach(x => x.classList.toggle("active", x === b));
|
||||||
|
if (state.lastMov) drawMoveChart(state.lastMov.buckets || [], state.moveMode);
|
||||||
|
}));
|
||||||
|
document.getElementById("anchorDate").addEventListener("change", loadOps);
|
||||||
|
|
||||||
|
function drawChart(buckets, minLev) {
|
||||||
|
const svg = document.getElementById("levChart");
|
||||||
|
const W=880,H=260,padL=44,padR=12,padT=24,padB=36;
|
||||||
|
const innerW=W-padL-padR, innerH=H-padT-padB;
|
||||||
|
const vals = buckets.map(b => b.mean || 0);
|
||||||
|
const maxV = Math.max(minLev * 1.2, ...vals, 1);
|
||||||
|
const barW = innerW / Math.max(buckets.length, 1);
|
||||||
|
const yMin = padT + innerH * (1 - minLev / maxV);
|
||||||
|
let html = "";
|
||||||
|
html += `<line x1="${padL}" y1="${yMin}" x2="${W-padR}" y2="${yMin}" stroke="#e6a23c" stroke-dasharray="4 4"/>`;
|
||||||
|
html += `<text x="${W-padR-4}" y="${yMin-4}" fill="#e6a23c" font-size="10" text-anchor="end">min ${minLev}</text>`;
|
||||||
|
buckets.forEach((b,i) => {
|
||||||
|
const v = b.mean || 0;
|
||||||
|
const h = b.n > 0 ? (v / maxV) * innerH : 0;
|
||||||
|
const x = padL + i * barW + barW * 0.15;
|
||||||
|
const y = padT + innerH - h;
|
||||||
|
const w = barW * 0.7;
|
||||||
|
const fill = b.n === 0 ? "#243041" : (v >= minLev ? "#3ecf8e" : "#3d8fd1");
|
||||||
|
html += `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(h, b.n>0?2:0)}" fill="${fill}" rx="2"><title>${b.label}: mean=${fmt(b.mean)} n=${b.n}</title></rect>`;
|
||||||
|
if (i % 2 === 0) html += `<text x="${x+w/2}" y="${H-10}" fill="#8b9aab" font-size="9" text-anchor="middle">${String(b.label).replace(":00","")}</text>`;
|
||||||
|
});
|
||||||
|
html += `<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||||||
|
html += `<line x1="${padL}" y1="${padT+innerH}" x2="${W-padR}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||||||
|
html += `<text x="4" y="${padT+8}" fill="#8b9aab" font-size="10">${maxV.toFixed(0)}</text>`;
|
||||||
|
html += `<text x="4" y="${padT+innerH}" fill="#8b9aab" font-size="10">0</text>`;
|
||||||
|
svg.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMoveChart(buckets, mode) {
|
||||||
|
const svg = document.getElementById("moveChart");
|
||||||
|
const W=880,H=260,padL=44,padR=12,padT=24,padB=36;
|
||||||
|
const innerW=W-padL-padR, innerH=H-padT-padB;
|
||||||
|
const vals = buckets.map(b => mode === "abs" ? (b.mean_abs||0) : (b.mean_signed||0));
|
||||||
|
const maxAbs = Math.max(...vals.map(v => Math.abs(v)), 1);
|
||||||
|
const y0 = mode === "signed" ? padT + innerH/2 : padT + innerH;
|
||||||
|
const scale = mode === "signed" ? (innerH/2)/maxAbs : innerH/maxAbs;
|
||||||
|
const barW = innerW / Math.max(buckets.length, 1);
|
||||||
|
let html = "";
|
||||||
|
if (mode === "signed") html += `<line x1="${padL}" y1="${y0}" x2="${W-padR}" y2="${y0}" stroke="#243041"/>`;
|
||||||
|
buckets.forEach((b,i) => {
|
||||||
|
const v = mode === "abs" ? (b.mean_abs||0) : (b.mean_signed||0);
|
||||||
|
const h = b.n > 0 ? Math.abs(v)*scale : 0;
|
||||||
|
const x = padL + i*barW + barW*0.15;
|
||||||
|
const y = mode === "signed" ? (v >= 0 ? y0 - h : y0) : y0 - h;
|
||||||
|
const w = barW*0.7;
|
||||||
|
const fill = b.n === 0 ? "#243041" : (mode === "abs" ? "#9b7bff" : (v >= 0 ? "#3ecf8e" : "#e85d5d"));
|
||||||
|
html += `<rect x="${x}" y="${y}" width="${w}" height="${Math.max(h, b.n>0?2:0)}" fill="${fill}" rx="2"><title>${b.label}: abs=${fmt(b.mean_abs)} signed=${fmt(b.mean_signed)} n=${b.n}</title></rect>`;
|
||||||
|
if (i % 2 === 0) html += `<text x="${x+w/2}" y="${H-10}" fill="#8b9aab" font-size="9" text-anchor="middle">${String(b.label).replace(":00","")}</text>`;
|
||||||
|
});
|
||||||
|
html += `<line x1="${padL}" y1="${padT}" x2="${padL}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||||||
|
html += `<line x1="${padL}" y1="${padT+innerH}" x2="${W-padR}" y2="${padT+innerH}" stroke="#243041"/>`;
|
||||||
|
html += `<text x="4" y="${padT+8}" fill="#8b9aab" font-size="10">${maxAbs.toFixed(0)}</text>`;
|
||||||
|
html += `<text x="4" y="${padT+innerH}" fill="#8b9aab" font-size="10">${mode==="signed"?"-"+maxAbs.toFixed(0):"0"}</text>`;
|
||||||
|
svg.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOps() {
|
||||||
|
const date = document.getElementById("anchorDate").value;
|
||||||
|
const q = new URLSearchParams({ range: state.range, side: state.side, bucket_minutes: "60", date });
|
||||||
|
try {
|
||||||
|
const d = await apiFetch("/api/stats/ops-map?" + q).then(r => {
|
||||||
|
if (!r.ok) throw new Error("ops-map " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
const lev = d.leverage || {};
|
||||||
|
document.getElementById("opsRange").textContent = (lev.start_ymd || "—") + " → " + (lev.end_ymd || "—");
|
||||||
|
document.getElementById("opsSamples").textContent = "杠杆样本 " + (lev.sample_count ?? 0);
|
||||||
|
document.getElementById("opsMin").textContent = lev.min_leverage ?? "—";
|
||||||
|
const titleMap = { day: "日", week: "近7日", month: "近30日" };
|
||||||
|
document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + ")";
|
||||||
|
drawChart(lev.buckets || [], lev.min_leverage || 100);
|
||||||
|
const mov = d.move_points || {};
|
||||||
|
state.lastMov = mov;
|
||||||
|
document.getElementById("opsMoveN").textContent = mov.settled_count ?? 0;
|
||||||
|
document.getElementById("opsPending").textContent = mov.pending_expiry
|
||||||
|
? ("pending " + (mov.pending_count ?? 0) + "(未到期已排除)")
|
||||||
|
: "全部已结算";
|
||||||
|
document.getElementById("pendingBadge").textContent = mov.pending_expiry ? "pending_expiry=true" : "";
|
||||||
|
document.getElementById("moveTitle").textContent =
|
||||||
|
"下图 · 时段→到期波动(" + (titleMap[state.range]||"") + " · " + state.moveMode + ")";
|
||||||
|
drawMoveChart(mov.buckets || [], state.moveMode);
|
||||||
|
document.getElementById("moveHint").textContent = mov.message || "";
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("opsRange").textContent = "加载失败";
|
||||||
|
document.getElementById("moveHint").textContent = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshDash() {
|
||||||
|
try {
|
||||||
|
const [h, mRes] = await Promise.all([
|
||||||
|
fetch("/health").then(r => r.json()),
|
||||||
|
apiFetch("/api/meta/latest"),
|
||||||
|
]);
|
||||||
|
if (!mRes.ok) throw new Error("latest " + mRes.status);
|
||||||
|
const m = await mRes.json();
|
||||||
|
const lag = h.collector_lag_ms;
|
||||||
|
const ok = h.ok && (lag == null || lag < 120000) && (h.consecutive_failures || 0) < 5;
|
||||||
|
const st = document.getElementById("status");
|
||||||
|
st.textContent = ok ? "正常" : "异常/等待";
|
||||||
|
st.className = "value " + (ok ? "status-ok" : "status-warn");
|
||||||
|
document.getElementById("lag").textContent =
|
||||||
|
lag == null ? "尚无采样" : `延迟 ${Math.round(lag/1000)}s · 样本 ${h.option_quotes||0}`;
|
||||||
|
const call = m.call, put = m.put;
|
||||||
|
document.getElementById("callLev").textContent = fmt(call && call.leverage, 1);
|
||||||
|
document.getElementById("putLev").textContent = fmt(put && put.leverage, 1);
|
||||||
|
document.getElementById("callMeta").textContent = call ? `${call.inst_id} · ask ${fmt(call.ask, 4)}` : "—";
|
||||||
|
document.getElementById("putMeta").textContent = put ? `${put.inst_id} · ask ${fmt(put.ask, 4)}` : "—";
|
||||||
|
const meta = (m.heartbeat && m.heartbeat.meta) || {};
|
||||||
|
document.getElementById("indexPx").textContent = fmt(meta.index_px || (call && call.index_px), 2);
|
||||||
|
document.getElementById("expiry").textContent = meta.expiry_ymd
|
||||||
|
? `到期 ${meta.expiry_ymd} · 行权 ${meta.strike||"—"}` : "—";
|
||||||
|
document.getElementById("raw").textContent = JSON.stringify({ health: h, latest: m }, null, 2);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("status").textContent = "请求失败";
|
||||||
|
document.getElementById("status").className = "value status-bad";
|
||||||
|
document.getElementById("raw").textContent = String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function boot() {
|
||||||
|
try {
|
||||||
|
const st = await fetch("/api/auth/status").then(r => r.json());
|
||||||
|
state.authRequired = !!st.auth_required;
|
||||||
|
if (state.authRequired && !getToken()) {
|
||||||
|
showLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showPage(state.page || "dash");
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById("raw").textContent = String(e);
|
||||||
|
showPage("dash");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
boot();
|
||||||
|
setInterval(() => {
|
||||||
|
if (state.page === "dash" && document.getElementById("page-login").classList.contains("hidden")) {
|
||||||
|
refreshDash();
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>比特骆驼行情采集分析</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "market-intel-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.26.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const TOKEN_KEY = "mi_token";
|
||||||
|
|
||||||
|
export function getToken(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token: string | null) {
|
||||||
|
try {
|
||||||
|
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
else localStorage.removeItem(TOKEN_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): HeadersInit {
|
||||||
|
const t = getToken();
|
||||||
|
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||||
|
const headers = {
|
||||||
|
...(init?.headers || {}),
|
||||||
|
...authHeaders(),
|
||||||
|
};
|
||||||
|
const r = await fetch(input, { ...init, headers, credentials: "include" });
|
||||||
|
if (r.status === 401) {
|
||||||
|
setToken(null);
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthStatus = { auth_required: boolean };
|
||||||
|
|
||||||
|
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
||||||
|
const r = await fetch("/api/auth/status", { credentials: "include" });
|
||||||
|
if (!r.ok) throw new Error("auth status failed");
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(password: string): Promise<{ ok: boolean; token?: string | null }> {
|
||||||
|
const r = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error("密码错误");
|
||||||
|
const body = await r.json();
|
||||||
|
if (body.token) setToken(body.token);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
setToken(null);
|
||||||
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Health = {
|
||||||
|
ok: boolean;
|
||||||
|
collector_lag_ms: number | null;
|
||||||
|
consecutive_failures: number;
|
||||||
|
option_quotes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Latest = {
|
||||||
|
call?: { leverage?: number; ask?: number; inst_id?: string; index_px?: number };
|
||||||
|
put?: { leverage?: number; ask?: number; inst_id?: string; index_px?: number };
|
||||||
|
heartbeat?: { meta?: { index_px?: number; expiry_ymd?: string; strike?: number } };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeverageBucket = {
|
||||||
|
bucket_start_min: number;
|
||||||
|
label: string;
|
||||||
|
n: number;
|
||||||
|
mean: number | null;
|
||||||
|
median: number | null;
|
||||||
|
p25: number | null;
|
||||||
|
p75: number | null;
|
||||||
|
pct_ge_min: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LeverageStats = {
|
||||||
|
status: string;
|
||||||
|
range: string;
|
||||||
|
date: string;
|
||||||
|
start_ymd: string;
|
||||||
|
end_ymd: string;
|
||||||
|
side: string;
|
||||||
|
sample_count: number;
|
||||||
|
min_leverage: number;
|
||||||
|
buckets: LeverageBucket[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MoveBucket = {
|
||||||
|
bucket_start_min: number;
|
||||||
|
label: string;
|
||||||
|
n: number;
|
||||||
|
mean_abs: number | null;
|
||||||
|
median_abs: number | null;
|
||||||
|
mean_signed: number | null;
|
||||||
|
median_signed: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MovePointsStats = {
|
||||||
|
status: string;
|
||||||
|
pending_expiry?: boolean;
|
||||||
|
pending_count?: number;
|
||||||
|
settled_count?: number;
|
||||||
|
sample_count?: number;
|
||||||
|
message?: string | null;
|
||||||
|
buckets: MoveBucket[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpsMap = {
|
||||||
|
leverage: LeverageStats;
|
||||||
|
move_points: MovePointsStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchHealth(): Promise<Health> {
|
||||||
|
const r = await fetch("/health");
|
||||||
|
if (!r.ok) throw new Error("health failed");
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLatest(): Promise<Latest> {
|
||||||
|
const r = await apiFetch("/api/meta/latest");
|
||||||
|
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "latest failed");
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOpsMap(params: {
|
||||||
|
range: string;
|
||||||
|
date?: string;
|
||||||
|
side?: string;
|
||||||
|
bucket_minutes?: number;
|
||||||
|
}): Promise<OpsMap> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set("range", params.range);
|
||||||
|
if (params.date) q.set("date", params.date);
|
||||||
|
if (params.side) q.set("side", params.side);
|
||||||
|
if (params.bucket_minutes) q.set("bucket_minutes", String(params.bucket_minutes));
|
||||||
|
const r = await apiFetch(`/api/stats/ops-map?${q}`);
|
||||||
|
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "ops-map failed");
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { LeverageBucket } from "../api/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
buckets: LeverageBucket[];
|
||||||
|
minLeverage: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LeverageChart({ buckets, minLeverage, title }: Props) {
|
||||||
|
const width = 880;
|
||||||
|
const height = 260;
|
||||||
|
const padL = 44;
|
||||||
|
const padR = 12;
|
||||||
|
const padT = 24;
|
||||||
|
const padB = 36;
|
||||||
|
const innerW = width - padL - padR;
|
||||||
|
const innerH = height - padT - padB;
|
||||||
|
|
||||||
|
const vals = buckets.map((b) => b.mean ?? 0);
|
||||||
|
const maxV = Math.max(minLeverage * 1.2, ...vals, 1);
|
||||||
|
const barW = innerW / Math.max(buckets.length, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-wrap">
|
||||||
|
<div className="chart-title">{title}</div>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="chart-svg" role="img">
|
||||||
|
<line
|
||||||
|
x1={padL}
|
||||||
|
y1={padT + innerH * (1 - minLeverage / maxV)}
|
||||||
|
x2={width - padR}
|
||||||
|
y2={padT + innerH * (1 - minLeverage / maxV)}
|
||||||
|
stroke="#e6a23c"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x={width - padR - 4}
|
||||||
|
y={padT + innerH * (1 - minLeverage / maxV) - 4}
|
||||||
|
fill="#e6a23c"
|
||||||
|
fontSize="10"
|
||||||
|
textAnchor="end"
|
||||||
|
>
|
||||||
|
min {minLeverage}
|
||||||
|
</text>
|
||||||
|
{buckets.map((b, i) => {
|
||||||
|
const v = b.mean ?? 0;
|
||||||
|
const h = b.n > 0 ? (v / maxV) * innerH : 0;
|
||||||
|
const x = padL + i * barW + barW * 0.15;
|
||||||
|
const y = padT + innerH - h;
|
||||||
|
const w = barW * 0.7;
|
||||||
|
const fill = b.n === 0 ? "#243041" : v >= minLeverage ? "#3ecf8e" : "#3d8fd1";
|
||||||
|
return (
|
||||||
|
<g key={b.bucket_start_min}>
|
||||||
|
<rect x={x} y={y} width={w} height={Math.max(h, b.n > 0 ? 2 : 0)} fill={fill} rx="2">
|
||||||
|
<title>
|
||||||
|
{b.label}: mean={b.mean?.toFixed(1) ?? "—"} n={b.n} median=
|
||||||
|
{b.median?.toFixed(1) ?? "—"}
|
||||||
|
</title>
|
||||||
|
</rect>
|
||||||
|
{i % 2 === 0 && (
|
||||||
|
<text
|
||||||
|
x={x + w / 2}
|
||||||
|
y={height - 10}
|
||||||
|
fill="#8b9aab"
|
||||||
|
fontSize="9"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{b.label.replace(":00", "")}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<line x1={padL} y1={padT} x2={padL} y2={padT + innerH} stroke="#243041" strokeWidth="1" />
|
||||||
|
<line
|
||||||
|
x1={padL}
|
||||||
|
y1={padT + innerH}
|
||||||
|
x2={width - padR}
|
||||||
|
y2={padT + innerH}
|
||||||
|
stroke="#243041"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text x={4} y={padT + 8} fill="#8b9aab" fontSize="10">
|
||||||
|
{maxV.toFixed(0)}
|
||||||
|
</text>
|
||||||
|
<text x={4} y={padT + innerH} fill="#8b9aab" fontSize="10">
|
||||||
|
0
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div className="chart-legend">
|
||||||
|
<span className="dot ok" /> ≥达标线均值
|
||||||
|
<span className="dot mid" /> <达标线
|
||||||
|
<span className="dot empty" /> 无样本
|
||||||
|
<span className="dot warn" /> 达标线
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { login } from "../api/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onOk: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LoginGate({ onOk }: Props) {
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setErr(null);
|
||||||
|
try {
|
||||||
|
await login(password);
|
||||||
|
onOk();
|
||||||
|
} catch (ex) {
|
||||||
|
setErr(String(ex));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<div className="brand">比特骆驼行情采集分析</div>
|
||||||
|
<div className="sub">需要登录后查看看板</div>
|
||||||
|
<form className="tile" onSubmit={submit} style={{ maxWidth: 360 }}>
|
||||||
|
<div className="label">管理员密码</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
padding: "0.55rem 0.65rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #243041",
|
||||||
|
background: "#0c1117",
|
||||||
|
color: "#e8eef5",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{err && <p style={{ color: "#e85d5d", fontSize: "0.85rem" }}>{err}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !password}
|
||||||
|
style={{
|
||||||
|
marginTop: "0.85rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: 0,
|
||||||
|
background: "#3d8fd1",
|
||||||
|
color: "#fff",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{busy ? "登录中…" : "登录"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { MoveBucket } from "../api/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
buckets: MoveBucket[];
|
||||||
|
title: string;
|
||||||
|
mode?: "abs" | "signed";
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MovePointsChart({ buckets, title, mode = "abs" }: Props) {
|
||||||
|
const width = 880;
|
||||||
|
const height = 260;
|
||||||
|
const padL = 44;
|
||||||
|
const padR = 12;
|
||||||
|
const padT = 24;
|
||||||
|
const padB = 36;
|
||||||
|
const innerW = width - padL - padR;
|
||||||
|
const innerH = height - padT - padB;
|
||||||
|
|
||||||
|
const vals = buckets.map((b) =>
|
||||||
|
mode === "abs" ? b.mean_abs ?? 0 : b.mean_signed ?? 0
|
||||||
|
);
|
||||||
|
const maxAbs = Math.max(...vals.map((v) => Math.abs(v)), 1);
|
||||||
|
const y0 = mode === "signed" ? padT + innerH / 2 : padT + innerH;
|
||||||
|
const scale = mode === "signed" ? innerH / 2 / maxAbs : innerH / maxAbs;
|
||||||
|
const barW = innerW / Math.max(buckets.length, 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-wrap">
|
||||||
|
<div className="chart-title">{title}</div>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="chart-svg" role="img">
|
||||||
|
{mode === "signed" && (
|
||||||
|
<line
|
||||||
|
x1={padL}
|
||||||
|
y1={y0}
|
||||||
|
x2={width - padR}
|
||||||
|
y2={y0}
|
||||||
|
stroke="#243041"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{buckets.map((b, i) => {
|
||||||
|
const v = mode === "abs" ? b.mean_abs ?? 0 : b.mean_signed ?? 0;
|
||||||
|
const h = b.n > 0 ? Math.abs(v) * scale : 0;
|
||||||
|
const x = padL + i * barW + barW * 0.15;
|
||||||
|
const y = mode === "signed" ? (v >= 0 ? y0 - h : y0) : y0 - h;
|
||||||
|
const w = barW * 0.7;
|
||||||
|
const fill =
|
||||||
|
b.n === 0 ? "#243041" : mode === "abs" ? "#9b7bff" : v >= 0 ? "#3ecf8e" : "#e85d5d";
|
||||||
|
return (
|
||||||
|
<g key={b.bucket_start_min}>
|
||||||
|
<rect x={x} y={y} width={w} height={Math.max(h, b.n > 0 ? 2 : 0)} fill={fill} rx="2">
|
||||||
|
<title>
|
||||||
|
{b.label}: abs={b.mean_abs?.toFixed(1) ?? "—"} signed=
|
||||||
|
{b.mean_signed?.toFixed(1) ?? "—"} n={b.n}
|
||||||
|
</title>
|
||||||
|
</rect>
|
||||||
|
{i % 2 === 0 && (
|
||||||
|
<text
|
||||||
|
x={x + w / 2}
|
||||||
|
y={height - 10}
|
||||||
|
fill="#8b9aab"
|
||||||
|
fontSize="9"
|
||||||
|
textAnchor="middle"
|
||||||
|
>
|
||||||
|
{b.label.replace(":00", "")}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<line x1={padL} y1={padT} x2={padL} y2={padT + innerH} stroke="#243041" strokeWidth="1" />
|
||||||
|
<line
|
||||||
|
x1={padL}
|
||||||
|
y1={padT + innerH}
|
||||||
|
x2={width - padR}
|
||||||
|
y2={padT + innerH}
|
||||||
|
stroke="#243041"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
<text x={4} y={padT + 8} fill="#8b9aab" fontSize="10">
|
||||||
|
{mode === "signed" ? maxAbs.toFixed(0) : maxAbs.toFixed(0)}
|
||||||
|
</text>
|
||||||
|
<text x={4} y={padT + innerH} fill="#8b9aab" fontSize="10">
|
||||||
|
{mode === "signed" ? `-${maxAbs.toFixed(0)}` : "0"}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
<div className="chart-legend">
|
||||||
|
{mode === "abs" ? (
|
||||||
|
<>
|
||||||
|
<span className="dot" style={{ background: "#9b7bff" }} /> 绝对波动均值
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="dot ok" /> 上涨
|
||||||
|
<span className="dot" style={{ background: "#e85d5d" }} /> 下跌
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="dot empty" /> 无样本/未到期
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import Dashboard from "./pages/Dashboard";
|
||||||
|
import OpsMap from "./pages/OpsMap";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/ops-map" element={<OpsMap />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
fetchAuthStatus,
|
||||||
|
fetchHealth,
|
||||||
|
fetchLatest,
|
||||||
|
Health,
|
||||||
|
Latest,
|
||||||
|
logout,
|
||||||
|
} from "../api/client";
|
||||||
|
import LoginGate from "../components/LoginGate";
|
||||||
|
|
||||||
|
function fmt(n?: number | null, d = 1) {
|
||||||
|
if (n == null || Number.isNaN(n)) return "—";
|
||||||
|
return n.toFixed(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [needLogin, setNeedLogin] = useState(false);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [health, setHealth] = useState<Health | null>(null);
|
||||||
|
const [latest, setLatest] = useState<Latest | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const bootstrap = async () => {
|
||||||
|
const st = await fetchAuthStatus();
|
||||||
|
if (!st.auth_required) {
|
||||||
|
setNeedLogin(false);
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetchLatest();
|
||||||
|
setNeedLogin(false);
|
||||||
|
} catch {
|
||||||
|
setNeedLogin(true);
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
bootstrap().catch((e) => setErr(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || needLogin) return;
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [h, m] = await Promise.all([fetchHealth(), fetchLatest()]);
|
||||||
|
if (!alive) return;
|
||||||
|
setHealth(h);
|
||||||
|
setLatest(m);
|
||||||
|
setErr(null);
|
||||||
|
} catch (e) {
|
||||||
|
if (!alive) return;
|
||||||
|
const msg = String(e);
|
||||||
|
if (msg.includes("unauthorized")) setNeedLogin(true);
|
||||||
|
else setErr(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 10000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, [ready, needLogin]);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<div className="sub">加载中…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (needLogin) {
|
||||||
|
return (
|
||||||
|
<LoginGate
|
||||||
|
onOk={() => {
|
||||||
|
setNeedLogin(false);
|
||||||
|
setErr(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lag = health?.collector_lag_ms;
|
||||||
|
const ok =
|
||||||
|
!!health?.ok && (lag == null || lag < 120_000) && (health.consecutive_failures || 0) < 5;
|
||||||
|
const meta = latest?.heartbeat?.meta;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<div className="brand">比特骆驼行情采集分析</div>
|
||||||
|
<div className="sub">只读采集 · 杠杆 = 指数 ÷ 卖一 · Asia/Shanghai</div>
|
||||||
|
<div className="nav">
|
||||||
|
<Link to="/">总览</Link>
|
||||||
|
<Link to="/ops-map">作战地图</Link>
|
||||||
|
<a
|
||||||
|
href="#logout"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
logout().finally(() => setNeedLogin(true));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
|
||||||
|
<div className="row">
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">采集状态</div>
|
||||||
|
<div className="value" style={{ color: ok ? "var(--ok)" : "var(--warn)" }}>
|
||||||
|
{health ? (ok ? "正常" : "异常/等待") : "…"}
|
||||||
|
</div>
|
||||||
|
<div className="hint">
|
||||||
|
{lag == null
|
||||||
|
? "尚无采样"
|
||||||
|
: `延迟 ${Math.round(lag / 1000)}s · 样本 ${health?.option_quotes ?? 0}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">ATM Call 杠杆</div>
|
||||||
|
<div className="value">{fmt(latest?.call?.leverage)}</div>
|
||||||
|
<div className="hint">{latest?.call?.inst_id ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">ATM Put 杠杆</div>
|
||||||
|
<div className="value">{fmt(latest?.put?.leverage)}</div>
|
||||||
|
<div className="hint">{latest?.put?.inst_id ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">指数</div>
|
||||||
|
<div className="value">{fmt(meta?.index_px ?? latest?.call?.index_px, 2)}</div>
|
||||||
|
<div className="hint">
|
||||||
|
{meta?.expiry_ymd ? `到期 ${meta.expiry_ymd} · 行权 ${meta.strike ?? "—"}` : "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
fetchAuthStatus,
|
||||||
|
fetchOpsMap,
|
||||||
|
LeverageStats,
|
||||||
|
logout,
|
||||||
|
MovePointsStats,
|
||||||
|
OpsMap,
|
||||||
|
} from "../api/client";
|
||||||
|
import LeverageChart from "../components/LeverageChart";
|
||||||
|
import LoginGate from "../components/LoginGate";
|
||||||
|
import MovePointsChart from "../components/MovePointsChart";
|
||||||
|
|
||||||
|
type RangeKey = "day" | "week" | "month";
|
||||||
|
type SideKey = "both" | "C" | "P";
|
||||||
|
|
||||||
|
function todayYmd() {
|
||||||
|
const d = new Date();
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OpsMapPage() {
|
||||||
|
const [needLogin, setNeedLogin] = useState(false);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [range, setRange] = useState<RangeKey>("day");
|
||||||
|
const [side, setSide] = useState<SideKey>("both");
|
||||||
|
const [date, setDate] = useState(todayYmd());
|
||||||
|
const [moveMode, setMoveMode] = useState<"abs" | "signed">("abs");
|
||||||
|
const [data, setData] = useState<OpsMap | null>(null);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAuthStatus()
|
||||||
|
.then(async (st) => {
|
||||||
|
if (!st.auth_required) {
|
||||||
|
setNeedLogin(false);
|
||||||
|
setReady(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fetchOpsMap({ range: "day", bucket_minutes: 60 });
|
||||||
|
setNeedLogin(false);
|
||||||
|
} catch {
|
||||||
|
setNeedLogin(true);
|
||||||
|
}
|
||||||
|
setReady(true);
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(String(e)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || needLogin) return;
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
fetchOpsMap({ range, date, side, bucket_minutes: 60 })
|
||||||
|
.then((d) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setData(d);
|
||||||
|
setErr(null);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!alive) return;
|
||||||
|
const msg = String(e);
|
||||||
|
if (msg.includes("unauthorized")) setNeedLogin(true);
|
||||||
|
else setErr(msg);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [range, side, date, ready, needLogin]);
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<div className="sub">加载中…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (needLogin) {
|
||||||
|
return <LoginGate onOk={() => setNeedLogin(false)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lev: LeverageStats | undefined = data?.leverage;
|
||||||
|
const mov: MovePointsStats | undefined = data?.move_points;
|
||||||
|
const rangeLabel = range === "day" ? "日" : range === "week" ? "近7日" : "近30日";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layout">
|
||||||
|
<div className="brand">比特骆驼行情采集分析</div>
|
||||||
|
<div className="sub">作战地图 · 杠杆 × 时段 → 到期波动</div>
|
||||||
|
<div className="nav">
|
||||||
|
<Link to="/">总览</Link>
|
||||||
|
<Link to="/ops-map">作战地图</Link>
|
||||||
|
<a
|
||||||
|
href="#logout"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
logout().finally(() => setNeedLogin(true));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
退出
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="seg">
|
||||||
|
{(["day", "week", "month"] as RangeKey[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
className={range === k ? "active" : ""}
|
||||||
|
onClick={() => setRange(k)}
|
||||||
|
>
|
||||||
|
{k === "day" ? "日" : k === "week" ? "周" : "月"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="seg">
|
||||||
|
{(["both", "C", "P"] as SideKey[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
className={side === k ? "active" : ""}
|
||||||
|
onClick={() => setSide(k)}
|
||||||
|
>
|
||||||
|
{k === "both" ? "双边" : k === "C" ? "Call" : "Put"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label className="date-field">
|
||||||
|
锚点日
|
||||||
|
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
|
||||||
|
{loading && !data && <p style={{ color: "var(--muted)" }}>加载中…</p>}
|
||||||
|
|
||||||
|
{lev && (
|
||||||
|
<>
|
||||||
|
<div className="row" style={{ marginBottom: "1rem" }}>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">范围</div>
|
||||||
|
<div className="value" style={{ fontSize: "1.1rem" }}>
|
||||||
|
{lev.start_ymd} → {lev.end_ymd}
|
||||||
|
</div>
|
||||||
|
<div className="hint">杠杆样本 {lev.sample_count}</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">达标线</div>
|
||||||
|
<div className="value">{lev.min_leverage}</div>
|
||||||
|
<div className="hint">杠杆 = 指数 ÷ 卖一</div>
|
||||||
|
</div>
|
||||||
|
<div className="tile">
|
||||||
|
<div className="label">波动样本</div>
|
||||||
|
<div className="value">{mov?.settled_count ?? 0}</div>
|
||||||
|
<div className="hint">
|
||||||
|
{mov?.pending_expiry
|
||||||
|
? `pending ${mov.pending_count ?? 0}(未到期已排除)`
|
||||||
|
: "全部已结算"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LeverageChart
|
||||||
|
buckets={lev.buckets}
|
||||||
|
minLeverage={lev.min_leverage}
|
||||||
|
title={`上图 · 时段杠杆均值(${rangeLabel})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="toolbar" style={{ marginTop: "1.25rem" }}>
|
||||||
|
<div className="seg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={moveMode === "abs" ? "active" : ""}
|
||||||
|
onClick={() => setMoveMode("abs")}
|
||||||
|
>
|
||||||
|
绝对波动
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={moveMode === "signed" ? "active" : ""}
|
||||||
|
onClick={() => setMoveMode("signed")}
|
||||||
|
>
|
||||||
|
带符号
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{mov?.pending_expiry && (
|
||||||
|
<span style={{ color: "var(--warn)", fontSize: "0.85rem" }}>
|
||||||
|
pending_expiry=true
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MovePointsChart
|
||||||
|
buckets={mov?.buckets ?? []}
|
||||||
|
mode={moveMode}
|
||||||
|
title={`下图 · 时段→到期波动(${rangeLabel} · ${
|
||||||
|
moveMode === "abs" ? "abs" : "signed"
|
||||||
|
})`}
|
||||||
|
/>
|
||||||
|
{mov?.message && (
|
||||||
|
<p style={{ color: "var(--muted)", fontSize: "0.85rem", marginTop: "0.75rem" }}>
|
||||||
|
{mov.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0c1117;
|
||||||
|
--panel: #151b24;
|
||||||
|
--text: #e8eef5;
|
||||||
|
--muted: #8b9aab;
|
||||||
|
--accent: #3d8fd1;
|
||||||
|
--ok: #3ecf8e;
|
||||||
|
--warn: #e6a23c;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 600px at 10% -10%, #1a2a3d 0%, transparent 55%),
|
||||||
|
var(--bg);
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
.layout { max-width: 980px; margin: 0 auto; padding: 1.5rem; }
|
||||||
|
.brand { font-size: 1.35rem; font-weight: 700; }
|
||||||
|
.sub { color: var(--muted); margin: 0.35rem 0 1.25rem; }
|
||||||
|
.nav { display: flex; gap: 1rem; margin-bottom: 1.25rem; }
|
||||||
|
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; }
|
||||||
|
.tile {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid #243041;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.label { color: var(--muted); font-size: 0.8rem; }
|
||||||
|
.value { margin-top: 0.35rem; font-size: 1.5rem; font-weight: 650; }
|
||||||
|
.hint { margin-top: 0.3rem; color: var(--muted); font-size: 0.78rem; }
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.seg {
|
||||||
|
display: inline-flex;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid #243041;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.seg button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.seg button.active {
|
||||||
|
background: #1e2a3a;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.date-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.date-field input {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid #243041;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-wrap {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid #243041;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0.75rem 0.5rem 0.5rem;
|
||||||
|
}
|
||||||
|
.chart-title {
|
||||||
|
padding: 0 0.75rem 0.25rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.chart-svg { width: 100%; height: auto; display: block; }
|
||||||
|
.chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.85rem;
|
||||||
|
padding: 0.25rem 0.75rem 0.5rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.dot.ok { background: var(--ok); }
|
||||||
|
.dot.mid { background: var(--accent); }
|
||||||
|
.dot.empty { background: #243041; }
|
||||||
|
.dot.warn { background: var(--warn); }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": 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:5170",
|
||||||
|
"/health": "http://127.0.0.1:5170",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
# 比特骆驼行情采集分析 — 开发方案
|
||||||
|
|
||||||
|
> **独立仓库**,与 `eth_hedge_sim`(策略 / 中控)**无代码共用、无进程共用、无交易密钥共用**。
|
||||||
|
> 本系统只做:**行情采集 → 落库 → 分析统计 → 只读展示 / API**。
|
||||||
|
> **不下单、不持仓、不替代策略选约。**
|
||||||
|
> Git 仓库由负责人在 `git.bz121.com` 创建;本目录为方案与后续工程落点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 命名与定位
|
||||||
|
|
||||||
|
| 项 | 约定 |
|
||||||
|
|----|------|
|
||||||
|
| **产品名** | **比特骆驼行情采集分析** |
|
||||||
|
| **项目名称(对外)** | 比特骆驼行情采集分析系统 |
|
||||||
|
| **仓库名称 / 工程名** | `market_intel` |
|
||||||
|
| **Git 地址(拟)** | `https://git.bz121.com/dekun/market_intel.git` |
|
||||||
|
| **安装目录(生产)** | `/opt/market_intel` |
|
||||||
|
| **部署操作系统** | **Ubuntu 22.04 LTS**(与策略仓一键脚本一致) |
|
||||||
|
| **运行方式** | **Docker Compose**(采集 + API + Web 同栈编排) |
|
||||||
|
| **一键入口** | `deploy/manage.sh` 交互菜单(对齐 `eth_hedge_sim`) |
|
||||||
|
|
||||||
|
### 1.1 硬边界
|
||||||
|
|
||||||
|
- **只读行情**:REST / WebSocket;禁止任何交易类 API。
|
||||||
|
- **与策略解耦**:策略机挂了,本系统仍可继续采;本系统挂了,策略仍可独立交易。
|
||||||
|
- **第一期标的**:ETH(OKX 指数 + ETH-USD_UM 期权盘口);架构预留多币种 / 多所,但实现标准先钉死 OKX ETH。
|
||||||
|
- **时区**:统计与「日 / 周 / 月」切分一律 **Asia/Shanghai**。
|
||||||
|
|
||||||
|
### 1.2 核心分析目标(作战地图数据底座)
|
||||||
|
|
||||||
|
1. **期权杠杆 × 日内时段**
|
||||||
|
口径:`杠杆 = 标的指数 ÷ 期权卖一`(与策略选约门限一致)。
|
||||||
|
2. **该时段 → 期权到期 的波动点数**
|
||||||
|
口径:到期时刻指数 − 该时段代表指数(可同时存带符号与绝对值)。
|
||||||
|
3. **范围**:按 **日 / 周 / 月** 过滤样本,横轴均为 **日内时段桶**(默认 1 小时)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 实现标准
|
||||||
|
|
||||||
|
### 2.1 技术栈
|
||||||
|
|
||||||
|
| 层 | 标准 |
|
||||||
|
|----|------|
|
||||||
|
| 语言 | Python 3.11+(采集 / API / 聚合) |
|
||||||
|
| Web API | FastAPI |
|
||||||
|
| 前端 | React + Vite(只读看板:采集状态、时段图、日周月切换) |
|
||||||
|
| 数据库 | 第一期 **SQLite**(volume 持久化);上量后可换 Postgres,表结构先按可迁移设计 |
|
||||||
|
| 容器 | Docker + Docker Compose v2 |
|
||||||
|
| 反向代理(可选) | 同 Compose 内 Caddy/Nginx,或宿主机已有反代 |
|
||||||
|
| 配置 | 仓库根 `.env`(密钥、交易所、采样间隔、端口);**已有非空值不覆盖** |
|
||||||
|
|
||||||
|
### 2.2 代码与工程规范
|
||||||
|
|
||||||
|
- 仓库根英文名固定 `market_intel`;文档可用中文。
|
||||||
|
- 配置项集中、可环境变量覆盖;禁止把 API Key 写进镜像层。
|
||||||
|
- 采集与 API **可同容器或分服务**;Compose 内用服务名互访。
|
||||||
|
- 所有时间戳存 **UTC ms**;展示与「自然日」按上海转换。
|
||||||
|
- 杠杆 / 波动口径写进代码常量 + 本文档,变更需改版本号与迁移说明。
|
||||||
|
- 日志:结构化或按日滚动;脱敏(不打完整密钥)。
|
||||||
|
- 测试:采集解析、杠杆计算、时段聚合、到期回填 有单元测试。
|
||||||
|
|
||||||
|
### 2.3 采集标准(第一期)
|
||||||
|
|
||||||
|
| 项 | 标准 |
|
||||||
|
|----|------|
|
||||||
|
| 交易所 | OKX(只读) |
|
||||||
|
| 指数 | ETH-USD 指数(或与策略一致的 index) |
|
||||||
|
| 期权 | 最近合资格到期的 ATM Call + ATM Put(规则文档化:最接近指数的行权价) |
|
||||||
|
| 杠杆采样间隔 | 默认 **30s**(可配 15–120s) |
|
||||||
|
| 指数采样 | 可与杠杆同频,或单独 **60s** |
|
||||||
|
| 字段最小集 | `ts_ms, exchange, underlying, expiry_ymd, strike, side(C/P), index_px, ask, bid, ask_sz, bid_sz, leverage, inst_id` |
|
||||||
|
| 失败策略 | 单次失败记日志并跳过;连续失败告警(可选企微,后期) |
|
||||||
|
|
||||||
|
### 2.4 统计标准
|
||||||
|
|
||||||
|
| 项 | 标准 |
|
||||||
|
|----|------|
|
||||||
|
| 时段桶 | 默认 **1 小时**(0–23,上海);可扩展 30 分钟 |
|
||||||
|
| 日 | 上海自然日 `YYYY-MM-DD` |
|
||||||
|
| 周 | **滚动近 7 个上海自然日**(第一期);后期可加自然周 |
|
||||||
|
| 月 | **滚动近 30 日** 或自然月(设置可选,默认滚动 30 日) |
|
||||||
|
| 杠杆聚合 | 桶内:样本数、均值、中位数、P25/P75、≥`min_leverage` 占比 |
|
||||||
|
| 波动点数 | 到期后回填;桶内:均值/中位/分位;同时提供 **signed** 与 **abs** |
|
||||||
|
| 未到期 | API 标记 `pending_expiry=true`,不假装有完整「到到期」分布 |
|
||||||
|
|
||||||
|
### 2.5 安全与权限
|
||||||
|
|
||||||
|
- 仅行情只读 Key(若需要);无交易权限。
|
||||||
|
- Web / API 默认需登录或 Token(对齐策略仓简单鉴权即可)。
|
||||||
|
- 局域网部署时可绑 `127.0.0.1` / 内网 IP;公网必须 HTTPS + 强密码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 系统架构
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ market_intel(本仓库 · Docker) │
|
||||||
|
OKX 行情 ────────►│ collector → SQLite/DB │
|
||||||
|
(REST/WS) │ analytics(日/周/月 · 时段聚合) │
|
||||||
|
│ api + web(只读看板) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 可选:只读 API
|
||||||
|
▼
|
||||||
|
人工浏览器 / 其它系统(后期)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **不**嵌入 `eth_hedge_sim` 进程。
|
||||||
|
- 后期若中控要展示,由中控 **HTTP 调用本系统 API**,本仓仍独立演进。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 代码结构(目标仓库)
|
||||||
|
|
||||||
|
```text
|
||||||
|
market_intel/
|
||||||
|
├── README.md
|
||||||
|
├── 开发方案.md # 可从本文件迁入 docs/
|
||||||
|
├── .env.example
|
||||||
|
├── .gitignore
|
||||||
|
├── docker-compose.yml # 一键编排入口
|
||||||
|
├── Dockerfile # API + 采集(或多阶段)
|
||||||
|
├── requirements.txt
|
||||||
|
├── deploy/
|
||||||
|
│ ├── manage.sh # 交互式菜单(curl | bash)
|
||||||
|
│ ├── bootstrap.sh # 缺 git/docker 时引导
|
||||||
|
│ └── lib/
|
||||||
|
│ ├── common.sh # 日志、读入、路径、.env 合并
|
||||||
|
│ ├── install.sh # 一键部署(clone + compose up)
|
||||||
|
│ ├── update.sh # git pull + compose build/up
|
||||||
|
│ └── uninstall.sh # 停容器;可选保留 data volume
|
||||||
|
├── apps/
|
||||||
|
│ ├── collector/ # 行情采集进程
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── main.py # 入口:循环 / WS
|
||||||
|
│ │ ├── okx_rest.py
|
||||||
|
│ │ ├── okx_ws.py
|
||||||
|
│ │ └── selectors.py # ATM / 到期选择
|
||||||
|
│ ├── api/ # FastAPI
|
||||||
|
│ │ ├── main.py
|
||||||
|
│ │ ├── routes/
|
||||||
|
│ │ │ ├── health.py
|
||||||
|
│ │ │ ├── samples.py # 原始/明细(调试)
|
||||||
|
│ │ │ └── stats.py # 日周月 · 杠杆 · 波动
|
||||||
|
│ │ └── auth.py
|
||||||
|
│ └── worker/ # 可选:到期回填、日终聚合
|
||||||
|
├── packages/
|
||||||
|
│ ├── db/ # schema、迁移、repository
|
||||||
|
│ ├── domain/ # option_leverage、bucket、move_points
|
||||||
|
│ └── config/ # settings from env
|
||||||
|
├── web/ # React 看板
|
||||||
|
│ ├── package.json
|
||||||
|
│ └── src/
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── Dashboard.tsx # 采集心跳、最新杠杆
|
||||||
|
│ │ └── OpsMap.tsx # 作战地图:杠杆 + 波动 · 日/周/月
|
||||||
|
│ └── api/
|
||||||
|
├── data/ # 本地/挂载:SQLite(.gitignore 内容)
|
||||||
|
├── scripts/
|
||||||
|
│ ├── smoke_collect.py
|
||||||
|
│ └── backfill_index.py # 可选:历史指数回填波动
|
||||||
|
└── tests/
|
||||||
|
├── test_leverage.py
|
||||||
|
├── test_buckets.py
|
||||||
|
└── test_move_points.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> 实现时可把 `apps/` 收成单包 `src/market_intel/`,但 **deploy / docker / web / 采集与 API 分离** 的边界保持不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 数据模型(摘要)
|
||||||
|
|
||||||
|
### 5.1 `option_quotes`(杠杆明细)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| id | 自增 |
|
||||||
|
| ts_ms | UTC |
|
||||||
|
| exchange | `okx` |
|
||||||
|
| inst_id | 合约 ID |
|
||||||
|
| expiry_ymd | `YYMMDD` |
|
||||||
|
| strike | 行权价 |
|
||||||
|
| side | `C` / `P` |
|
||||||
|
| index_px | 指数 |
|
||||||
|
| ask / bid | 卖一 / 买一 |
|
||||||
|
| ask_sz / bid_sz | 可选 |
|
||||||
|
| leverage | `index_px / ask`(ask>0) |
|
||||||
|
|
||||||
|
### 5.2 `index_ticks`(指数明细)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| ts_ms | UTC |
|
||||||
|
| underlying | `ETH` |
|
||||||
|
| index_px | 指数 |
|
||||||
|
|
||||||
|
### 5.3 `expiry_settlements`(到期锚点)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| expiry_ymd | 到期日 |
|
||||||
|
| settle_ts_ms | 到期时刻(OKX:UTC 08:00) |
|
||||||
|
| settle_index_px | 结算/到期指数 |
|
||||||
|
|
||||||
|
波动点数:对历史某桶代表时刻 \(t\),
|
||||||
|
`move = settle_index_px - index_at(t)`(同 `expiry_ymd`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. API 约定(第一期)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/health` | 存活;可选返回采集延迟 |
|
||||||
|
| GET | `/api/stats/leverage` | `range=day\|week\|month` + 日期;返回各时段桶聚合 |
|
||||||
|
| GET | `/api/stats/move_points` | 同上;返回时段→到期波动 |
|
||||||
|
| GET | `/api/stats/ops-map` | 一次返回杠杆 + 波动(看板主接口) |
|
||||||
|
| GET | `/api/meta/latest` | 最新一条 Call/Put 杠杆、采集时间 |
|
||||||
|
|
||||||
|
查询参数统一:`range`、`date`(锚点日)、`side=C|P|both`、`bucket_minutes=60`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Docker 运行标准
|
||||||
|
|
||||||
|
### 7.1 服务划分(Compose)
|
||||||
|
|
||||||
|
| 服务名 | 职责 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `collector` | 写库 | 重启策略 `unless-stopped` |
|
||||||
|
| `api` | FastAPI + 静态前端(或挂 `web` 构建产物) | 默认端口 **5170**(可配,避开策略 5155 / 中控 5160) |
|
||||||
|
| `db` | 第一期可省略(SQLite 挂 volume) | 后期 Postgres 再加 |
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml 示意(实现时落地)
|
||||||
|
services:
|
||||||
|
collector:
|
||||||
|
build: .
|
||||||
|
command: ["python", "-m", "apps.collector.main"]
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- mi_data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
command: ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "5170"]
|
||||||
|
env_file: .env
|
||||||
|
ports:
|
||||||
|
- "${MI_PORT:-5170}:5170"
|
||||||
|
volumes:
|
||||||
|
- mi_data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- collector
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
mi_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 本地开发(非必须 Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
python -m apps.collector.main # 终端 1
|
||||||
|
uvicorn apps.api.main:app --reload # 终端 2
|
||||||
|
cd web && npm i && npm run dev # 终端 3
|
||||||
|
```
|
||||||
|
|
||||||
|
**生产标准路径以 Docker Compose 为准。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 一键部署(对齐 eth_hedge_sim)
|
||||||
|
|
||||||
|
### 8.1 新机器(免克隆)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://git.bz121.com/dekun/market_intel/raw/branch/main/deploy/manage.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:Ubuntu 22.04;脚本内检测并安装 **Git、Docker、Docker Compose 插件**(已有则跳过)。
|
||||||
|
|
||||||
|
### 8.2 已安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash /opt/market_intel/deploy/manage.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 交互菜单(实现标准)
|
||||||
|
|
||||||
|
与策略仓 `deploy/manage.sh` 同级体验:**数字选项 + 读 `/dev/tty`**,支持 `curl | bash`。
|
||||||
|
|
||||||
|
| 选项 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| **1) 一键部署** | 检测 Docker → clone 到 `/opt/market_intel` → 生成/补全 `.env`(已有非空不覆盖)→ `docker compose up -d --build` → 健康检查 |
|
||||||
|
| **2) 更新** | `git pull` + `compose build/up`;保留 `.env` 与 data volume |
|
||||||
|
| **3) 停止** | `docker compose stop` |
|
||||||
|
| **4) 启动** | `docker compose start` / `up -d` |
|
||||||
|
| **5) 查看状态** | `compose ps` + `/health` |
|
||||||
|
| **6) 一键卸载** | 停容器;询问是否删除 data volume;备份 `.env` 到 `/root/backups/market_intel/`;按确认删除 `/opt/market_intel` |
|
||||||
|
| **0) 退出** | — |
|
||||||
|
|
||||||
|
已存在安装目录时,选项 1 进入子菜单:**取消 / 修复(保留 .env 与数据)**,行为对齐策略仓 `install.sh`。
|
||||||
|
|
||||||
|
### 8.4 `.env` 交互补全(首次部署)
|
||||||
|
|
||||||
|
脚本可交互询问(有默认值,回车采用默认):
|
||||||
|
|
||||||
|
- `OKX` API Key / Secret / Passphrase(只读;可留空若仅用公开行情)
|
||||||
|
- `MI_PORT`(默认 `5170`)
|
||||||
|
- `SAMPLE_INTERVAL_SEC`(默认 `30`)
|
||||||
|
- `MIN_OPTION_LEVERAGE`(统计达标线,默认 `100`)
|
||||||
|
- 管理员密码 / `AUTH_SECRET`
|
||||||
|
|
||||||
|
原则:**文件中已有非空值不覆盖**(对齐中控 `.env.control` 行为)。
|
||||||
|
|
||||||
|
### 8.5 部署后验收
|
||||||
|
|
||||||
|
1. `curl -fsS http://127.0.0.1:5170/health` 返回 ok
|
||||||
|
2. 等待 ≥1 个采样周期后,库中有 `option_quotes` 行
|
||||||
|
3. 打开 Web 看板能看到最新杠杆
|
||||||
|
4. `manage.sh` → 更新 → 容器重建后数据 volume 仍在
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Web 看板(第一期页面)
|
||||||
|
|
||||||
|
| 页面 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 总览 | 采集是否正常、延迟、当前 ATM Call/Put 杠杆 |
|
||||||
|
| 作战地图 | 切换 **日 / 周 / 月**;上图时段杠杆;下图时段→到期波动点数 |
|
||||||
|
| 设置(简) | 只读展示当前采样参数(改参走 `.env` + 更新重启) |
|
||||||
|
|
||||||
|
UI 要求:暗色可与策略仓风格接近,但 **独立品牌标题「行情采集分析」**,避免与对冲策略页混淆。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 分期计划
|
||||||
|
|
||||||
|
| 阶段 | 交付 |
|
||||||
|
|------|------|
|
||||||
|
| **P0** | 仓库骨架、Docker Compose、manage.sh 菜单、健康检查 |
|
||||||
|
| **P1** | OKX 指数 + ATM Call/Put 采样落库 |
|
||||||
|
| **P2** | `/api/stats/leverage` 日周月;Web 作战地图杠杆图 |
|
||||||
|
| **P3** | 到期回填 + 波动点数统计与下图 |
|
||||||
|
| **P4** | 鉴权加固、企微采集异常推送、可选历史指数回填 |
|
||||||
|
| **P5** | (可选)中控只读嵌入;多 underlying |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 与 `eth_hedge_sim` 的关系(再强调)
|
||||||
|
|
||||||
|
| | `eth_hedge_sim` | `market_intel`(本仓) |
|
||||||
|
|--|-----------------|------------------------|
|
||||||
|
| 职责 | 对冲交易 / 中控运维 | 行情采集与统计分析 |
|
||||||
|
| 运行 | PM2(现状) | **Docker Compose** |
|
||||||
|
| 端口 | 5155 / 5160 | **5170**(默认) |
|
||||||
|
| 密钥 | 可含交易权限 | **仅只读行情** |
|
||||||
|
| 依赖 | 互不依赖 | 互不依赖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 验收清单(方案级)
|
||||||
|
|
||||||
|
- [ ] 仓库名 `market_intel`,产品名「比特骆驼行情采集分析」
|
||||||
|
- [ ] `curl | bash` 出交互菜单,可一键部署 / 更新 / 卸载
|
||||||
|
- [ ] 全程 Docker 运行,数据落 volume
|
||||||
|
- [ ] 杠杆口径 = 指数 ÷ 卖一;时段统计支持日 / 周 / 月
|
||||||
|
- [ ] 波动点数 = 时段指数 → 到期指数;未到期显式标记
|
||||||
|
- [ ] 零交易 API;与策略仓进程隔离
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 文档修订
|
||||||
|
|
||||||
|
| 日期 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-07-31 | 初稿:独立仓、Docker、交互式一键部署、作战地图数据标准 |
|
||||||
Reference in New Issue
Block a user