Files
big-qmt/py-client/libs/orderbook.py

160 lines
6.6 KiB
Python
Raw Normal View History

2026-09-07 14:04:26 +08:00
"""SQLite positions and deals, aligned with SDK models; one writer per database."""
2026-09-07 00:27:33 +08:00
import math
import sqlite3
from contextlib import closing
2026-09-07 14:04:26 +08:00
from dataclasses import asdict, fields
2026-09-07 00:27:33 +08:00
from datetime import datetime
from pathlib import Path
2026-09-07 21:22:51 +08:00
from itertools import chain
2026-09-07 00:27:33 +08:00
2026-09-07 14:04:26 +08:00
from sdk import DealItem, PositionItem
2026-09-07 00:27:33 +08:00
SCHEMA = """
CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
2026-09-07 14:04:26 +08:00
stock_code TEXT NOT NULL,
stock_name TEXT NOT NULL DEFAULT '',
direction INTEGER,
volume INTEGER NOT NULL DEFAULT 0 CHECK (volume >= 0),
open_price REAL NOT NULL DEFAULT 0,
open_cost REAL NOT NULL DEFAULT 0,
float_profit REAL NOT NULL DEFAULT 0,
market_value REAL NOT NULL DEFAULT 0,
stock_holder TEXT NOT NULL DEFAULT '',
frozen_volume INTEGER NOT NULL DEFAULT 0,
can_use_volume INTEGER NOT NULL DEFAULT 0,
on_road_volume INTEGER NOT NULL DEFAULT 0,
yesterday_volume INTEGER NOT NULL DEFAULT 0,
last_price REAL NOT NULL DEFAULT 0,
profit_rate REAL NOT NULL DEFAULT 0,
future_trade_type INTEGER,
expire_date TEXT NOT NULL DEFAULT ''
2026-09-07 00:27:33 +08:00
);
2026-09-07 14:04:26 +08:00
CREATE UNIQUE INDEX IF NOT EXISTS idx_positions_stock_code ON positions (stock_code);
2026-09-07 00:27:33 +08:00
CREATE TABLE IF NOT EXISTS deals (
2026-09-07 14:04:26 +08:00
id INTEGER PRIMARY KEY AUTOINCREMENT,
stock_code TEXT NOT NULL,
order_sys_id TEXT NOT NULL CHECK (order_sys_id <> ''),
order_local_id TEXT NOT NULL CHECK (order_local_id <> ''),
ref INTEGER NOT NULL DEFAULT 0,
order_ref TEXT NOT NULL DEFAULT '',
direction INTEGER NOT NULL DEFAULT 0,
offset_flag INTEGER NOT NULL CHECK (offset_flag IN (23, 24, 48, 49)),
price REAL NOT NULL CHECK (price >= 0),
volume INTEGER NOT NULL CHECK (volume > 0),
trade_amount REAL NOT NULL CHECK (trade_amount > 0),
trade_date TEXT NOT NULL,
trade_time TEXT NOT NULL,
remark TEXT NOT NULL DEFAULT '',
close_profit REAL NOT NULL DEFAULT 0
2026-09-07 00:27:33 +08:00
);
2026-09-07 14:04:26 +08:00
CREATE UNIQUE INDEX IF NOT EXISTS idx_deals_order_sys_id ON deals (order_sys_id);
CREATE INDEX IF NOT EXISTS idx_deals_order_ref ON deals (order_local_id);
CREATE INDEX IF NOT EXISTS idx_deals_stock_code_date ON deals (stock_code);
CREATE INDEX IF NOT EXISTS idx_deals_date_time ON deals (trade_date);
2026-09-07 00:27:33 +08:00
"""
2026-09-07 14:04:26 +08:00
# SQL is assembled once; order_local_id is derived from the SDK remark property.
POSITION_FIELDS = tuple(field.name for field in fields(PositionItem))
DEAL_FIELDS = tuple(field.name for field in fields(DealItem)) + ('order_local_id',)
2026-09-07 18:18:00 +08:00
POSITION_DEFAULTS = asdict(PositionItem())
2026-09-07 14:04:26 +08:00
POSITION_UPSERT = (
f"INSERT INTO positions ({', '.join(POSITION_FIELDS)}) "
f"VALUES ({', '.join(':' + key for key in POSITION_FIELDS)}) "
"ON CONFLICT(stock_code) DO UPDATE SET "
+ ', '.join(f'{key} = excluded.{key}' for key in POSITION_FIELDS if key != 'stock_code')
)
DEAL_INSERT = (
f"INSERT INTO deals ({', '.join(DEAL_FIELDS)}) "
f"VALUES ({', '.join(':' + key for key in DEAL_FIELDS)})"
)
2026-09-07 00:27:33 +08:00
class OrderBook:
2026-09-07 14:04:26 +08:00
"""Position snapshots and append-only deals. No schema migration."""
2026-09-07 00:27:33 +08:00
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')
2026-09-07 14:04:26 +08:00
positions = {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM positions')}
deals = {row['order_sys_id']: dict(row) for row in db.execute('SELECT * FROM deals ORDER BY id')}
2026-09-07 00:27:33 +08:00
self.positions = positions
self.deals = deals
self.deals_sys_ids = set(deals)
@staticmethod
def deal_record(deal: DealItem) -> dict:
2026-09-07 14:04:26 +08:00
if not deal.order_sys_id or deal.volume <= 0:
raise ValueError('System order ID and positive volume are required')
2026-09-07 18:18:00 +08:00
2026-09-07 00:27:33 +08:00
row = asdict(deal)
2026-09-07 21:22:51 +08:00
row['order_local_id'] = deal.local_order_id
2026-09-07 18:18:00 +08:00
if not row['order_local_id']:
raise ValueError('Local order ID is required')
2026-09-07 00:27:33 +08:00
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
2026-09-07 14:04:26 +08:00
raise ValueError('Numeric values must be finite')
amount = deal.trade_amount if deal.trade_amount > 0 else deal.price * deal.volume
2026-09-07 00:27:33 +08:00
if not math.isfinite(amount) or amount <= 0:
2026-09-07 14:04:26 +08:00
raise ValueError('Trade amount must be positive and finite')
2026-09-07 00:27:33 +08:00
row['trade_amount'] = amount
2026-09-07 14:04:26 +08:00
date = deal.trade_date or datetime.now().date().isoformat()
2026-09-07 00:27:33 +08:00
if len(date) == 8 and date.isdigit():
date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
2026-09-07 14:04:26 +08:00
row['trade_date'] = date
2026-09-07 00:27:33 +08:00
return row
def sync_deals(self, deals: list[DealItem]) -> None:
2026-09-07 18:18:00 +08:00
new_deals = {}
2026-09-07 00:27:33 +08:00
for deal in deals:
2026-09-07 18:18:00 +08:00
if deal.order_sys_id not in self.deals_sys_ids and deal.order_sys_id not in new_deals:
new_deals[deal.order_sys_id] = self.deal_record(deal)
2026-09-07 00:27:33 +08:00
if not new_deals:
return
with closing(self._connect()) as db, db:
2026-09-07 18:18:00 +08:00
db.executemany(DEAL_INSERT, new_deals.values())
2026-09-07 00:27:33 +08:00
self.load()
2026-09-07 14:04:26 +08:00
def sync_positions(self, positions: list[PositionItem]) -> None:
"""Replace the complete position snapshot, retaining IDs for existing stocks."""
2026-09-07 18:18:00 +08:00
self.save({item.stock_code: asdict(item) for item in positions})
2026-09-07 14:04:26 +08:00
2026-09-07 18:18:00 +08:00
def save(self, items: dict, deals: list[dict] | None = None) -> None:
"""保存持仓快照,可同时追加成交;省略 deals 时仅更新持仓。"""
new_deals = []
if deals is not None:
if len(deals) < len(self.deals):
raise ValueError('Execution history is append-only')
new_deals = deals[len(self.deals):]
positions = [{**POSITION_DEFAULTS, **item} for item in items.values()]
2026-09-07 21:22:51 +08:00
for row in chain(positions, new_deals):
2026-09-07 00:27:33 +08:00
if any(isinstance(value, float) and not math.isfinite(value) for value in row.values()):
2026-09-07 14:04:26 +08:00
raise ValueError('Numeric values must be finite')
2026-09-07 00:27:33 +08:00
with closing(self._connect()) as db, db:
2026-09-07 18:18:00 +08:00
removed = [
(row['stock_code'],)
for row in db.execute('SELECT stock_code FROM positions')
if row['stock_code'] not in items
]
db.executemany('DELETE FROM positions WHERE stock_code = ?', removed)
2026-09-07 14:04:26 +08:00
db.executemany(POSITION_UPSERT, positions)
2026-09-07 18:18:00 +08:00
db.executemany(DEAL_INSERT, new_deals)
2026-09-07 00:27:33 +08:00
self.load()