333 lines
14 KiB
Python
333 lines
14 KiB
Python
"""模拟盘 SQLite 持久化(4 表 + pending + checkpoint + WAL,spec §8 / §9.1)。
|
||
|
||
表:paper_accounts / paper_trades / paper_positions / paper_daily_balance
|
||
/ paper_pending_orders(C-S3 实走跨日 pending)
|
||
WAL 模式支持 worker 进程写 + 主进程读(spec §9.1 共享 DB 进度)。
|
||
"""
|
||
import json
|
||
import sqlite3
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS paper_accounts (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
task_id TEXT, owner_id TEXT DEFAULT 'admin', name TEXT,
|
||
strategy_type TEXT DEFAULT 'cta',
|
||
mode TEXT, interval TEXT,
|
||
symbols TEXT, strategies TEXT,
|
||
initial_capital REAL, rate REAL, slippage REAL, size REAL, pricetick REAL,
|
||
stamp_duty_rate REAL, transfer_fee_rate REAL, min_commission REAL,
|
||
status TEXT, start_date TEXT, end_date TEXT,
|
||
last_run_date TEXT, next_run_at TEXT, scheduler_job_id TEXT,
|
||
checkpoint_date TEXT, error_msg TEXT,
|
||
created_at TEXT, updated_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS paper_trades (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER, strategy_id TEXT, datetime TEXT, symbol TEXT,
|
||
direction TEXT, offset TEXT, match_session TEXT,
|
||
price REAL, volume INTEGER,
|
||
commission REAL, stamp_duty REAL, transfer_fee REAL,
|
||
rejected INTEGER, reject_reason TEXT, bar_date TEXT,
|
||
blocked_by_strategy_id TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS paper_positions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER, scope TEXT, symbol TEXT, date TEXT,
|
||
volume INTEGER, frozen INTEGER, avg_price REAL, market_value REAL,
|
||
updated_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS paper_daily_balance (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER, date TEXT,
|
||
cash REAL, market_value REAL, total_equity REAL,
|
||
per_strategy_pnl TEXT, is_checkpoint INTEGER
|
||
);
|
||
CREATE TABLE IF NOT EXISTS paper_pending_orders (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER, strategy_id TEXT, symbol TEXT,
|
||
side TEXT, price REAL, volume INTEGER,
|
||
is_market INTEGER, match_session TEXT, listing_days INTEGER,
|
||
created_at TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS paper_shadow_orders (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
account_id INTEGER, trade_id INTEGER, bridge_order_id INTEGER,
|
||
status TEXT, posted_at TEXT,
|
||
UNIQUE(account_id, trade_id)
|
||
);
|
||
"""
|
||
|
||
|
||
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)
|
||
# 迁移:老库补 strategy_type 列(组合策略实走,E1)
|
||
try:
|
||
conn.execute("ALTER TABLE paper_accounts ADD COLUMN strategy_type TEXT DEFAULT 'cta'")
|
||
except sqlite3.OperationalError:
|
||
pass # 列已存在
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.commit()
|
||
|
||
|
||
def save_account(db_path: str, account: dict[str, Any]) -> int:
|
||
with sqlite3.connect(db_path) as conn:
|
||
cur = conn.execute(
|
||
"""INSERT INTO paper_accounts
|
||
(task_id, owner_id, name, strategy_type, mode, interval, symbols, strategies,
|
||
initial_capital, rate, slippage, size, pricetick,
|
||
stamp_duty_rate, transfer_fee_rate, min_commission,
|
||
status, start_date, end_date, created_at, updated_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
account.get("task_id"), account.get("owner_id", "admin"),
|
||
account.get("name"), account.get("strategy_type", "cta"),
|
||
account.get("mode"), account.get("interval"),
|
||
json.dumps(account.get("symbols", [])),
|
||
json.dumps(account.get("strategies", [])),
|
||
account.get("initial_capital", 0), account.get("rate", 0.0003),
|
||
account.get("slippage", 0), account.get("size", 1),
|
||
account.get("pricetick", 0.01),
|
||
account.get("stamp_duty_rate", 0.0005),
|
||
account.get("transfer_fee_rate", 0.00001),
|
||
account.get("min_commission", 5.0),
|
||
account.get("status", "pending"),
|
||
account.get("start_date") or account.get("start"),
|
||
account.get("end_date") or account.get("end"),
|
||
_now(), _now(),
|
||
),
|
||
)
|
||
conn.commit()
|
||
return cur.lastrowid
|
||
|
||
|
||
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 paper_accounts SET status=?, error_msg=?, updated_at=? WHERE id=?",
|
||
(status, error_msg, _now(), account_id),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def save_trade(db_path: str, account_id: int, trade: dict[str, Any],
|
||
rejected: bool = False, reject_reason: str = "") -> int:
|
||
with sqlite3.connect(db_path) as conn:
|
||
cur = conn.execute(
|
||
"""INSERT INTO paper_trades
|
||
(account_id, strategy_id, datetime, symbol, direction, offset,
|
||
match_session, price, volume, commission, stamp_duty, transfer_fee,
|
||
rejected, reject_reason, bar_date, blocked_by_strategy_id)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(
|
||
account_id, trade.get("strategy_id", ""), trade.get("datetime", ""),
|
||
trade.get("symbol", ""), trade.get("direction", ""),
|
||
trade.get("offset", ""), trade.get("match_session", ""),
|
||
trade.get("price", 0), trade.get("volume", 0),
|
||
trade.get("commission", 0), trade.get("stamp_duty", 0),
|
||
trade.get("transfer_fee", 0),
|
||
1 if rejected else 0, reject_reason, trade.get("bar_date", ""),
|
||
trade.get("blocked_by_strategy_id"),
|
||
),
|
||
)
|
||
conn.commit()
|
||
return cur.lastrowid
|
||
|
||
|
||
def save_daily_balance(db_path: str, account_id: int, date: str, cash: float,
|
||
market_value: float, total_equity: float,
|
||
per_strategy_pnl: dict | None = None,
|
||
is_checkpoint: bool = False) -> None:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"""INSERT INTO paper_daily_balance
|
||
(account_id, date, cash, market_value, total_equity,
|
||
per_strategy_pnl, is_checkpoint)
|
||
VALUES (?,?,?,?,?,?,?)""",
|
||
(account_id, date, cash, market_value, total_equity,
|
||
json.dumps(per_strategy_pnl or {}), 1 if is_checkpoint else 0),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def update_checkpoint(db_path: str, account_id: int, date: str) -> None:
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"UPDATE paper_accounts SET checkpoint_date=?, updated_at=? WHERE id=?",
|
||
(date, _now(), account_id),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def load_checkpoint(db_path: str, account_id: int) -> str | None:
|
||
with sqlite3.connect(db_path) as conn:
|
||
cur = conn.execute(
|
||
"SELECT checkpoint_date FROM paper_accounts WHERE id=?", (account_id,)
|
||
)
|
||
row = cur.fetchone()
|
||
return row[0] if row else None
|
||
|
||
|
||
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 paper_trades WHERE account_id=? ORDER BY id", (account_id,)
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
def list_daily_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 paper_daily_balance WHERE account_id=? ORDER BY date",
|
||
(account_id,),
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
def list_strategy_summary(db_path: str, account_id: int) -> list[dict]:
|
||
"""按 strategy_id 聚合成交/拒单/费用(分户归因,spec §7)。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"""SELECT strategy_id,
|
||
COUNT(*) AS total_orders,
|
||
SUM(CASE WHEN rejected=0 THEN 1 ELSE 0 END) AS filled,
|
||
SUM(CASE WHEN rejected=1 THEN 1 ELSE 0 END) AS rejected,
|
||
SUM(commission) AS commission,
|
||
SUM(stamp_duty) AS stamp_duty,
|
||
SUM(transfer_fee) AS transfer_fee
|
||
FROM paper_trades WHERE account_id=?
|
||
GROUP BY strategy_id ORDER BY strategy_id""",
|
||
(account_id,),
|
||
)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
|
||
|
||
# ---- C-S3 实走跨日状态(pending 订单 + positions 恢复)----
|
||
|
||
def save_pending_orders(db_path: str, account_id: int, orders: list[dict]) -> None:
|
||
"""持久化跨日 pending 订单(NEXT_OPEN 昨日发今日撮合)。覆盖式(每根 step 后重写)。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"DELETE FROM paper_pending_orders WHERE account_id=?", (account_id,)
|
||
)
|
||
conn.executemany(
|
||
"""INSERT INTO paper_pending_orders
|
||
(account_id, strategy_id, symbol, side, price, volume,
|
||
is_market, match_session, listing_days, created_at)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
||
[
|
||
(account_id, o.get("strategy_id", ""), o.get("symbol", ""),
|
||
o.get("side", ""), o.get("price", 0), o.get("volume", 0),
|
||
1 if o.get("is_market") else 0, o.get("match_session", ""),
|
||
o.get("listing_days", 0), _now())
|
||
for o in orders
|
||
],
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def load_pending_orders(db_path: str, account_id: int) -> list[dict]:
|
||
"""恢复跨日 pending 订单(实走 step 前读)。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT strategy_id, symbol, side, price, volume, "
|
||
"is_market, match_session, listing_days "
|
||
"FROM paper_pending_orders WHERE account_id=? ORDER BY id",
|
||
(account_id,),
|
||
)
|
||
rows = [dict(r) for r in cur.fetchall()]
|
||
for r in rows:
|
||
r["is_market"] = bool(r["is_market"])
|
||
return rows
|
||
|
||
|
||
def save_positions(db_path: str, account_id: int, scope: str,
|
||
positions: dict, date: str) -> None:
|
||
"""持久化持仓快照(实走跨日恢复)。positions={symbol:{volume,frozen,avg_price}}。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"DELETE FROM paper_positions WHERE account_id=? AND scope=?",
|
||
(account_id, scope),
|
||
)
|
||
conn.executemany(
|
||
"""INSERT INTO paper_positions
|
||
(account_id, scope, symbol, date, volume, frozen, avg_price, updated_at)
|
||
VALUES (?,?,?,?,?,?,?,?)""",
|
||
[
|
||
(account_id, scope, sym, date, 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, scope: str) -> dict:
|
||
"""恢复持仓快照 → {symbol:{volume,frozen,avg_price}}。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT symbol, volume, frozen, avg_price FROM paper_positions "
|
||
"WHERE account_id=? AND scope=?",
|
||
(account_id, scope),
|
||
)
|
||
return {r["symbol"]: {"volume": r["volume"], "frozen": r["frozen"],
|
||
"avg_price": r["avg_price"]}
|
||
for r in cur.fetchall()}
|
||
|
||
|
||
def load_last_balance(db_path: str, account_id: int) -> dict | None:
|
||
"""实走恢复:最后一条余额(cash 用于恢复 account.cash)。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.row_factory = sqlite3.Row
|
||
cur = conn.execute(
|
||
"SELECT cash, market_value, total_equity, date FROM paper_daily_balance "
|
||
"WHERE account_id=? ORDER BY date DESC LIMIT 1",
|
||
(account_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
return dict(row) if row else None
|
||
|
||
|
||
# ---- D-3 影子下单幂等(spec §5 模式 A)----
|
||
|
||
def save_shadow_order(db_path: str, account_id: int, trade_id: int,
|
||
bridge_order_id: int | None, status: str) -> None:
|
||
"""记录影子下单结果(幂等:UNIQUE(account_id, trade_id),重复 INSERT 被 IGNORE)。
|
||
|
||
无论 ok/failed 都记录 —— 保证 scheduler 重跑不重复 POST(「不能重复下单」硬约束)。
|
||
status 取值:'ok'(bridge 返回 ok=true)/ 'failed'(ok=false 或网络失败)。
|
||
"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
conn.execute(
|
||
"""INSERT OR IGNORE INTO paper_shadow_orders
|
||
(account_id, trade_id, bridge_order_id, status, posted_at)
|
||
VALUES (?,?,?,?,?)""",
|
||
(account_id, trade_id, bridge_order_id, status, _now()),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def is_trade_shadowed(db_path: str, account_id: int, trade_id: int) -> bool:
|
||
"""该成交是否已影子下单(幂等去重,避免 scheduler 重试重复 POST)。"""
|
||
with sqlite3.connect(db_path) as conn:
|
||
cur = conn.execute(
|
||
"SELECT 1 FROM paper_shadow_orders WHERE account_id=? AND trade_id=?",
|
||
(account_id, trade_id),
|
||
)
|
||
return cur.fetchone() is not None
|