398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""实盘模拟 SQLite 持久化(4 表,spec §live-api)。
|
||
|
||
表:live_accounts / live_trades / live_positions / live_balance
|
||
WAL 模式支持 supervisor 进程写 + API 进程读(DB 解耦,spec §live-api)。
|
||
|
||
设计参考 ``sanguo_trader/persistence.py``(paper_* 表),但:
|
||
- account.setting 存 JSON 字符串(策略参数透传给 CtaTemplate.update_setting)
|
||
- status: stopped | running(API 改字段,supervisor 轮询该字段决定起停)
|
||
- positions 为覆盖式快照(supervisor 定时把 OMS PositionData 落库,不做增量)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import sqlite3
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS live_accounts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT,
|
||
account TEXT,
|
||
vt_symbol TEXT,
|
||
strategy_class TEXT,
|
||
strategy_name TEXT,
|
||
setting TEXT,
|
||
status TEXT,
|
||
interval TEXT,
|
||
initial_capital REAL,
|
||
connect_wait_sec INTEGER,
|
||
init_wait_sec INTEGER,
|
||
mini_path TEXT,
|
||
error_msg TEXT,
|
||
created_at TEXT,
|
||
updated_at TEXT,
|
||
strategy_type TEXT DEFAULT 'cta',
|
||
pool TEXT,
|
||
max_pool INTEGER,
|
||
benchmark TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS live_trades (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER,
|
||
strategy_name TEXT,
|
||
symbol TEXT,
|
||
direction TEXT,
|
||
offset TEXT,
|
||
price REAL,
|
||
volume REAL,
|
||
traded_at TEXT,
|
||
vt_tradeid TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS live_positions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER,
|
||
symbol TEXT,
|
||
volume REAL,
|
||
frozen REAL,
|
||
avg_price REAL,
|
||
updated_at TEXT,
|
||
UNIQUE(account_id, symbol)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS live_balance (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER,
|
||
date TEXT,
|
||
cash REAL,
|
||
market_value REAL,
|
||
total REAL
|
||
);
|
||
CREATE TABLE IF NOT EXISTS qmt_account_snapshot (
|
||
account TEXT PRIMARY KEY,
|
||
mini_path TEXT,
|
||
cash REAL,
|
||
market_value REAL,
|
||
total REAL,
|
||
positions TEXT,
|
||
updated_at TEXT
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_live_trades_account ON live_trades(account_id);
|
||
CREATE INDEX IF NOT EXISTS idx_live_balance_account ON live_balance(account_id, date);
|
||
"""
|
||
|
||
|
||
def _now() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def init_db(db_path: str) -> None:
|
||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.executescript(SCHEMA)
|
||
# 轻量迁移:旧库补组合实盘列(新库 CREATE 已含,ALTER 报错忽略)
|
||
for col, ddl in (
|
||
("strategy_type", "TEXT DEFAULT 'cta'"),
|
||
("pool", "TEXT"),
|
||
("max_pool", "INTEGER"),
|
||
("benchmark", "TEXT"),
|
||
("instance_id", "INTEGER"), # §12.6 实例做实:账户绑档案
|
||
("code_hash", "TEXT"), # §12.6 补:发起时代码版本快照
|
||
):
|
||
try:
|
||
conn.execute(f"ALTER TABLE live_accounts ADD COLUMN {col} {ddl}")
|
||
except sqlite3.OperationalError:
|
||
pass # 列已存在
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.commit()
|
||
|
||
|
||
# ----------------- live_accounts CRUD -----------------
|
||
|
||
def save_account(db_path: str, account: dict[str, Any]) -> int:
|
||
with sqlite3.connect(db_path) as conn:
|
||
cur = conn.execute(
|
||
"""INSERT INTO live_accounts
|
||
(name, account, vt_symbol, strategy_class, strategy_name, setting,
|
||
status, interval, initial_capital, connect_wait_sec, init_wait_sec,
|
||
mini_path, created_at, updated_at,
|
||
strategy_type, pool, max_pool, benchmark, instance_id, code_hash)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
account.get("name", "live"),
|
||
account.get("account", ""),
|
||
account.get("vt_symbol", ""),
|
||
account.get("strategy_class", "AShareDoubleMaStrategy"),
|
||
account.get("strategy_name", ""),
|
||
json.dumps(account.get("setting", {})),
|
||
account.get("status", "stopped"),
|
||
account.get("interval", "15m"),
|
||
account.get("initial_capital", 1_000_000),
|
||
int(account.get("connect_wait_sec", 10)),
|
||
int(account.get("init_wait_sec", 60)),
|
||
account.get("mini_path", ""),
|
||
_now(), _now(),
|
||
account.get("strategy_type", "cta"),
|
||
account.get("pool", ""),
|
||
int(account.get("max_pool", 0) or 0),
|
||
account.get("benchmark", ""),
|
||
account.get("instance_id"),
|
||
account.get("code_hash"),
|
||
),
|
||
)
|
||
conn.commit()
|
||
return cur.lastrowid
|
||
|
||
|
||
def list_accounts(db_path: str) -> list[dict]:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute("SELECT * FROM live_accounts ORDER BY id DESC")
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
def get_account(db_path: str, account_id: int) -> dict | None:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT * FROM live_accounts WHERE id=?", (account_id,)
|
||
)
|
||
row = cur.fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def update_account_status(
|
||
db_path: str, account_id: int, status: str, error_msg: str = ""
|
||
) -> None:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"UPDATE live_accounts SET status=?, error_msg=?, updated_at=? WHERE id=?",
|
||
(status, error_msg, _now(), account_id),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def list_running_accounts(db_path: str) -> list[dict]:
|
||
"""supervisor 轮询:取所有 status=running 的实例。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT * FROM live_accounts WHERE status=? ORDER BY id", ("running",)
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
# ----------------- live_trades -----------------
|
||
|
||
def save_trade(db_path: str, account_id: int, trade: dict[str, Any]) -> int:
|
||
with sqlite3.connect(db_path) as conn:
|
||
cur = conn.execute(
|
||
"""INSERT INTO live_trades
|
||
(account_id, strategy_name, symbol, direction, offset,
|
||
price, volume, traded_at, vt_tradeid)
|
||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
account_id, trade.get("strategy_name", ""),
|
||
trade.get("symbol", ""), trade.get("direction", ""),
|
||
trade.get("offset", ""), trade.get("price", 0),
|
||
trade.get("volume", 0), trade.get("traded_at", ""),
|
||
trade.get("vt_tradeid", ""),
|
||
),
|
||
)
|
||
conn.commit()
|
||
return cur.lastrowid
|
||
|
||
|
||
def list_trades(db_path: str, account_id: int) -> list[dict]:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT * FROM live_trades WHERE account_id=? ORDER BY id",
|
||
(account_id,),
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
# ----------------- live_positions (覆盖式快照) -----------------
|
||
|
||
def save_positions(
|
||
db_path: str, account_id: int, positions: dict[str, dict]
|
||
) -> None:
|
||
"""覆盖式落库。positions = {symbol: {volume, frozen, avg_price}}。
|
||
|
||
supervisor 每 snapshot_interval_sec 调一次,把 OMS 最新 PositionData 覆盖落库。
|
||
只保留 volume>0 的持仓。
|
||
"""
|
||
now = _now()
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute("DELETE FROM live_positions WHERE account_id=?", (account_id,))
|
||
conn.executemany(
|
||
"""INSERT INTO live_positions
|
||
(account_id, symbol, volume, frozen, avg_price, updated_at)
|
||
VALUES (?,?,?,?,?,?)""",
|
||
[
|
||
(account_id, sym, p["volume"], p.get("frozen", 0),
|
||
p["avg_price"], now)
|
||
for sym, p in positions.items() if p.get("volume", 0) > 0
|
||
],
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def load_positions(db_path: str, account_id: int) -> list[dict]:
|
||
"""API 读持仓快照 → [{symbol, volume, frozen, avg_price, updated_at}]。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT symbol, volume, frozen, avg_price, updated_at "
|
||
"FROM live_positions WHERE account_id=?",
|
||
(account_id,),
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
# ----------------- live_balance -----------------
|
||
|
||
def save_balance(
|
||
db_path: str, account_id: int, date: str, cash: float,
|
||
market_value: float, total: float
|
||
) -> None:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"""INSERT INTO live_balance
|
||
(account_id, date, cash, market_value, total)
|
||
VALUES (?,?,?,?,?)""",
|
||
(account_id, date, cash, market_value, total),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def list_balance(db_path: str, account_id: int) -> list[dict]:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT * FROM live_balance WHERE account_id=? ORDER BY date, id",
|
||
(account_id,),
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
def get_last_balance(db_path: str, account_id: int) -> dict | None:
|
||
"""最新一条账户快照(API /live/{aid}/account)。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT account_id, date, cash, market_value, total "
|
||
"FROM live_balance WHERE account_id=? ORDER BY id DESC LIMIT 1",
|
||
(account_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
def get_first_balance(db_path: str, account_id: int) -> dict | None:
|
||
"""最早一条账户快照(收益率基线;首快照 total = baseline)。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT account_id, date, cash, market_value, total "
|
||
"FROM live_balance WHERE account_id=? ORDER BY id ASC LIMIT 1",
|
||
(account_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
# ----------------- qmt_account_snapshot (B1 全局账户快照,spec §B1) -----------------
|
||
|
||
def upsert_account_snapshot(
|
||
db_path: str, account: str, cash: float, market_value: float,
|
||
total: float, positions: list[dict], mini_path: str = "",
|
||
) -> None:
|
||
"""upsert 单行全局快照(按 QMT 账号一行,不挂实例)。
|
||
|
||
positions = [{symbol,volume,can_use,avg_price,mv}] 存 JSON。
|
||
mini_path 为空时不覆盖已有值(sticky:删光重建期监视器靠它记住路径)。
|
||
"""
|
||
now = _now()
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"""INSERT INTO qmt_account_snapshot
|
||
(account, mini_path, cash, market_value, total, positions, updated_at)
|
||
VALUES (?,?,?,?,?,?,?)
|
||
ON CONFLICT(account) DO UPDATE SET
|
||
mini_path=CASE WHEN ?!='' THEN ? ELSE qmt_account_snapshot.mini_path END,
|
||
cash=excluded.cash, market_value=excluded.market_value,
|
||
total=excluded.total, positions=excluded.positions,
|
||
updated_at=excluded.updated_at""",
|
||
(account, mini_path, cash, market_value, total,
|
||
json.dumps(positions, ensure_ascii=False), now,
|
||
mini_path, mini_path),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def get_account_snapshot(db_path: str, account: str) -> dict | None:
|
||
"""读快照(positions JSON 解析回 list);无行返回 None。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT * FROM qmt_account_snapshot WHERE account=?", (account,)
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
return None
|
||
d = dict(row)
|
||
try:
|
||
d["positions"] = json.loads(d.get("positions") or "[]")
|
||
except (ValueError, TypeError):
|
||
d["positions"] = []
|
||
return d
|
||
|
||
|
||
def list_snapshot_accounts(db_path: str) -> list[dict]:
|
||
"""全部快照行(account+mini_path)——监视器 sticky 账户来源。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT account, mini_path FROM qmt_account_snapshot"
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
def _snapshot_age_sec(updated_at: str | None) -> float:
|
||
"""updated_at(ISO,UTC)距今秒数;解析失败按无穷旧处理。"""
|
||
if not updated_at:
|
||
return float("inf")
|
||
try:
|
||
ts = datetime.fromisoformat(updated_at)
|
||
if ts.tzinfo is None:
|
||
ts = ts.replace(tzinfo=timezone.utc)
|
||
return (datetime.now(timezone.utc) - ts).total_seconds()
|
||
except ValueError:
|
||
return float("inf")
|
||
|
||
|
||
def get_fresh_account_snapshot(
|
||
db_path: str, account: str, max_age_sec: float = 600.0
|
||
) -> dict | None:
|
||
"""新鲜快照(默认 10 分钟内);缺失/过期返回 None——B3 预算校验 fail-closed 用。"""
|
||
snap = get_account_snapshot(db_path, account)
|
||
if snap is None:
|
||
return None
|
||
if _snapshot_age_sec(snap.get("updated_at")) > max_age_sec:
|
||
return None
|
||
return snap
|
||
|
||
|
||
__all__ = [
|
||
"init_db", "save_account", "list_accounts", "get_account",
|
||
"update_account_status", "list_running_accounts",
|
||
"save_trade", "list_trades",
|
||
"save_positions", "load_positions",
|
||
"save_balance", "list_balance", "get_last_balance", "get_first_balance",
|
||
"upsert_account_snapshot", "get_account_snapshot",
|
||
"list_snapshot_accounts", "get_fresh_account_snapshot",
|
||
]
|