Files
2026-08-01 10:33:19 +08:00

276 lines
8.4 KiB
Python

"""数据访问。"""
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