first commit

This commit is contained in:
dekun
2026-08-01 10:33:19 +08:00
commit d9a34d4f20
72 changed files with 5499 additions and 0 deletions
+38
View File
@@ -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.dbvolume 持久化)
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
View File
@@ -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
View File
@@ -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
# 默认 APICompose 里 collector 覆盖 command
CMD ["uvicorn", "apps.api.main:app", "--host", "0.0.0.0", "--port", "5170"]
+92
View File
@@ -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` | 密码换 tokenCookie + 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 | 鉴权加固、企微告警 |
## 许可
内部项目。
+1
View File
@@ -0,0 +1 @@
# Collector package
+1
View File
@@ -0,0 +1 @@
# API package
+87
View File
@@ -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)
+70
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
# routes package
+48
View File
@@ -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}
+33
View File
@@ -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()
+36
View File
@@ -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()
+34
View File
@@ -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}
+29
View File
@@ -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()
+113
View File
@@ -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,
}
+1
View File
@@ -0,0 +1 @@
# OKX market collector
+173
View File
@@ -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()
+154
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
"""WebSocket 占位(P1 用 RESTWS 后期可接)。"""
from __future__ import annotations
# 第一期采集走 REST 轮询;此模块预留多路订阅入口。
+157
View File
@@ -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"],
)
+1
View File
@@ -0,0 +1 @@
# worker package — 到期回填 / 日终聚合
+82
View File
@@ -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()
+187
View File
@@ -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",
]
+1
View File
@@ -0,0 +1 @@
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# 兼容入口:转发到 manage.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
exec bash "${ROOT}/deploy/manage.sh" "$@"
+358
View File
@@ -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 ""
}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# deploy/lib/install.sh — 一键部署 market_intelDocker 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 "$@"
+105
View File
@@ -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 "$@"
+41
View File
@@ -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 "$@"
+228
View File
@@ -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 "$@"
+56
View File
@@ -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:
+7
View File
@@ -0,0 +1,7 @@
"""配置:从环境变量加载。"""
from __future__ import annotations
from packages.config.settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]
+3
View File
@@ -0,0 +1,3 @@
from packages.config.settings import Settings, get_settings
__all__ = ["Settings", "get_settings"]
+62
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
from packages.db.schema import init_db
from packages.db.repository import Repository
__all__ = ["init_db", "Repository"]
+275
View File
@@ -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
+75
View File
@@ -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
+29
View File
@@ -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",
]
+342
View File
@@ -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,
}
+41
View File
@@ -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:
"""
日内时段桶起点分钟01439
默认 60 0,60,120,...,1380即小时 023
"""
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:
"""023 小时桶。"""
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")
+15
View File
@@ -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)
+14
View File
@@ -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 / askask 无效时返回 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)
+10
View File
@@ -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)
+77
View File
@@ -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,
}
+3
View File
@@ -0,0 +1,3 @@
from packages.notify import wecom
__all__ = ["wecom"]
+163
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
+7
View File
@@ -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
+34
View File
@@ -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())
+43
View File
@@ -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())
+65
View File
@@ -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
+44
View File
@@ -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)
+27
View File
@@ -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
+13
View File
@@ -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
+57
View File
@@ -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
+18
View File
@@ -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
+27
View File
@@ -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")
+56
View File
@@ -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()
+343
View File
@@ -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>
+12
View File
@@ -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>
+23
View File
@@ -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"
}
}
+150
View File
@@ -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();
}
+98
View File
@@ -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" /> &lt;线
<span className="dot empty" />
<span className="dot warn" /> 线
</div>
</div>
);
}
+67
View File
@@ -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>
);
}
+102
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
+143
View File
@@ -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>
);
}
+219
View File
@@ -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>
);
}
+108
View File
@@ -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); }
+17
View File
@@ -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"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:5170",
"/health": "http://127.0.0.1:5170",
},
},
build: {
outDir: "dist",
emptyOutDir: true,
},
});
+383
View File
@@ -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。
- **与策略解耦**:策略机挂了,本系统仍可继续采;本系统挂了,策略仍可独立交易。
- **第一期标的**ETHOKX 指数 + 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**(可配 15120s |
| 指数采样 | 可与杠杆同频,或单独 **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 | 到期时刻(OKXUTC 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、交互式一键部署、作战地图数据标准 |