Files
xiaobai-review/xiaobai-datahub/datahub/db.py
T
3498dd7a4b feat(HEL-382): 搭建 datahub 底座和盘后正式数据链路
新增独立 xiaobai-datahub 服务(SQLite WAL、Tushare 盘后发布、/v1 契约和管理后台),不改现站页面与数据链路。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-09-02 12:05:26 +08:00

341 lines
10 KiB
Python

from __future__ import annotations
import sqlite3
import threading
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
from datahub.timeutil import isoformat
SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS credentials (
name TEXT PRIMARY KEY,
encrypted_payload TEXT NOT NULL,
last4 TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_admin (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_must_change INTEGER NOT NULL DEFAULT 1,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_sessions (
token_hash TEXT PRIMARY KEY,
csrf_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
token_hash TEXT PRIMARY KEY,
name TEXT NOT NULL,
last4 TEXT NOT NULL,
created_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS trade_calendar (
exchange TEXT NOT NULL,
cal_date TEXT NOT NULL,
is_open INTEGER NOT NULL,
pretrade_date TEXT,
fetched_at TEXT NOT NULL,
PRIMARY KEY (exchange, cal_date)
);
CREATE TABLE IF NOT EXISTS stock_master (
ts_code TEXT PRIMARY KEY,
symbol TEXT,
name TEXT,
area TEXT,
industry TEXT,
market TEXT,
list_status TEXT,
list_date TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS eod_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL, adj_factor REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_valuation (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
turnover_rate REAL, volume_ratio REAL,
total_mv REAL, circ_mv REAL,
pe_ttm REAL, pb REAL, ps_ttm REAL, dv_ttm REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_moneyflow (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
buy_sm_amount REAL, sell_sm_amount REAL,
buy_md_amount REAL, sell_md_amount REAL,
buy_lg_amount REAL, sell_lg_amount REAL,
buy_elg_amount REAL, sell_elg_amount REAL,
net_mf_amount REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_auction (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
volume REAL, price REAL, amount REAL, pre_close REAL,
turnover_rate REAL, volume_ratio REAL, float_share REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS eod_index_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL,
batch_id TEXT NOT NULL,
PRIMARY KEY (ts_code, trade_date, batch_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS staging_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL, adj_factor REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_valuation (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
turnover_rate REAL, volume_ratio REAL,
total_mv REAL, circ_mv REAL, pe_ttm REAL, pb REAL, ps_ttm REAL, dv_ttm REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_moneyflow (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
buy_sm_amount REAL, sell_sm_amount REAL, buy_md_amount REAL, sell_md_amount REAL,
buy_lg_amount REAL, sell_lg_amount REAL, buy_elg_amount REAL, sell_elg_amount REAL,
net_mf_amount REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_auction (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
volume REAL, price REAL, amount REAL, pre_close REAL,
turnover_rate REAL, volume_ratio REAL, float_share REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS staging_index_bars (
ts_code TEXT NOT NULL, trade_date TEXT NOT NULL, batch_id TEXT NOT NULL,
open REAL, high REAL, low REAL, close REAL, pct_chg REAL,
volume REAL, amount REAL,
PRIMARY KEY (batch_id, ts_code, trade_date)
);
CREATE TABLE IF NOT EXISTS publications (
dataset TEXT NOT NULL, trade_date TEXT NOT NULL,
active_batch TEXT NOT NULL, prev_batch TEXT,
state TEXT NOT NULL,
published_at TEXT NOT NULL,
PRIMARY KEY (dataset, trade_date)
);
CREATE TABLE IF NOT EXISTS publication_history (
dataset TEXT NOT NULL, trade_date TEXT NOT NULL,
batch_id TEXT NOT NULL, published_at TEXT NOT NULL,
generation INTEGER NOT NULL,
PRIMARY KEY (dataset, trade_date, batch_id)
);
CREATE TABLE IF NOT EXISTS batches (
batch_id TEXT PRIMARY KEY,
dataset TEXT NOT NULL,
trade_date TEXT NOT NULL,
state TEXT NOT NULL,
attempt INTEGER DEFAULT 0,
rows_in INTEGER,
rows_out INTEGER,
quality_json TEXT,
started_at TEXT,
finished_at TEXT,
error TEXT
);
CREATE TABLE IF NOT EXISTS src_health (
provider TEXT NOT NULL, endpoint_class TEXT NOT NULL,
state TEXT NOT NULL,
last_ok_at TEXT, last_error TEXT,
consec_failures INTEGER DEFAULT 0,
opened_at TEXT,
cooldown_until TEXT,
PRIMARY KEY (provider, endpoint_class)
);
CREATE TABLE IF NOT EXISTS src_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL,
endpoint TEXT NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER,
error TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id TEXT NOT NULL,
state TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
rows_in INTEGER,
rows_out INTEGER,
error TEXT,
attempt INTEGER DEFAULT 1,
detail TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS rt_cache (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
source TEXT NOT NULL,
stored_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS last_known_good (
cache_key TEXT PRIMARY KEY,
payload TEXT NOT NULL,
source TEXT NOT NULL,
stored_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS diff_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trade_date TEXT NOT NULL,
metric TEXT NOT NULL,
left_source TEXT,
right_source TEXT,
left_value REAL,
right_value REAL,
deviation REAL,
sample_count INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_batches_date ON batches(trade_date, dataset);
CREATE INDEX IF NOT EXISTS idx_job_runs_job ON job_runs(job_id, started_at);
CREATE INDEX IF NOT EXISTS idx_src_calls_created ON src_calls(created_at);
CREATE INDEX IF NOT EXISTS idx_eod_bars_date ON eod_bars(trade_date, batch_id);
CREATE INDEX IF NOT EXISTS idx_calendar_open ON trade_calendar(is_open, cal_date);
"""
DATASET_TABLES = {
"daily": ("eod_bars", "staging_bars"),
"valuation": ("eod_valuation", "staging_valuation"),
"moneyflow": ("eod_moneyflow", "staging_moneyflow"),
"auction": ("eod_auction", "staging_auction"),
"index_daily": ("eod_index_bars", "staging_index_bars"),
}
class ManagedConnection(sqlite3.Connection):
def __exit__(self, exc_type, exc_value, traceback):
try:
return super().__exit__(exc_type, exc_value, traceback)
finally:
self.close()
class HubDB:
def __init__(self, path: Path, timeout_seconds: float = 20) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.timeout_seconds = timeout_seconds
self._write_lock = threading.RLock()
self.initialize()
def connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(
self.path,
timeout=self.timeout_seconds,
factory=ManagedConnection,
)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
connection.execute("PRAGMA busy_timeout=20000")
connection.execute("PRAGMA synchronous=NORMAL")
return connection
def initialize(self) -> None:
with self.connect() as connection:
connection.executescript(SCHEMA)
row = connection.execute(
"SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1"
).fetchone()
if row is None:
connection.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (1, ?)",
(isoformat(),),
)
@contextmanager
def write(self) -> Iterator[sqlite3.Connection]:
with self._write_lock:
with self.connect() as connection:
yield connection
def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(sql, params).fetchall()
return [dict(row) for row in rows]
def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(sql, params).fetchone()
return dict(row) if row else None
def execute(self, sql: str, params: tuple[Any, ...] = ()) -> None:
with self.write() as connection:
connection.execute(sql, params)
def executemany(self, sql: str, rows: list[tuple[Any, ...]]) -> None:
with self.write() as connection:
connection.executemany(sql, rows)
def backup_to(self, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
with self.connect() as source, sqlite3.connect(dest) as target:
source.backup(target)
def vacuum(self) -> None:
with self.connect() as connection:
connection.execute("VACUUM")