This commit is contained in:
2026-09-13 13:16:41 +08:00
parent 554dd0f4cb
commit 67b46ca049
2 changed files with 136 additions and 205 deletions

View File

@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS deals (
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)),
offset_flag INTEGER NOT NULL CHECK (offset_flag IN (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),
@@ -58,6 +58,14 @@ 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);
"""
DELAS_INSERT_SQL = """
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, is_arch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
"""
@dataclass(slots=True)
class StateItem:
"""策略状态字段;同步账户底仓时无法获知的委托编号留空。"""
@@ -83,6 +91,7 @@ _UPSERT_STATE = (
)
class State:
"""单写入者使用的 SQLite 存储;公开缓存仅在事务成功后替换。
@@ -144,19 +153,26 @@ class State:
deals = self._read_deals(db)
self.deals, self.deals_sys_ids = deals, set(deals)
@staticmethod
def _insert_deals(db: sqlite3.Connection, deals: list[DealItem],
existing_ids: set[str] | None = None) -> None:
"""共享事务内保存成交;空本地编号使用明确的待核对标记。"""
existing = existing_ids if existing_ids is not None else {
row[0] for row in db.execute('SELECT order_sys_id FROM deals')}
def sync_deals(self, deals: list[DealItem]) -> None:
"""按 order_sys_id 只追加成交时间超过 30 秒的新记录,保留全部历史记录。"""
now = datetime.now()
new_deals: dict[str, DealItem] = {}
for deal in deals:
if deal.order_sys_id not in existing:
if deal.order_sys_id in self.deals_sys_ids:
continue
date = (deal.trade_date or now.date().isoformat()).replace('-', '')
time = deal.trade_time.replace(':', '')
traded_at = datetime.strptime(f'{date} {time}', '%Y%m%d %H%M%S')
if (now - traded_at).total_seconds() <= 30:
continue
new_deals.setdefault(deal.order_sys_id, deal)
if not new_deals:
return
today = datetime.now().date().isoformat()
# 插入记录。
with closing(self._connect()) as db, db:
db.execute('BEGIN')
today = now.date().isoformat()
values = []
for deal in new_deals.values():
amount = deal.trade_amount if deal.trade_amount > 0 else deal.price * deal.volume
@@ -167,30 +183,14 @@ class State:
date = f'{date[:4]}-{date[4:6]}-{date[6:]}'
values.append((
deal.stock_code, deal.order_sys_id,
deal.get_local_order_id.strip() or f'{UNATTRIBUTED_PREFIX}{deal.order_sys_id}',
deal.get_local_order_id.strip(),
deal.ref, deal.order_ref, deal.direction, deal.offset_flag,
deal.price, deal.volume, amount, date, deal.trade_time,
deal.remark, deal.close_profit,
))
# 唯一键负责最终去重,避免旧缓存造成重复插入失败。
db.executemany("""
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, is_arch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(order_sys_id) DO NOTHING
""", values)
def sync_deals(self, deals: list[DealItem]) -> None:
"""按成交编号只追加;同批保留首笔,任一无效成交回滚整批。"""
if all(deal.order_sys_id in self.deals_sys_ids for deal in deals):
return
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
self._insert_deals(db, deals)
cached_deals = self._read_deals(db)
self.deals, self.deals_sys_ids = cached_deals, set(cached_deals)
db.executemany(DELAS_INSERT_SQL, values)
cache_deals = self._read_deals(db)
self.deals, self.deals_sys_ids = cache_deals, set(cache_deals)
def sync_state(self, positions: list[PositionItem]) -> None:
"""同步完整持仓:新增底仓、保留已有状态、删除已清仓证券。
@@ -221,172 +221,83 @@ class State:
state = self._read_state(db)
self.state = state
@staticmethod
def _archive_stock(db: sqlite3.Connection, code: str, snapshot_dedup: bool = True) -> None:
"""在调用方的保存点内完成一只证券的记账及成交标记。"""
current = db.execute('SELECT * FROM state WHERE stock_code = ?', (code,)).fetchone()
state = dict(current) if current else asdict(StateItem(stock_code=code))
pending = db.execute("""
SELECT * FROM deals WHERE stock_code = ? AND is_arch = 0
ORDER BY trade_date, REPLACE(trade_time, ':', ''), id
""", (code,)).fetchall()
if any(deal['order_local_id'].startswith(UNATTRIBUTED_PREFIX) for deal in pending):
raise ValueError('Unattributed trade requires reconciliation')
total_qty = state['base_qty'] + state['added_qty']
if (
snapshot_dedup and total_qty > 0
and all(deal['offset_flag'] == FLAG_BUY for deal in pending)
and sum(deal['volume'] for deal in pending) == total_qty
):
# 按快照去重规则,仅标记这批成交,原仓位数量和成本均保留。
# 卖出和买卖混合批次不能用此规则,否则会漏掉真实减仓。
db.executemany(
'UPDATE deals SET is_arch = 1 WHERE id = ? AND is_arch = 0',
[(deal['id'],) for deal in pending],
)
def merge_deals(self) -> dict[str, dict]:
"""按本地委托编号汇总未归档成交,返回新字典,不修改原始记录。
数量、金额和平仓盈亏累加,价格为总金额除以总数量;
其余字段(包括成交编号和时间)保留同组首笔记录的值。
"""
merged: dict[str, dict] = {}
for deal in self.deals.values():
if deal['is_arch'] != 0:
continue
order_local_id = deal['order_local_id']
if order_local_id not in merged:
merged[order_local_id] = deal.copy()
else:
item = merged[order_local_id]
item['stock_code']=deal['stock_code']
item['volume'] += deal['volume']
item['trade_amount'] += deal['trade_amount']
item['close_profit'] += deal['close_profit']
for item in merged.values():
item['price'] = item['trade_amount'] / item['volume']
return merged
def archiving(self) -> None:
"""先合并缓存中的未归档成交再计算;单证券失败回滚并保留重试。"""
merged = self.merge_deals()
if not merged:
return
for deal in pending:
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
for order_local_id, deal in merged.items():
db.execute('SAVEPOINT archive_stock')
code = deal['stock_code']
try:
result = db.execute('SELECT * FROM state WHERE stock_code = ?', (code,)).fetchone()
state = dict(result) if result else asdict(StateItem(stock_code=code))
if deal['offset_flag'] == FLAG_BUY:
bucket = 'base' if deal['order_local_id'].startswith('zt-base-') else 'added'
total = state[f'{bucket}_qty'] + deal['volume']
state[f'{bucket}_price'] = (
state[f'{bucket}_qty'] * state[f'{bucket}_price'] + deal['trade_amount']
) / total
state[f'{bucket}_qty'] = total
state[f'{bucket}_price'] = deal['price']
state[f'{bucket}_qty'] = deal['volume']
state[f'{bucket}_order_local_id'] = deal['order_local_id']
state[f'{bucket}_created_at'] = f"{deal['trade_date']} {deal['trade_time']}".strip()
db.execute(_UPSERT_STATE, state)
elif deal['offset_flag'] == FLAG_SELL:
newState = StateItem(stock_code=code)
qty = deal['volume']
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
elif qty == total:
# 清仓底仓与补仓
newState.status='CLEAR'
elif qty == state['added_qty']:
# 清仓补仓
newState.base_qty = state['base_qty']
newState.base_price = state['base_price']
newState.base_order_local_id = state['base_order_local_id']
newState.base_created_at = state['base_created_at']
elif qty == state['base_qty']:
# 清仓底仓
newState.status='CLEAR'
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'] == 0:
state.update(asdict(StateItem(stock_code=code)))
else:
raise ValueError(f"Unsupported trade flag: {deal['offset_flag']}")
if state['base_qty'] + state['added_qty'] == 0:
raise ValueError(f'Sell volume {qty} holdings {total}')
if newState.status == 'CLEAR':
db.execute('DELETE FROM state WHERE stock_code = ?', (code,))
else:
# 保留原记录主键及策略状态,包括同批清仓后重新建仓。
if current:
state['status'] = current['status']
db.execute(_UPSERT_STATE, state)
db.execute(
'UPDATE deals SET is_arch = 1 WHERE stock_code = ? '
'AND is_arch = 0', (code,),
)
def _archive_pending(self, db: sqlite3.Connection, snapshot_dedup: bool = True,
blocked_codes: set[str] | None = None) -> None:
"""保留现有逐证券保存点ZT 增量路径禁用数量相等推断。"""
codes = db.execute(
'SELECT DISTINCT stock_code FROM deals WHERE is_arch = 0'
).fetchall()
for row in codes:
code = row['stock_code']
if blocked_codes and code in blocked_codes:
continue
db.execute('SAVEPOINT archive_stock')
try:
self._archive_stock(db, code, snapshot_dedup)
db.execute(_UPSERT_STATE, asdict(newState))
db.execute('UPDATE deals SET is_arch = 1 WHERE order_local_id = ? ''AND is_arch = 0', (order_local_id,))
except (ValueError, sqlite3.IntegrityError) as exc:
db.execute('ROLLBACK TO archive_stock')
log.warning('[归档] %s 失败,保留未归档成交:%s', code, exc)
finally:
db.execute('RELEASE archive_stock')
def archiving(self) -> None:
"""归档未处理成交;单证券失败回滚并保留重试,不影响其他证券。"""
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
self._archive_pending(db)
state = self._read_state(db)
deals = self._read_deals(db)
self.state, self.deals, self.deals_sys_ids = state, deals, set(deals)
def sync_account(self, positions: list[PositionItem], deals: list[DealItem], *, initialize: bool = False) -> None:
"""ZT 原子同步。初始化使用已核对快照;恢复只应用增量、不覆盖库存。
空库存且无成交历史时允许初始化。快照缺失或
未归档成交只隔离对应证券,后续成交到齐并核对一致后自动恢复。
"""
holdings = {p.stock_code: p for p in positions if p.volume > 0}
with closing(self._connect()) as db, db:
db.execute('BEGIN IMMEDIATE')
db.execute('''CREATE TABLE IF NOT EXISTS zt_rejected_deals (
stock_code TEXT NOT NULL, order_sys_id TEXT NOT NULL,
payload TEXT NOT NULL, error TEXT NOT NULL,
PRIMARY KEY (stock_code, order_sys_id)
)''')
if initialize:
if (db.execute('SELECT 1 FROM state LIMIT 1').fetchone()
or db.execute('SELECT 1 FROM deals LIMIT 1').fetchone()):
raise ValueError('ZT state already initialized; cannot overwrite existing data')
created_at = datetime.now().isoformat(timespec='seconds')
for code, position in holdings.items():
if not code or not math.isfinite(position.open_price) or position.open_price <= 0:
raise ValueError('Initial position code and cost must be valid')
db.execute(_UPSERT_STATE, asdict(StateItem(
stock_code=code, base_qty=position.volume,
base_price=position.open_price, base_created_at=created_at,
)))
self._insert_deals(db, deals)
# 这些成交已包含在已核对的基准中,不再增减快照库存。
db.execute('UPDATE deals SET is_arch = 1')
else:
self._insert_account_deals(db, deals)
rejected = {row[0] for row in db.execute('SELECT stock_code FROM zt_rejected_deals')}
self._archive_pending(db, snapshot_dedup=False, blocked_codes=rejected)
state = self._read_state(db)
cached_deals = self._read_deals(db)
blocked = {d['stock_code'] for d in cached_deals.values() if d['is_arch'] != 1}
blocked.update(row[0] for row in db.execute('SELECT stock_code FROM zt_rejected_deals'))
for code in set(state) | set(holdings):
row = state.get(code, {})
recorded = row.get('base_qty', 0) + row.get('added_qty', 0)
actual = holdings[code].volume if code in holdings else 0
if recorded != actual:
blocked.add(code)
self.state, self.deals, self.deals_sys_ids = state, cached_deals, set(cached_deals)
self.blocked_codes = blocked
if blocked:
log.warning('[ZT 同步] 以下证券状态待核对,暂停交易:%s', ', '.join(sorted(blocked)))
def _insert_account_deals(self, db: sqlite3.Connection, deals: list[DealItem]) -> None:
"""仅 ZT 增量路径隔离坏成交;保留原始字段,缺席回报不会解除隔离。"""
existing = {row[0] for row in db.execute('SELECT order_sys_id FROM deals')}
for deal in deals:
if not deal.stock_code:
raise ValueError('Trade without stock code cannot be isolated')
db.execute('SAVEPOINT insert_zt_deal')
try:
if not deal.stock_code or not deal.order_sys_id:
raise ValueError('Trade code and order ID are required')
if (deal.offset_flag not in (FLAG_BUY, FLAG_SELL)
or not math.isfinite(deal.volume) or deal.volume <= 0
or int(deal.volume) != deal.volume
or not math.isfinite(deal.price) or deal.price < 0
or not math.isfinite(deal.trade_amount)
or not math.isfinite(deal.close_profit)):
raise ValueError('Invalid trade direction, volume or amount')
self._insert_deals(db, [deal], existing)
except (ValueError, TypeError, OverflowError, sqlite3.IntegrityError) as exc:
db.execute('ROLLBACK TO insert_zt_deal')
db.execute('''INSERT OR REPLACE INTO zt_rejected_deals
(stock_code, order_sys_id, payload, error) VALUES (?, ?, ?, ?)''',
(deal.stock_code, deal.order_sys_id, json.dumps(asdict(deal), ensure_ascii=False), str(exc)))
log.warning('[ZT 成交] 隔离证券=%s,委托=%s,原因=%s',
deal.stock_code, deal.order_sys_id, exc)
else:
existing.add(deal.order_sys_id)
db.execute('DELETE FROM zt_rejected_deals WHERE stock_code = ? AND order_sys_id = ?',
(deal.stock_code, deal.order_sys_id))
finally:
db.execute('RELEASE insert_zt_deal')

View File

@@ -15,7 +15,7 @@ from libs.runtime import Runtime
from libs.signal import SignalItem, init_signals
from libs.state import State
from libs.watch import DipWatch
from sdk import Client
from sdk import Client, DealItem, PositionItem
from .open import open_signal
from .positions import manage_positions
from libs.snapshot import cache_portfolio
@@ -55,7 +55,7 @@ def StartZT() -> None:
log.info('[启动] ZT策略已启动账户=%s,信号=%d,持仓=%d',
config.account_config.account_id, len(signals), len(positions))
cache_portfolio(config.account_config.account_id, assets, positions, deals)
state.sync_account(positions, deals, initialize=initialize)
_sync_state(state, positions, deals, initialize=initialize)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(client, portfolio.orders, cancel_prefix='zt-')
Overview(assets, positions, config.account_config)
@@ -94,6 +94,26 @@ def StartZT() -> None:
client.close()
def _sync_state(state: State, positions: list[PositionItem], deals: list[DealItem],
*, initialize: bool = False) -> None:
"""使用 State 的独立接口同步,交易前核对归档结果与账户持仓。"""
if initialize:
state.sync_state(positions)
state.sync_deals(deals)
state.archiving()
holdings = {p.stock_code: p.volume for p in positions if p.volume > 0}
blocked = {d.stock_code for d in deals if d.order_sys_id not in state.deals_sys_ids}
blocked.update(d['stock_code'] for d in state.deals.values() if d['is_arch'] != 1)
for code in state.state.keys() | holdings.keys():
row = state.get_by_code(code)
if row.get('base_qty', 0) + row.get('added_qty', 0) != holdings.get(code, 0):
blocked.add(code)
state.blocked_codes = blocked
if blocked:
log.warning('[ZT 同步] 状态待核对,暂停交易:%s', ', '.join(sorted(blocked)))
def RunOnce(run: Runtime, state: State, signals: list[SignalItem]) -> None:
now = datetime.now()
if not trading_time(now):
@@ -114,7 +134,7 @@ def RunOnce(run: Runtime, state: State, signals: list[SignalItem]) -> None:
position_codes = list(portfolio.positions)
cache_portfolio(run.account_cfg.account_id, assets, positions, deals)
state.sync_account(positions, deals)
_sync_state(state, positions, deals)
run.profit_tracker.sync_positions(positions, state)
run.orders.refresh(run.client, portfolio.orders, cancel_prefix='zt-')
except Exception: