Files

333 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""模拟盘 SQLite 持久化(4 表 + pending + checkpoint + WALspec §8 / §9.1)。
表:paper_accounts / paper_trades / paper_positions / paper_daily_balance
/ paper_pending_ordersC-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