171 lines
7.3 KiB
Python
171 lines
7.3 KiB
Python
"""SQLite 状态簿:每个账户/策略使用独立数据库,单个策略串行读写。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import sqlite3
|
||
from contextlib import closing
|
||
from dataclasses import asdict
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from sdk import DealItem
|
||
|
||
SCHEMA = """
|
||
CREATE TABLE IF NOT EXISTS positions (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
code TEXT NOT NULL,
|
||
base_order_id TEXT NOT NULL DEFAULT '',
|
||
base_qty INTEGER NOT NULL DEFAULT 0 CHECK (base_qty >= 0),
|
||
base_cost REAL NOT NULL DEFAULT 0.0 CHECK (base_cost >= 0),
|
||
added_order_id TEXT NOT NULL DEFAULT '',
|
||
added_num INTEGER NOT NULL DEFAULT 0 CHECK (added_num >= 0),
|
||
added_qty INTEGER NOT NULL DEFAULT 0 CHECK (added_qty >= 0),
|
||
added_cost REAL NOT NULL DEFAULT 0.0 CHECK (added_cost >= 0),
|
||
status TEXT NOT NULL
|
||
);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_code ON positions (code);
|
||
CREATE INDEX IF NOT EXISTS idx_positions_base_order_id ON positions (base_order_id);
|
||
CREATE INDEX IF NOT EXISTS idx_positions_added_order_id ON positions (added_order_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS deals (
|
||
id INTEGER PRIMARY KEY,
|
||
sys_order_id TEXT NOT NULL UNIQUE CHECK (sys_order_id <> ''),
|
||
local_order_id TEXT NOT NULL,
|
||
code TEXT NOT NULL,
|
||
instrument_id TEXT NOT NULL,
|
||
exchange_id TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
offset_flag TEXT NOT NULL,
|
||
side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
|
||
status TEXT NOT NULL,
|
||
remaining_volume INTEGER NOT NULL CHECK (remaining_volume >= 0),
|
||
traded_volume INTEGER NOT NULL CHECK (traded_volume > 0),
|
||
order_time INTEGER NOT NULL,
|
||
insert_date TEXT NOT NULL,
|
||
insert_time TEXT NOT NULL,
|
||
remark TEXT NOT NULL,
|
||
price REAL NOT NULL,
|
||
trade_price REAL NOT NULL CHECK (trade_price >= 0),
|
||
trade_amount REAL NOT NULL CHECK (trade_amount > 0)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_deals_local_order_id ON deals (local_order_id);
|
||
CREATE INDEX IF NOT EXISTS idx_deals_code_date ON deals (code, insert_date);
|
||
CREATE INDEX IF NOT EXISTS idx_deals_date_time ON deals (insert_date, insert_time);
|
||
"""
|
||
|
||
|
||
class OrderBook:
|
||
"""Persist position snapshots and append-only executions; one writer per database."""
|
||
|
||
def __init__(self, path: str | Path) -> None:
|
||
self.path = Path(path)
|
||
self.positions: dict[str, dict] = {}
|
||
self.deals: dict[str, dict] = {}
|
||
self.deals_sys_ids: set[str] = set()
|
||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||
with closing(self._connect()) as db:
|
||
db.executescript(SCHEMA)
|
||
self.load()
|
||
|
||
def _connect(self) -> sqlite3.Connection:
|
||
db = sqlite3.connect(self.path, timeout=30)
|
||
db.row_factory = sqlite3.Row
|
||
return db
|
||
|
||
def load(self) -> None:
|
||
with closing(self._connect()) as db, db:
|
||
db.execute('BEGIN')
|
||
positions = {row['code']: dict(row) for row in db.execute('SELECT * FROM positions')}
|
||
deals = {row['sys_order_id']: dict(row) for row in db.execute('SELECT * FROM deals ORDER BY id')}
|
||
self.positions = positions
|
||
self.deals = deals
|
||
self.deals_sys_ids = set(deals)
|
||
self._deal_count = len(deals)
|
||
|
||
@staticmethod
|
||
def _insert_deals(db: sqlite3.Connection, deals: list[dict]) -> None:
|
||
db.executemany(
|
||
"""INSERT INTO deals
|
||
(sys_order_id, local_order_id, code, instrument_id, exchange_id, name,
|
||
offset_flag, side, status, remaining_volume, traded_volume, order_time,
|
||
insert_date, insert_time, remark, price, trade_price, trade_amount)
|
||
VALUES (:sys_order_id, :local_order_id, :code, :instrument_id, :exchange_id, :name,
|
||
:offset_flag, :side, :status, :remaining_volume, :traded_volume, :order_time,
|
||
:insert_date, :insert_time, :remark, :price, :trade_price, :trade_amount)""",
|
||
deals,
|
||
)
|
||
|
||
@staticmethod
|
||
def deal_record(deal: DealItem) -> dict:
|
||
"""API 字段转入库记录;委托价格不用于推算成交金额。"""
|
||
if not deal.sys_order_id or deal.traded_volume <= 0:
|
||
raise ValueError('System order ID and positive traded volume are required')
|
||
row = asdict(deal)
|
||
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
|
||
raise ValueError('Prices and amounts must be finite')
|
||
amount = deal.trade_amount if deal.trade_amount > 0 else deal.trade_price * deal.traded_volume
|
||
if not math.isfinite(amount) or amount <= 0:
|
||
raise ValueError('Execution amount must be positive and finite')
|
||
row['trade_amount'] = amount
|
||
date = deal.insert_date or datetime.now().date().isoformat()
|
||
if len(date) == 8 and date.isdigit():
|
||
date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
|
||
row['insert_date'] = date
|
||
return row
|
||
|
||
def sync_deals(self, deals: list[DealItem]) -> None:
|
||
"""按系统订单号去重,批量写入已成交数据;失败时不更新缓存。"""
|
||
new_deals = []
|
||
seen = self.deals_sys_ids.copy()
|
||
for deal in deals:
|
||
if deal.sys_order_id in seen:
|
||
continue
|
||
new_deals.append(self.deal_record(deal))
|
||
seen.add(deal.sys_order_id)
|
||
if not new_deals:
|
||
return
|
||
with closing(self._connect()) as db, db:
|
||
self._insert_deals(db, new_deals)
|
||
self.load()
|
||
|
||
def save(self, items: dict, deals: list[dict]) -> None:
|
||
if len(deals) < self._deal_count:
|
||
raise ValueError('Execution history is append-only')
|
||
new_deals = deals[self._deal_count:]
|
||
positions = [
|
||
{
|
||
'base_order_id': '', 'base_qty': 0, 'base_cost': 0.0,
|
||
'added_order_id': '', 'added_num': 0, 'added_qty': 0, 'added_cost': 0.0,
|
||
**item,
|
||
}
|
||
for item in items.values()
|
||
]
|
||
for row in [*items.values(), *new_deals]:
|
||
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
|
||
raise ValueError('Quantities, prices and amounts must be finite')
|
||
with closing(self._connect()) as db, db:
|
||
# 更新已有证券时保留其自增 ID;仅删除快照中已移除的证券。
|
||
for row in db.execute('SELECT code FROM positions').fetchall():
|
||
if row['code'] not in items:
|
||
db.execute('DELETE FROM positions WHERE code = ?', (row['code'],))
|
||
db.executemany(
|
||
"""INSERT INTO positions
|
||
(code, base_order_id, base_qty, base_cost,
|
||
added_order_id, added_num, added_qty, added_cost, status)
|
||
VALUES (:code, :base_order_id, :base_qty, :base_cost,
|
||
:added_order_id, :added_num, :added_qty, :added_cost, :status)
|
||
ON CONFLICT(code) DO UPDATE SET
|
||
base_order_id = excluded.base_order_id,
|
||
base_qty = excluded.base_qty,
|
||
base_cost = excluded.base_cost,
|
||
added_order_id = excluded.added_order_id,
|
||
added_num = excluded.added_num,
|
||
added_qty = excluded.added_qty,
|
||
added_cost = excluded.added_cost,
|
||
status = excluded.status""",
|
||
positions,
|
||
)
|
||
self._insert_deals(db, new_deals)
|
||
self.load()
|