first commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
"""配置:从环境变量加载。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.config.settings import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
@@ -0,0 +1,3 @@
|
||||
from packages.config.settings import Settings, get_settings
|
||||
|
||||
__all__ = ["Settings", "get_settings"]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""集中配置(环境变量可覆盖)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# 服务
|
||||
mi_port: int = Field(default=5170, alias="MI_PORT")
|
||||
tz: str = Field(default="Asia/Shanghai", alias="TZ")
|
||||
auth_secret: str = Field(default="change-me", alias="AUTH_SECRET")
|
||||
admin_password: str = Field(default="admin123", alias="ADMIN_PASSWORD")
|
||||
|
||||
# OKX
|
||||
okx_api_key: str = Field(default="", alias="OKX_API_KEY")
|
||||
okx_api_secret: str = Field(default="", alias="OKX_API_SECRET")
|
||||
okx_api_passphrase: str = Field(default="", alias="OKX_API_PASSPHRASE")
|
||||
okx_base_url: str = Field(default="https://www.okx.com", alias="OKX_BASE_URL")
|
||||
okx_proxy: str = Field(default="", alias="OKX_PROXY")
|
||||
|
||||
index_inst_id: str = Field(default="ETH-USD", alias="INDEX_INST_ID")
|
||||
option_inst_family: str = Field(default="ETH-USD_UM", alias="OPTION_INST_FAMILY")
|
||||
underlying: str = Field(default="ETH", alias="UNDERLYING")
|
||||
|
||||
sample_interval_sec: int = Field(default=30, alias="SAMPLE_INTERVAL_SEC", ge=15, le=120)
|
||||
index_sample_interval_sec: int = Field(default=60, alias="INDEX_SAMPLE_INTERVAL_SEC", ge=15)
|
||||
instruments_refresh_sec: int = Field(default=300, alias="INSTRUMENTS_REFRESH_SEC", ge=60)
|
||||
min_option_hours: float = Field(default=12.0, alias="MIN_OPTION_HOURS", ge=0)
|
||||
min_option_leverage: float = Field(default=100.0, alias="MIN_OPTION_LEVERAGE", ge=1)
|
||||
|
||||
mi_db_path: str = Field(default="./data/market_intel.db", alias="MI_DB_PATH")
|
||||
bucket_minutes: int = Field(default=60, alias="BUCKET_MINUTES")
|
||||
month_range_mode: str = Field(default="rolling_30", alias="MONTH_RANGE_MODE")
|
||||
settle_backfill_interval_sec: int = Field(
|
||||
default=300, alias="SETTLE_BACKFILL_INTERVAL_SEC", ge=60
|
||||
)
|
||||
|
||||
# 企微告警
|
||||
wecom_enabled: bool = Field(default=False, alias="WECOM_ENABLED")
|
||||
wecom_webhook_url: str = Field(default="", alias="WECOM_WEBHOOK_URL")
|
||||
wecom_machine_name: str = Field(default="", alias="WECOM_MACHINE_NAME")
|
||||
alert_fail_threshold: int = Field(default=5, alias="ALERT_FAIL_THRESHOLD", ge=1)
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
return Path(self.mi_db_path)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,4 @@
|
||||
from packages.db.schema import init_db
|
||||
from packages.db.repository import Repository
|
||||
|
||||
__all__ = ["init_db", "Repository"]
|
||||
@@ -0,0 +1,275 @@
|
||||
"""数据访问。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from packages.db.schema import init_db
|
||||
|
||||
|
||||
def _now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OptionQuoteRow:
|
||||
ts_ms: int
|
||||
exchange: str
|
||||
underlying: str
|
||||
inst_id: str
|
||||
expiry_ymd: str
|
||||
strike: float
|
||||
side: str
|
||||
index_px: float
|
||||
ask: float | None
|
||||
bid: float | None
|
||||
ask_sz: float | None
|
||||
bid_sz: float | None
|
||||
leverage: float | None
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self.conn = init_db(self.db_path)
|
||||
|
||||
def close(self) -> None:
|
||||
self.conn.close()
|
||||
|
||||
def insert_option_quote(self, row: OptionQuoteRow) -> int:
|
||||
cur = self.conn.execute(
|
||||
"""
|
||||
INSERT INTO option_quotes (
|
||||
ts_ms, exchange, underlying, inst_id, expiry_ymd, strike, side,
|
||||
index_px, ask, bid, ask_sz, bid_sz, leverage, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row.ts_ms,
|
||||
row.exchange,
|
||||
row.underlying,
|
||||
row.inst_id,
|
||||
row.expiry_ymd,
|
||||
row.strike,
|
||||
row.side,
|
||||
row.index_px,
|
||||
row.ask,
|
||||
row.bid,
|
||||
row.ask_sz,
|
||||
row.bid_sz,
|
||||
row.leverage,
|
||||
_now_ms(),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def insert_index_tick(
|
||||
self,
|
||||
*,
|
||||
ts_ms: int,
|
||||
exchange: str,
|
||||
underlying: str,
|
||||
index_px: float,
|
||||
) -> int:
|
||||
cur = self.conn.execute(
|
||||
"""
|
||||
INSERT INTO index_ticks (ts_ms, exchange, underlying, index_px, created_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(ts_ms, exchange, underlying, index_px, _now_ms()),
|
||||
)
|
||||
self.conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
def upsert_heartbeat(
|
||||
self,
|
||||
*,
|
||||
ok: bool,
|
||||
error: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
now = _now_ms()
|
||||
row = self.conn.execute(
|
||||
"SELECT consecutive_failures FROM collector_heartbeat WHERE id = 1"
|
||||
).fetchone()
|
||||
fails = int(row["consecutive_failures"] if row else 0)
|
||||
if ok:
|
||||
fails = 0
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE collector_heartbeat
|
||||
SET last_ok_ts_ms = ?, last_error = NULL, consecutive_failures = 0,
|
||||
meta_json = COALESCE(?, meta_json)
|
||||
WHERE id = 1
|
||||
""",
|
||||
(now, json.dumps(meta, ensure_ascii=False) if meta else None),
|
||||
)
|
||||
else:
|
||||
fails += 1
|
||||
self.conn.execute(
|
||||
"""
|
||||
UPDATE collector_heartbeat
|
||||
SET last_error = ?, last_error_ts_ms = ?, consecutive_failures = ?,
|
||||
meta_json = COALESCE(?, meta_json)
|
||||
WHERE id = 1
|
||||
""",
|
||||
(
|
||||
(error or "unknown")[:2000],
|
||||
now,
|
||||
fails,
|
||||
json.dumps(meta, ensure_ascii=False) if meta else None,
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def get_heartbeat(self) -> dict[str, Any]:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM collector_heartbeat WHERE id = 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
d = dict(row)
|
||||
meta = d.get("meta_json")
|
||||
if meta:
|
||||
try:
|
||||
d["meta"] = json.loads(meta)
|
||||
except json.JSONDecodeError:
|
||||
d["meta"] = None
|
||||
else:
|
||||
d["meta"] = None
|
||||
return d
|
||||
|
||||
def latest_quotes_by_side(self) -> dict[str, dict[str, Any]]:
|
||||
"""返回 side -> 最新一条。"""
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for side in ("C", "P"):
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT * FROM option_quotes
|
||||
WHERE side = ?
|
||||
ORDER BY ts_ms DESC, id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(side,),
|
||||
).fetchone()
|
||||
if row:
|
||||
out[side] = dict(row)
|
||||
return out
|
||||
|
||||
def count_option_quotes(self) -> int:
|
||||
row = self.conn.execute("SELECT COUNT(*) AS n FROM option_quotes").fetchone()
|
||||
return int(row["n"] if row else 0)
|
||||
|
||||
def count_index_ticks(self) -> int:
|
||||
row = self.conn.execute("SELECT COUNT(*) AS n FROM index_ticks").fetchone()
|
||||
return int(row["n"] if row else 0)
|
||||
|
||||
def fetch_option_quotes(
|
||||
self,
|
||||
*,
|
||||
start_ms: int,
|
||||
end_ms: int,
|
||||
side: str = "both",
|
||||
underlying: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""[start_ms, end_ms) 半开区间。"""
|
||||
clauses = ["ts_ms >= ?", "ts_ms < ?"]
|
||||
params: list[Any] = [int(start_ms), int(end_ms)]
|
||||
want = (side or "both").upper()
|
||||
if want in ("C", "P"):
|
||||
clauses.append("side = ?")
|
||||
params.append(want)
|
||||
if underlying:
|
||||
clauses.append("underlying = ?")
|
||||
params.append(underlying)
|
||||
sql = f"""
|
||||
SELECT ts_ms, exchange, underlying, inst_id, expiry_ymd, strike, side,
|
||||
index_px, ask, bid, ask_sz, bid_sz, leverage
|
||||
FROM option_quotes
|
||||
WHERE {' AND '.join(clauses)}
|
||||
ORDER BY ts_ms ASC, id ASC
|
||||
"""
|
||||
rows = self.conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_settlement(self, expiry_ymd: str) -> dict[str, Any] | None:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM expiry_settlements WHERE expiry_ymd = ?",
|
||||
(expiry_ymd,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_settlements(self, ymds: list[str] | None = None) -> dict[str, dict[str, Any]]:
|
||||
if ymds is not None and not ymds:
|
||||
return {}
|
||||
if ymds is None:
|
||||
rows = self.conn.execute("SELECT * FROM expiry_settlements").fetchall()
|
||||
else:
|
||||
placeholders = ",".join("?" for _ in ymds)
|
||||
rows = self.conn.execute(
|
||||
f"SELECT * FROM expiry_settlements WHERE expiry_ymd IN ({placeholders})",
|
||||
list(ymds),
|
||||
).fetchall()
|
||||
return {str(r["expiry_ymd"]): dict(r) for r in rows}
|
||||
|
||||
def upsert_settlement(
|
||||
self,
|
||||
*,
|
||||
expiry_ymd: str,
|
||||
settle_ts_ms: int,
|
||||
settle_index_px: float,
|
||||
exchange: str,
|
||||
underlying: str,
|
||||
) -> None:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO expiry_settlements (
|
||||
expiry_ymd, settle_ts_ms, settle_index_px, exchange, underlying, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(expiry_ymd) DO UPDATE SET
|
||||
settle_ts_ms = excluded.settle_ts_ms,
|
||||
settle_index_px = excluded.settle_index_px,
|
||||
exchange = excluded.exchange,
|
||||
underlying = excluded.underlying
|
||||
""",
|
||||
(
|
||||
expiry_ymd,
|
||||
int(settle_ts_ms),
|
||||
float(settle_index_px),
|
||||
exchange,
|
||||
underlying,
|
||||
_now_ms(),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def nearest_index_tick(
|
||||
self,
|
||||
*,
|
||||
underlying: str,
|
||||
target_ts_ms: int,
|
||||
max_delta_ms: int,
|
||||
) -> dict[str, Any] | None:
|
||||
row = self.conn.execute(
|
||||
"""
|
||||
SELECT ts_ms, index_px, ABS(ts_ms - ?) AS delta
|
||||
FROM index_ticks
|
||||
WHERE underlying = ?
|
||||
AND ts_ms BETWEEN ? AND ?
|
||||
ORDER BY delta ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(
|
||||
int(target_ts_ms),
|
||||
underlying,
|
||||
int(target_ts_ms) - int(max_delta_ms),
|
||||
int(target_ts_ms) + int(max_delta_ms),
|
||||
),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
@@ -0,0 +1,75 @@
|
||||
"""SQLite schema(可迁移设计)。时间戳存 UTC ms。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS option_quotes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
exchange TEXT NOT NULL,
|
||||
underlying TEXT NOT NULL,
|
||||
inst_id TEXT NOT NULL,
|
||||
expiry_ymd TEXT NOT NULL,
|
||||
strike REAL NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
index_px REAL NOT NULL,
|
||||
ask REAL,
|
||||
bid REAL,
|
||||
ask_sz REAL,
|
||||
bid_sz REAL,
|
||||
leverage REAL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_oq_ts ON option_quotes(ts_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_oq_side_ts ON option_quotes(side, ts_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_oq_expiry ON option_quotes(expiry_ymd);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS index_ticks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts_ms INTEGER NOT NULL,
|
||||
exchange TEXT NOT NULL,
|
||||
underlying TEXT NOT NULL,
|
||||
index_px REAL NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_it_ts ON index_ticks(ts_ms);
|
||||
CREATE INDEX IF NOT EXISTS idx_it_u_ts ON index_ticks(underlying, ts_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expiry_settlements (
|
||||
expiry_ymd TEXT PRIMARY KEY,
|
||||
settle_ts_ms INTEGER NOT NULL,
|
||||
settle_index_px REAL NOT NULL,
|
||||
exchange TEXT NOT NULL,
|
||||
underlying TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collector_heartbeat (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
last_ok_ts_ms INTEGER,
|
||||
last_error TEXT,
|
||||
last_error_ts_ms INTEGER,
|
||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
||||
meta_json TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def init_db(db_path: str | Path) -> sqlite3.Connection:
|
||||
path = Path(db_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA synchronous=NORMAL;")
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO collector_heartbeat (id, consecutive_failures) VALUES (1, 0)"
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
@@ -0,0 +1,29 @@
|
||||
"""领域口径:杠杆、时段桶、波动点数。变更需升版本。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from packages.domain.aggregate import (
|
||||
aggregate_leverage,
|
||||
aggregate_move_points,
|
||||
summarize_values,
|
||||
)
|
||||
from packages.domain.buckets import shanghai_bucket, shanghai_day
|
||||
from packages.domain.expiry import expiry_ms_from_ymd
|
||||
from packages.domain.leverage import LEVERAGE_FORMULA_VERSION, option_leverage
|
||||
from packages.domain.move_points import MOVE_POINTS_FORMULA_VERSION, move_points
|
||||
from packages.domain.range import resolve_range, today_shanghai
|
||||
|
||||
__all__ = [
|
||||
"LEVERAGE_FORMULA_VERSION",
|
||||
"MOVE_POINTS_FORMULA_VERSION",
|
||||
"option_leverage",
|
||||
"move_points",
|
||||
"expiry_ms_from_ymd",
|
||||
"shanghai_day",
|
||||
"shanghai_bucket",
|
||||
"resolve_range",
|
||||
"today_shanghai",
|
||||
"aggregate_leverage",
|
||||
"aggregate_move_points",
|
||||
"summarize_values",
|
||||
]
|
||||
@@ -0,0 +1,342 @@
|
||||
"""杠杆按时段桶聚合。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
from packages.domain.buckets import shanghai_bucket
|
||||
from packages.domain.leverage import LEVERAGE_FORMULA_VERSION
|
||||
|
||||
|
||||
def _percentile(sorted_vals: Sequence[float], p: float) -> float | None:
|
||||
"""线性插值百分位;p in [0,100]。"""
|
||||
if not sorted_vals:
|
||||
return None
|
||||
if len(sorted_vals) == 1:
|
||||
return float(sorted_vals[0])
|
||||
p = max(0.0, min(100.0, float(p)))
|
||||
k = (len(sorted_vals) - 1) * (p / 100.0)
|
||||
f = math.floor(k)
|
||||
c = math.ceil(k)
|
||||
if f == c:
|
||||
return float(sorted_vals[int(k)])
|
||||
d0 = sorted_vals[f] * (c - k)
|
||||
d1 = sorted_vals[c] * (k - f)
|
||||
return float(d0 + d1)
|
||||
|
||||
|
||||
def summarize_values(values: Sequence[float], *, min_leverage: float) -> dict[str, Any]:
|
||||
if not values:
|
||||
return {
|
||||
"n": 0,
|
||||
"mean": None,
|
||||
"median": None,
|
||||
"p25": None,
|
||||
"p75": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
"pct_ge_min": None,
|
||||
}
|
||||
xs = sorted(float(v) for v in values)
|
||||
n = len(xs)
|
||||
ge = sum(1 for v in xs if v >= float(min_leverage))
|
||||
return {
|
||||
"n": n,
|
||||
"mean": sum(xs) / n,
|
||||
"median": _percentile(xs, 50),
|
||||
"p25": _percentile(xs, 25),
|
||||
"p75": _percentile(xs, 75),
|
||||
"min": xs[0],
|
||||
"max": xs[-1],
|
||||
"pct_ge_min": ge / n,
|
||||
}
|
||||
|
||||
|
||||
def bucket_label(bucket_start_min: int, bucket_minutes: int) -> str:
|
||||
"""如 14:00 或 14:00-14:30。"""
|
||||
h, m = divmod(int(bucket_start_min), 60)
|
||||
start = f"{h:02d}:{m:02d}"
|
||||
if bucket_minutes >= 60 and bucket_minutes % 60 == 0 and m == 0:
|
||||
return f"{h:02d}:00"
|
||||
end_min = bucket_start_min + bucket_minutes
|
||||
eh, em = divmod(end_min % (24 * 60), 60)
|
||||
return f"{start}-{eh:02d}:{em:02d}"
|
||||
|
||||
|
||||
def all_bucket_starts(bucket_minutes: int) -> list[int]:
|
||||
if bucket_minutes <= 0 or 1440 % bucket_minutes != 0:
|
||||
# 允许非整除:仍按步进生成到 <1440
|
||||
out = []
|
||||
t = 0
|
||||
while t < 1440:
|
||||
out.append(t)
|
||||
t += bucket_minutes
|
||||
return out
|
||||
return list(range(0, 1440, bucket_minutes))
|
||||
|
||||
|
||||
def aggregate_leverage(
|
||||
rows: Iterable[dict[str, Any]],
|
||||
*,
|
||||
bucket_minutes: int = 60,
|
||||
min_leverage: float = 100.0,
|
||||
side: str = "both",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
rows: 需含 ts_ms, leverage, side。
|
||||
返回按桶排序的聚合列表(含空桶)。
|
||||
"""
|
||||
want = (side or "both").upper()
|
||||
by_bucket: dict[int, list[float]] = defaultdict(list)
|
||||
|
||||
for r in rows:
|
||||
lev = r.get("leverage")
|
||||
if lev is None:
|
||||
continue
|
||||
try:
|
||||
lev_f = float(lev)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not math.isfinite(lev_f) or lev_f <= 0:
|
||||
continue
|
||||
s = str(r.get("side") or "").upper()
|
||||
if want in ("C", "P") and s != want:
|
||||
continue
|
||||
if want == "BOTH" and s not in ("C", "P"):
|
||||
continue
|
||||
b = shanghai_bucket(int(r["ts_ms"]), bucket_minutes)
|
||||
by_bucket[b].append(lev_f)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for b in all_bucket_starts(bucket_minutes):
|
||||
stats = summarize_values(by_bucket.get(b, []), min_leverage=min_leverage)
|
||||
out.append(
|
||||
{
|
||||
"bucket_start_min": b,
|
||||
"bucket_hour": b // 60 if bucket_minutes >= 60 else None,
|
||||
"label": bucket_label(b, bucket_minutes),
|
||||
**stats,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def summarize_distribution(values: Sequence[float]) -> dict[str, Any]:
|
||||
"""通用分布摘要(无达标线)。"""
|
||||
if not values:
|
||||
return {
|
||||
"n": 0,
|
||||
"mean": None,
|
||||
"median": None,
|
||||
"p25": None,
|
||||
"p75": None,
|
||||
"min": None,
|
||||
"max": None,
|
||||
}
|
||||
xs = sorted(float(v) for v in values)
|
||||
n = len(xs)
|
||||
return {
|
||||
"n": n,
|
||||
"mean": sum(xs) / n,
|
||||
"median": _percentile(xs, 50),
|
||||
"p25": _percentile(xs, 25),
|
||||
"p75": _percentile(xs, 75),
|
||||
"min": xs[0],
|
||||
"max": xs[-1],
|
||||
}
|
||||
|
||||
|
||||
def aggregate_move_points(
|
||||
samples: Iterable[dict[str, Any]],
|
||||
*,
|
||||
bucket_minutes: int = 60,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
samples: {ts_ms, move_signed, move_abs}
|
||||
桶内同时给出 signed / abs 分布。
|
||||
"""
|
||||
by_signed: dict[int, list[float]] = defaultdict(list)
|
||||
by_abs: dict[int, list[float]] = defaultdict(list)
|
||||
for s in samples:
|
||||
ts = s.get("ts_ms")
|
||||
signed = s.get("move_signed")
|
||||
if ts is None or signed is None:
|
||||
continue
|
||||
try:
|
||||
signed_f = float(signed)
|
||||
abs_f = float(s.get("move_abs", abs(signed_f)))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if not math.isfinite(signed_f):
|
||||
continue
|
||||
b = shanghai_bucket(int(ts), bucket_minutes)
|
||||
by_signed[b].append(signed_f)
|
||||
by_abs[b].append(abs_f)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
for b in all_bucket_starts(bucket_minutes):
|
||||
signed_stats = summarize_distribution(by_signed.get(b, []))
|
||||
abs_stats = summarize_distribution(by_abs.get(b, []))
|
||||
out.append(
|
||||
{
|
||||
"bucket_start_min": b,
|
||||
"bucket_hour": b // 60 if bucket_minutes >= 60 else None,
|
||||
"label": bucket_label(b, bucket_minutes),
|
||||
"n": signed_stats["n"],
|
||||
"signed": signed_stats,
|
||||
"abs": abs_stats,
|
||||
# 便捷字段(看板默认用 abs 均值)
|
||||
"mean_signed": signed_stats["mean"],
|
||||
"median_signed": signed_stats["median"],
|
||||
"mean_abs": abs_stats["mean"],
|
||||
"median_abs": abs_stats["median"],
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def build_move_samples(
|
||||
rows: Iterable[dict[str, Any]],
|
||||
settlements: dict[str, dict[str, Any]],
|
||||
*,
|
||||
side: str = "both",
|
||||
now_ms: int | None = None,
|
||||
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
||||
"""
|
||||
对期权样本计算波动点数。
|
||||
返回 (settled_samples, meta)。
|
||||
meta: pending_expiry, pending_count, settled_count, pending_ymds, settled_ymds
|
||||
"""
|
||||
import time
|
||||
|
||||
from packages.domain.move_points import move_points as calc_move
|
||||
|
||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||
want = (side or "both").upper()
|
||||
settled: list[dict[str, Any]] = []
|
||||
pending_ymds: set[str] = set()
|
||||
settled_ymds: set[str] = set()
|
||||
pending_count = 0
|
||||
settled_count = 0
|
||||
|
||||
for r in rows:
|
||||
s = str(r.get("side") or "").upper()
|
||||
if want in ("C", "P") and s != want:
|
||||
continue
|
||||
if want == "BOTH" and s not in ("C", "P"):
|
||||
continue
|
||||
ymd = str(r.get("expiry_ymd") or "")
|
||||
idx = r.get("index_px")
|
||||
ts = r.get("ts_ms")
|
||||
if not ymd or idx is None or ts is None:
|
||||
continue
|
||||
settle = settlements.get(ymd)
|
||||
if settle is None or int(settle.get("settle_ts_ms") or 0) > now:
|
||||
pending_ymds.add(ymd)
|
||||
pending_count += 1
|
||||
continue
|
||||
try:
|
||||
signed = calc_move(float(settle["settle_index_px"]), float(idx))
|
||||
except (TypeError, ValueError):
|
||||
pending_ymds.add(ymd)
|
||||
pending_count += 1
|
||||
continue
|
||||
settled_ymds.add(ymd)
|
||||
settled_count += 1
|
||||
settled.append(
|
||||
{
|
||||
"ts_ms": int(ts),
|
||||
"expiry_ymd": ymd,
|
||||
"side": s,
|
||||
"index_at_t": float(idx),
|
||||
"settle_index_px": float(settle["settle_index_px"]),
|
||||
"move_signed": signed,
|
||||
"move_abs": abs(signed),
|
||||
}
|
||||
)
|
||||
|
||||
meta = {
|
||||
"pending_expiry": pending_count > 0,
|
||||
"pending_count": pending_count,
|
||||
"settled_count": settled_count,
|
||||
"pending_ymds": sorted(pending_ymds),
|
||||
"settled_ymds": sorted(settled_ymds),
|
||||
}
|
||||
return settled, meta
|
||||
|
||||
|
||||
def move_points_stats_payload(
|
||||
rows: Iterable[dict[str, Any]],
|
||||
settlements: dict[str, dict[str, Any]],
|
||||
*,
|
||||
range_info: dict[str, Any],
|
||||
bucket_minutes: int,
|
||||
side: str,
|
||||
now_ms: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
from packages.domain.move_points import MOVE_POINTS_FORMULA_VERSION
|
||||
|
||||
samples, meta = build_move_samples(
|
||||
rows, settlements, side=side, now_ms=now_ms
|
||||
)
|
||||
buckets = aggregate_move_points(samples, bucket_minutes=bucket_minutes)
|
||||
return {
|
||||
"status": "ok",
|
||||
"formula_version": MOVE_POINTS_FORMULA_VERSION,
|
||||
"move_def": "settle_index_px - index_at(t)",
|
||||
"range": range_info["range"],
|
||||
"date": range_info["anchor"],
|
||||
"start_ymd": range_info["start_ymd"],
|
||||
"end_ymd": range_info["end_ymd"],
|
||||
"days": range_info["days"],
|
||||
"month_mode": range_info.get("month_mode"),
|
||||
"side": side,
|
||||
"bucket_minutes": bucket_minutes,
|
||||
"pending_expiry": meta["pending_expiry"],
|
||||
"pending_count": meta["pending_count"],
|
||||
"settled_count": meta["settled_count"],
|
||||
"pending_ymds": meta["pending_ymds"],
|
||||
"settled_ymds": meta["settled_ymds"],
|
||||
"sample_count": meta["settled_count"],
|
||||
"buckets": buckets,
|
||||
"message": (
|
||||
"部分样本未到期或缺少结算锚点,已排除出分布"
|
||||
if meta["pending_expiry"]
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def leverage_stats_payload(
|
||||
rows: Iterable[dict[str, Any]],
|
||||
*,
|
||||
range_info: dict[str, Any],
|
||||
bucket_minutes: int,
|
||||
min_leverage: float,
|
||||
side: str,
|
||||
) -> dict[str, Any]:
|
||||
buckets = aggregate_leverage(
|
||||
rows,
|
||||
bucket_minutes=bucket_minutes,
|
||||
min_leverage=min_leverage,
|
||||
side=side,
|
||||
)
|
||||
total_n = sum(int(b["n"]) for b in buckets)
|
||||
return {
|
||||
"status": "ok",
|
||||
"formula_version": LEVERAGE_FORMULA_VERSION,
|
||||
"leverage_def": "index_px / ask",
|
||||
"range": range_info["range"],
|
||||
"date": range_info["anchor"],
|
||||
"start_ymd": range_info["start_ymd"],
|
||||
"end_ymd": range_info["end_ymd"],
|
||||
"days": range_info["days"],
|
||||
"month_mode": range_info.get("month_mode"),
|
||||
"side": side,
|
||||
"bucket_minutes": bucket_minutes,
|
||||
"min_leverage": min_leverage,
|
||||
"sample_count": total_n,
|
||||
"buckets": buckets,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Asia/Shanghai 日切与时段桶。时间戳一律 UTC ms 入,上海出。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def _to_shanghai(ts_ms: int) -> datetime:
|
||||
return datetime.fromtimestamp(int(ts_ms) / 1000.0, tz=_SH)
|
||||
|
||||
|
||||
def shanghai_day(ts_ms: int) -> str:
|
||||
"""上海自然日 YYYY-MM-DD。"""
|
||||
return _to_shanghai(ts_ms).strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def shanghai_bucket(ts_ms: int, bucket_minutes: int = 60) -> int:
|
||||
"""
|
||||
日内时段桶起点(分钟,0–1439)。
|
||||
默认 60 → 0,60,120,...,1380(即小时 0–23)。
|
||||
"""
|
||||
if bucket_minutes <= 0:
|
||||
raise ValueError("bucket_minutes must be > 0")
|
||||
dt = _to_shanghai(ts_ms)
|
||||
minutes = dt.hour * 60 + dt.minute
|
||||
return (minutes // bucket_minutes) * bucket_minutes
|
||||
|
||||
|
||||
def shanghai_bucket_hour(ts_ms: int) -> int:
|
||||
"""0–23 小时桶。"""
|
||||
return shanghai_bucket(ts_ms, 60) // 60
|
||||
|
||||
|
||||
def rolling_day_start(anchor_ymd: str, days: int) -> str:
|
||||
"""锚点日(含)往前 days-1 天的起始 YYYY-MM-DD。"""
|
||||
d = datetime.strptime(anchor_ymd, "%Y-%m-%d").date()
|
||||
start = d - timedelta(days=max(0, days - 1))
|
||||
return start.strftime("%Y-%m-%d")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""OKX / 欧式期权惯例:到期日当日 08:00 UTC(上海 16:00)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def expiry_ms_from_ymd(ymd: str) -> int:
|
||||
"""YYMMDD → 到期毫秒时间戳(UTC 08:00)。"""
|
||||
ymd = (ymd or "").strip()
|
||||
if len(ymd) != 6 or not ymd.isdigit():
|
||||
raise ValueError(f"invalid expiry ymd: {ymd!r}")
|
||||
yy, mm, dd = int(ymd[0:2]), int(ymd[2:4]), int(ymd[4:6])
|
||||
dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc)
|
||||
return int(dt.timestamp() * 1000)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""杠杆口径 v1:杠杆 = 标的指数 ÷ 期权卖一(ask)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LEVERAGE_FORMULA_VERSION = "1.0"
|
||||
|
||||
|
||||
def option_leverage(index_px: float, ask: float | None) -> float | None:
|
||||
"""index_px / ask;ask 无效时返回 None。"""
|
||||
if index_px is None or index_px <= 0:
|
||||
return None
|
||||
if ask is None or ask <= 0:
|
||||
return None
|
||||
return float(index_px) / float(ask)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""波动点数口径 v1:到期指数 − 时段代表指数。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
MOVE_POINTS_FORMULA_VERSION = "1.0"
|
||||
|
||||
|
||||
def move_points(settle_index_px: float, index_at_t: float) -> float:
|
||||
"""signed 点数;绝对值由调用方取 abs。"""
|
||||
return float(settle_index_px) - float(index_at_t)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""日/周/月时间范围解析(Asia/Shanghai)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
_SH = ZoneInfo("Asia/Shanghai")
|
||||
|
||||
|
||||
def today_shanghai(now: datetime | None = None) -> str:
|
||||
n = (now or datetime.now(tz=_SH)).astimezone(_SH)
|
||||
return n.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def parse_ymd(ymd: str) -> date:
|
||||
return datetime.strptime(ymd, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def day_bounds_ms(ymd: str) -> tuple[int, int]:
|
||||
"""上海自然日 [start_ms, end_ms),end 为次日 00:00。"""
|
||||
d = parse_ymd(ymd)
|
||||
start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=_SH)
|
||||
end = start + timedelta(days=1)
|
||||
return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
|
||||
|
||||
|
||||
def resolve_range(
|
||||
range_name: str,
|
||||
anchor_ymd: str | None = None,
|
||||
*,
|
||||
month_mode: str = "rolling_30",
|
||||
now: datetime | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
返回:
|
||||
anchor, start_ymd, end_ymd (含), start_ms, end_ms (半开区间), days
|
||||
"""
|
||||
anchor = anchor_ymd or today_shanghai(now)
|
||||
parse_ymd(anchor) # validate
|
||||
|
||||
name = (range_name or "day").strip().lower()
|
||||
if name == "day":
|
||||
start_ymd = end_ymd = anchor
|
||||
days = 1
|
||||
elif name == "week":
|
||||
end_ymd = anchor
|
||||
start = parse_ymd(anchor) - timedelta(days=6)
|
||||
start_ymd = start.strftime("%Y-%m-%d")
|
||||
days = 7
|
||||
elif name == "month":
|
||||
end_ymd = anchor
|
||||
mode = (month_mode or "rolling_30").strip().lower()
|
||||
if mode in ("calendar", "natural", "natural_month"):
|
||||
d = parse_ymd(anchor)
|
||||
start_ymd = d.replace(day=1).strftime("%Y-%m-%d")
|
||||
days = (parse_ymd(end_ymd) - parse_ymd(start_ymd)).days + 1
|
||||
else:
|
||||
# rolling_30
|
||||
start = parse_ymd(anchor) - timedelta(days=29)
|
||||
start_ymd = start.strftime("%Y-%m-%d")
|
||||
days = 30
|
||||
else:
|
||||
raise ValueError(f"invalid range: {range_name!r}")
|
||||
|
||||
start_ms, _ = day_bounds_ms(start_ymd)
|
||||
_, end_ms = day_bounds_ms(end_ymd)
|
||||
return {
|
||||
"range": name,
|
||||
"anchor": anchor,
|
||||
"start_ymd": start_ymd,
|
||||
"end_ymd": end_ymd,
|
||||
"start_ms": start_ms,
|
||||
"end_ms": end_ms,
|
||||
"days": days,
|
||||
"month_mode": month_mode if name == "month" else None,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from packages.notify import wecom
|
||||
|
||||
__all__ = ["wecom"]
|
||||
@@ -0,0 +1,163 @@
|
||||
"""企业微信群机器人通知(独立实现,不依赖策略仓)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from packages.config import get_settings
|
||||
|
||||
log = logging.getLogger("notify.wecom")
|
||||
|
||||
_last_fault_key: str | None = None
|
||||
_last_fault_ms: float = 0.0
|
||||
_fault_active: bool = False
|
||||
|
||||
|
||||
def _as_bool(raw: Any, default: bool = False) -> bool:
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
return str(raw).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def wecom_enabled() -> bool:
|
||||
s = get_settings()
|
||||
return _as_bool(getattr(s, "wecom_enabled", False))
|
||||
|
||||
|
||||
def wecom_webhook_url() -> str:
|
||||
s = get_settings()
|
||||
return (getattr(s, "wecom_webhook_url", "") or "").strip()
|
||||
|
||||
|
||||
def wecom_machine_name() -> str:
|
||||
s = get_settings()
|
||||
return (getattr(s, "wecom_machine_name", "") or "").strip()[:64]
|
||||
|
||||
|
||||
def alert_fail_threshold() -> int:
|
||||
s = get_settings()
|
||||
try:
|
||||
return max(1, int(getattr(s, "alert_fail_threshold", 5) or 5))
|
||||
except (TypeError, ValueError):
|
||||
return 5
|
||||
|
||||
|
||||
def build_markdown(*, tag: str, title: str, lines: list[str] | None = None) -> str:
|
||||
machine = wecom_machine_name()
|
||||
prefix = f"【{machine}】" if machine else ""
|
||||
parts = [
|
||||
f"## {prefix}{title}",
|
||||
f"> **标识**: `{tag}`",
|
||||
f"> **系统**: 比特骆驼行情采集分析",
|
||||
f"> **时间**: {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
]
|
||||
if machine:
|
||||
parts.append(f"> **机器**: {machine}")
|
||||
if lines:
|
||||
parts.append("")
|
||||
for ln in lines:
|
||||
parts.append(f"> {ln}" if not ln.startswith(">") else ln)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def post_markdown(content: str) -> tuple[bool, str]:
|
||||
if not wecom_enabled():
|
||||
return False, "未开启企业微信通知"
|
||||
url = wecom_webhook_url()
|
||||
if not url:
|
||||
return False, "未配置 Webhook"
|
||||
raw = content.encode("utf-8")
|
||||
if len(raw) > 4000:
|
||||
content = raw[:3900].decode("utf-8", errors="ignore") + "\n…"
|
||||
payload = {"msgtype": "markdown", "markdown": {"content": content}}
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
r = client.post(url, json=payload)
|
||||
body = r.json() if r.content else {}
|
||||
if r.status_code != 200 or str(body.get("errcode", 0)) not in ("0", "0.0"):
|
||||
return False, f"webhook failed status={r.status_code} body={body}"
|
||||
return True, "ok"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def notify_test() -> tuple[bool, str]:
|
||||
md = build_markdown(
|
||||
tag="TEST",
|
||||
title="行情采集分析 · 测试推送",
|
||||
lines=["这是一条测试消息,说明企微 Webhook 可用。"],
|
||||
)
|
||||
return post_markdown(md)
|
||||
|
||||
|
||||
def notify_collector_fault(
|
||||
*,
|
||||
error: str,
|
||||
consecutive_failures: int,
|
||||
dedup_sec: float = 300.0,
|
||||
) -> tuple[bool, str]:
|
||||
"""连续失败告警;同错误键在 dedup_sec 内不重复推。"""
|
||||
global _last_fault_key, _last_fault_ms, _fault_active
|
||||
threshold = alert_fail_threshold()
|
||||
if consecutive_failures < threshold:
|
||||
return False, f"below threshold ({consecutive_failures}<{threshold})"
|
||||
|
||||
key = f"{consecutive_failures // threshold}:{(error or '')[:120]}"
|
||||
now = time.time()
|
||||
if (
|
||||
_last_fault_key == key
|
||||
and (now - _last_fault_ms) < dedup_sec
|
||||
):
|
||||
return False, "dedup"
|
||||
_last_fault_key = key
|
||||
_last_fault_ms = now
|
||||
_fault_active = True
|
||||
|
||||
# 脱敏:避免日志/推送里出现完整密钥形态串
|
||||
err_show = (error or "unknown").replace("\n", " ")[:300]
|
||||
md = build_markdown(
|
||||
tag="FAULT",
|
||||
title="行情采集异常",
|
||||
lines=[
|
||||
f"**连续失败**: {consecutive_failures}(阈值 {threshold})",
|
||||
f"**错误**: {err_show}",
|
||||
"请检查 OKX 连通性 / 代理 / 合约是否可交易。",
|
||||
],
|
||||
)
|
||||
ok, msg = post_markdown(md)
|
||||
if ok:
|
||||
log.info("wecom fault notified failures=%s", consecutive_failures)
|
||||
else:
|
||||
log.warning("wecom fault notify failed: %s", msg)
|
||||
return ok, msg
|
||||
|
||||
|
||||
def notify_collector_recovered(*, consecutive_failures: int = 0) -> tuple[bool, str]:
|
||||
global _fault_active, _last_fault_key
|
||||
if not _fault_active:
|
||||
return False, "no active fault"
|
||||
_fault_active = False
|
||||
_last_fault_key = None
|
||||
md = build_markdown(
|
||||
tag="RECOVER",
|
||||
title="行情采集已恢复",
|
||||
lines=["采样已恢复正常。"],
|
||||
)
|
||||
ok, msg = post_markdown(md)
|
||||
if ok:
|
||||
log.info("wecom recovered notified")
|
||||
else:
|
||||
log.warning("wecom recover notify failed: %s", msg)
|
||||
return ok, msg
|
||||
|
||||
|
||||
def reset_alert_state() -> None:
|
||||
"""测试用。"""
|
||||
global _last_fault_key, _last_fault_ms, _fault_active
|
||||
_last_fault_key = None
|
||||
_last_fault_ms = 0.0
|
||||
_fault_active = False
|
||||
Reference in New Issue
Block a user