260 lines
13 KiB
Python
260 lines
13 KiB
Python
|
|
"""SQLite 策略状态与成交存储;每个数据库仅使用一个写入者,不做数据迁移。"""
|
|||
|
|
|
|||
|
|
import math
|
|||
|
|
import json
|
|||
|
|
import logging as log
|
|||
|
|
import sqlite3
|
|||
|
|
from contextlib import closing
|
|||
|
|
from dataclasses import asdict, dataclass
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from sdk import DealItem, PositionItem
|
|||
|
|
|
|||
|
|
SCHEMA = """
|
|||
|
|
-- 策略状态:base_ 表示底仓,added_ 表示补仓。
|
|||
|
|
CREATE TABLE IF NOT EXISTS state (
|
|||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 状态记录主键
|
|||
|
|
stock_code TEXT NOT NULL, -- 证券代码
|
|||
|
|
status TEXT NOT NULL DEFAULT '', -- 策略状态,由策略定义取值
|
|||
|
|
base_order_local_id TEXT NOT NULL DEFAULT '', -- 底仓本地委托编号
|
|||
|
|
base_qty INTEGER NOT NULL DEFAULT 0 CHECK (base_qty >= 0), -- 底仓数量
|
|||
|
|
base_price REAL NOT NULL DEFAULT 0, -- 底仓价格
|
|||
|
|
base_created_at TEXT NOT NULL DEFAULT '', -- 底仓创建时间
|
|||
|
|
added_order_local_id TEXT NOT NULL DEFAULT '', -- 补仓本地委托编号
|
|||
|
|
added_qty INTEGER NOT NULL DEFAULT 0 CHECK (added_qty >= 0), -- 补仓数量
|
|||
|
|
added_price REAL NOT NULL DEFAULT 0, -- 补仓价格
|
|||
|
|
added_created_at TEXT NOT NULL DEFAULT '' -- 补仓创建时间
|
|||
|
|
);
|
|||
|
|
-- 每个证券仅保留一条策略状态。
|
|||
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_state_stock_code ON state (stock_code);
|
|||
|
|
|
|||
|
|
-- 首次归档前的持仓基准,清仓后仍保留,供迟到成交按时间重算。
|
|||
|
|
CREATE TABLE IF NOT EXISTS state_origin (
|
|||
|
|
stock_code TEXT PRIMARY KEY, -- 证券代码
|
|||
|
|
snapshot TEXT NOT NULL -- 初始持仓字段的 JSON 快照
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
-- 成交记录独立保存,不随状态删除。
|
|||
|
|
CREATE TABLE IF NOT EXISTS deals (
|
|||
|
|
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,
|
|||
|
|
is_arch INTEGER DEFAULT 0
|
|||
|
|
);
|
|||
|
|
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);
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
@dataclass(slots=True)
|
|||
|
|
class StateItem:
|
|||
|
|
"""策略状态字段;同步账户底仓时无法获知的委托编号留空。"""
|
|||
|
|
|
|||
|
|
stock_code: str = '' # 证券代码
|
|||
|
|
status: str = '' # 策略状态
|
|||
|
|
base_order_local_id: str = '' # 底仓本地委托编号
|
|||
|
|
base_qty: int = 0 # 底仓数量
|
|||
|
|
base_price: float = 0.0 # 底仓价格
|
|||
|
|
base_created_at: str = '' # 底仓创建时间
|
|||
|
|
added_order_local_id: str = '' # 补仓本地委托编号
|
|||
|
|
added_qty: int = 0 # 补仓数量
|
|||
|
|
added_price: float = 0.0 # 补仓价格
|
|||
|
|
added_created_at: str = '' # 补仓创建时间
|
|||
|
|
|
|||
|
|
|
|||
|
|
class State:
|
|||
|
|
"""保存策略状态和只追加的成交记录,仅创建新表,不迁移旧数据。"""
|
|||
|
|
|
|||
|
|
def __init__(self, path: str | Path) -> None:
|
|||
|
|
self.path = Path(path)
|
|||
|
|
self.state: 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')
|
|||
|
|
state = {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM state')}
|
|||
|
|
deals = {row['order_sys_id']: dict(row) for row in db.execute('SELECT * FROM deals ORDER BY id')}
|
|||
|
|
self.state = state
|
|||
|
|
self.deals = deals
|
|||
|
|
self.deals_sys_ids = set(deals)
|
|||
|
|
|
|||
|
|
def sync_deals(self, deals: list[DealItem]) -> None:
|
|||
|
|
"""按系统成交编号去重,整批写入成功后刷新缓存。"""
|
|||
|
|
new_deals = {}
|
|||
|
|
for deal in deals:
|
|||
|
|
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] = deal
|
|||
|
|
if not new_deals:
|
|||
|
|
return
|
|||
|
|
with closing(self._connect()) as db, db:
|
|||
|
|
for deal in new_deals.values():
|
|||
|
|
order_id = deal.get_local_order_id
|
|||
|
|
if not order_id:
|
|||
|
|
raise ValueError('Local order ID is required')
|
|||
|
|
amount = deal.trade_amount if deal.trade_amount > 0 else deal.price * deal.volume
|
|||
|
|
if not math.isfinite(amount) or amount <= 0:
|
|||
|
|
raise ValueError('Trade amount must be positive and finite')
|
|||
|
|
date = deal.trade_date or datetime.now().date().isoformat()
|
|||
|
|
if len(date) == 8 and date.isdigit():
|
|||
|
|
date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
|
|||
|
|
# 直接读取模型字段,金额和日期的补全不修改传入模型。
|
|||
|
|
db.execute(
|
|||
|
|
'INSERT INTO deals (stock_code, order_sys_id, order_local_id, ref, '
|
|||
|
|
'order_ref, direction, offset_flag, price, volume, trade_amount, '
|
|||
|
|
'trade_date, trade_time, remark, close_profit) '
|
|||
|
|
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|||
|
|
(deal.stock_code, deal.order_sys_id, order_id, deal.ref,
|
|||
|
|
deal.order_ref, deal.direction, deal.offset_flag, deal.price, deal.volume,
|
|||
|
|
amount, date, deal.trade_time, deal.remark, deal.close_profit),
|
|||
|
|
)
|
|||
|
|
self.load()
|
|||
|
|
|
|||
|
|
def archiving(self) -> dict[str, str]:
|
|||
|
|
"""按证券从持仓基准重放成交;失败证券保留未归档记录并返回原因。"""
|
|||
|
|
errors = {}
|
|||
|
|
with closing(self._connect()) as db, db:
|
|||
|
|
db.execute('BEGIN IMMEDIATE')
|
|||
|
|
codes = db.execute(
|
|||
|
|
'SELECT DISTINCT stock_code FROM deals WHERE is_arch = 0 AND offset_flag IN (48, 49)'
|
|||
|
|
).fetchall()
|
|||
|
|
for entry in codes:
|
|||
|
|
code = entry['stock_code']
|
|||
|
|
db.execute('SAVEPOINT archive_stock')
|
|||
|
|
try:
|
|||
|
|
current = db.execute('SELECT * FROM state WHERE stock_code = ?', (code,)).fetchone()
|
|||
|
|
origin = db.execute('SELECT snapshot FROM state_origin WHERE stock_code = ?', (code,)).fetchone()
|
|||
|
|
if origin is None:
|
|||
|
|
# 没有旧基准时不能用已归档后的持仓反推历史,不做数据迁移。
|
|||
|
|
if db.execute(
|
|||
|
|
'SELECT 1 FROM deals WHERE stock_code = ? AND is_arch = 1 LIMIT 1', (code,)
|
|||
|
|
).fetchone():
|
|||
|
|
raise ValueError('Missing holding baseline for archived history')
|
|||
|
|
state = dict(current) if current else asdict(StateItem(stock_code=code))
|
|||
|
|
db.execute('INSERT INTO state_origin VALUES (?, ?)', (code, json.dumps(state)))
|
|||
|
|
else:
|
|||
|
|
state = json.loads(origin['snapshot'])
|
|||
|
|
# 数量相等的初始买入视为已包含在快照中,只匹配一次。
|
|||
|
|
snapshot_qty = state['base_qty'] + state['added_qty']
|
|||
|
|
covered = False
|
|||
|
|
deals = db.execute(
|
|||
|
|
'SELECT * FROM deals WHERE stock_code = ? AND offset_flag IN (48, 49) '
|
|||
|
|
"ORDER BY trade_date, REPLACE(trade_time, ':', ''), id", (code,)
|
|||
|
|
).fetchall()
|
|||
|
|
for deal in deals:
|
|||
|
|
qty = deal['volume']
|
|||
|
|
if deal['offset_flag'] == 48:
|
|||
|
|
if not covered and snapshot_qty == qty:
|
|||
|
|
covered = True
|
|||
|
|
continue
|
|||
|
|
covered = True
|
|||
|
|
total = state['added_qty'] + qty
|
|||
|
|
state['added_price'] = (
|
|||
|
|
state['added_qty'] * state['added_price'] + deal['trade_amount']
|
|||
|
|
) / total
|
|||
|
|
state['added_qty'] = total
|
|||
|
|
state['added_order_local_id'] = deal['order_local_id']
|
|||
|
|
state['added_created_at'] = f"{deal['trade_date']} {deal['trade_time']}".strip()
|
|||
|
|
else:
|
|||
|
|
# 卖出之后的买入属于新交易,不再与初始快照匹配。
|
|||
|
|
covered = True
|
|||
|
|
total = state['base_qty'] + state['added_qty']
|
|||
|
|
if qty > total:
|
|||
|
|
raise ValueError(f'Sell volume {qty} exceeds recorded holdings {total}')
|
|||
|
|
if qty < state['added_qty']:
|
|||
|
|
state['added_qty'] -= qty
|
|||
|
|
else:
|
|||
|
|
state['base_qty'] = total - qty
|
|||
|
|
state['added_qty'] = 0
|
|||
|
|
state['added_price'] = 0.0
|
|||
|
|
state['added_order_local_id'] = state['added_created_at'] = ''
|
|||
|
|
if state['base_qty'] + state['added_qty'] == 0:
|
|||
|
|
state = asdict(StateItem(stock_code=code))
|
|||
|
|
if state['base_qty'] + state['added_qty'] == 0:
|
|||
|
|
db.execute('DELETE FROM state WHERE stock_code = ?', (code,))
|
|||
|
|
else:
|
|||
|
|
# 重算数量和成本,保留调用方当前设置的 status 及已有记录主键。
|
|||
|
|
state['status'] = current['status'] if current else state['status']
|
|||
|
|
state.pop('id', None)
|
|||
|
|
columns = tuple(state)
|
|||
|
|
db.execute(
|
|||
|
|
f"INSERT INTO state ({', '.join(columns)}) "
|
|||
|
|
f"VALUES ({', '.join(':' + key for key in columns)}) "
|
|||
|
|
'ON CONFLICT(stock_code) DO UPDATE SET '
|
|||
|
|
+ ', '.join(f'{key} = excluded.{key}' for key in columns if key != 'stock_code'),
|
|||
|
|
state,
|
|||
|
|
)
|
|||
|
|
db.execute(
|
|||
|
|
'UPDATE deals SET is_arch = 1 WHERE stock_code = ? '
|
|||
|
|
'AND is_arch = 0 AND offset_flag IN (48, 49)', (code,)
|
|||
|
|
)
|
|||
|
|
except (ValueError, sqlite3.IntegrityError) as exc:
|
|||
|
|
db.execute('ROLLBACK TO archive_stock')
|
|||
|
|
errors[code] = str(exc)
|
|||
|
|
log.warning('[归档] %s 失败,保留未归档成交:%s', code, exc)
|
|||
|
|
finally:
|
|||
|
|
db.execute('RELEASE archive_stock')
|
|||
|
|
self.load()
|
|||
|
|
return errors
|
|||
|
|
|
|||
|
|
def sync_state(self, positions: list[PositionItem]) -> None:
|
|||
|
|
"""同步完整持仓:无状态则插入底仓,已有则保留,清仓则删除。
|
|||
|
|
|
|||
|
|
数量为零或未出现在完整持仓列表中的证券视为已清仓;空列表清空状态。
|
|||
|
|
"""
|
|||
|
|
# 传入完整账户持仓;同步时间作为新增底仓的创建时间。
|
|||
|
|
created_at = datetime.now().isoformat(timespec='seconds')
|
|||
|
|
holdings = {item.stock_code: item for item in positions if item.volume > 0}
|
|||
|
|
with closing(self._connect()) as db, db:
|
|||
|
|
db.execute('BEGIN')
|
|||
|
|
existing = {row['stock_code']: dict(row) for row in db.execute('SELECT * FROM state')}
|
|||
|
|
# 先保留基准,再删除清仓状态,卖出成交仍可据此归档。
|
|||
|
|
for code, row in existing.items():
|
|||
|
|
db.execute(
|
|||
|
|
'INSERT OR IGNORE INTO state_origin SELECT ?, ? WHERE NOT EXISTS '
|
|||
|
|
'(SELECT 1 FROM deals WHERE stock_code = ? AND is_arch = 1)',
|
|||
|
|
(code, json.dumps(row), code),
|
|||
|
|
)
|
|||
|
|
db.executemany(
|
|||
|
|
'DELETE FROM state WHERE stock_code = ?',
|
|||
|
|
[(code,) for code in existing if code not in holdings],
|
|||
|
|
)
|
|||
|
|
for code, item in holdings.items():
|
|||
|
|
if code in existing:
|
|||
|
|
continue
|
|||
|
|
if not math.isfinite(item.open_price):
|
|||
|
|
raise ValueError('Base price must be finite')
|
|||
|
|
# 只插入底仓字段,补仓字段使用数据库默认值。
|
|||
|
|
db.execute(
|
|||
|
|
'INSERT INTO state '
|
|||
|
|
'(stock_code, status, base_order_local_id, base_qty, base_price, base_created_at) '
|
|||
|
|
"VALUES (?, '', '', ?, ?, ?)",
|
|||
|
|
(code, item.volume, item.open_price, created_at),
|
|||
|
|
)
|
|||
|
|
self.load()
|