This commit is contained in:
2026-09-07 00:27:33 +08:00
parent fdcbdc7869
commit d37f9edefc
11 changed files with 839 additions and 384 deletions

View File

@@ -1,15 +1,14 @@
"""做 T 策略的底仓、待确认委托和实际成交记录。"""
"""做 T 策略的持仓状态和逐笔实际成交记录。"""
from __future__ import annotations
import json
import logging as log
import math
from dataclasses import asdict, dataclass, field
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from time import time
from sdk import DealItem, PositionItem
from libs.orderbook import OrderBook
READY, SOLD, DONE = "READY", "SOLD", "DONE"
@@ -21,180 +20,162 @@ class TStateItem:
base_cost: float = 0.0
trade_date: str = ""
phase: str = READY
sell_order_id: str = ""
sell_qty: int = 0
sell_price: float = 0.0
buy_order_id: str = ""
base_order_id: str = ""
buy_qty: int = 0
buy_cost: float = 0.0
@dataclass(slots=True)
class PendingOrder:
order_id: str
code: str
kind: str # base底仓sell做 T 卖出buy做 T 买回
qty: int
trade_date: str
submit_at: float = field(default_factory=time)
id: int = 0
base_order_id: str = ''
added_order_id: str = ''
added_num: int = 0
added_qty: int = 0
added_cost: float = 0.0
class TState:
"""交易逻辑串行更新JSON 保存底仓、待确认委托及成交历史。"""
"""Apply actual executions immediately, atomically with their position changes."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.items: dict[str, TStateItem] = {}
self.pending: dict[str, PendingOrder] = {}
self.records: list[dict] = []
self._store = OrderBook(path)
self.path = self._store.path
self._load()
@staticmethod
def _is_zt_deal(deal: DealItem) -> bool:
return (
deal.side == 'BUY' and deal.local_order_id.startswith(('zt-base-', 'zt-t-buy-'))
) or (
deal.side == 'SELL' and deal.local_order_id.startswith('zt-t-sell-')
)
@staticmethod
def _reset(item: TStateItem, date: str) -> bool:
if item.phase == DONE and item.trade_date != date:
item.phase, item.trade_date = READY, ''
item.sell_qty = item.buy_qty = 0
item.sell_price = item.buy_cost = 0.0
return True
return False
@classmethod
def for_strategy(
cls, data_dir: str | Path, strategy: str, account_id: str
) -> TState:
return cls(Path(data_dir) / f"{strategy}_{account_id}_state.json")
def busy(self, code: str) -> bool:
return any(order.code == code for order in self.pending.values())
def new_order(self, order: PendingOrder) -> None:
"""下单前落盘;请求超时不能当作失败删除,等待后续委托确认。"""
if self.busy(order.code):
raise ValueError(f"{order.code} 已有待确认委托")
self.pending[order.order_id] = order
try:
self.save()
except Exception:
del self.pending[order.order_id]
raise
def _apply_t_deal(cls, item: TStateItem, deal: dict) -> None:
"""实时入账与重启恢复共用同一套做 T 轮次计算。"""
cls._reset(item, deal['insert_date'])
qty, amount = deal['traded_volume'], deal['trade_amount']
if deal['side'] == 'SELL':
total = item.sell_qty + qty
item.sell_price = (item.sell_qty * item.sell_price + amount) / total
item.sell_qty = total
item.phase = SOLD
else:
total = item.buy_qty + qty
item.buy_cost = (item.buy_qty * item.buy_cost + amount) / total
item.buy_qty = total
item.phase = DONE if total >= item.sell_qty else SOLD
item.trade_date = deal['insert_date']
def reconcile(
self, positions: list[PositionItem], deals: list[DealItem], today: str
self, positions: list[PositionItem], deals: list[DealItem]
) -> None:
"""先按实际成交记账,再接管未知持仓;不覆盖已记录的底仓成本。"""
# 同一本地委托可能有多笔成交;按成交编号去重后合并数量和金额。
by_id: dict[str, dict[str, DealItem]] = {}
"""Deduplicate each fill; partial fills do not wait for order completion."""
today = datetime.now().date().isoformat()
seen = {row['sys_order_id'] for row in self.deals}
rows = []
for deal in deals:
if deal.local_order_id and deal.id:
by_id.setdefault(deal.local_order_id, {})[deal.id] = deal
for order_id, pending in list(self.pending.items()):
side = "SELL" if pending.kind == "sell" else "BUY"
rows = [
row
for row in by_id.get(order_id, {}).values()
if row.code == pending.code and row.side == side
]
if not rows:
log.warning("[ZT 状态] 成交暂未查到,保留待确认:%s", order_id)
if not self._is_zt_deal(deal) or deal.sys_order_id in seen:
continue
qty = sum(row.volume for row in rows)
# 成交未达到计划数量时继续等待,防止后续成交到达后重复记账。
if qty != pending.qty:
try:
row = self._store.deal_record(deal)
except ValueError:
continue
amounts = [
row.amount if row.amount > 0 else row.price * row.volume
for row in rows
if row.volume > 0
]
if any(not math.isfinite(amount) or amount <= 0 for amount in amounts):
continue
amount = sum(amounts)
cost = amount / qty if qty else 0.0
item = self.items.setdefault(pending.code, TStateItem(pending.code))
if pending.kind == "base":
item.base_order_id = order_id
item.base_qty, item.base_cost = qty, cost
elif pending.kind == "sell":
item.trade_date = today # 跨日成交也占用确认当天的一轮。
item.sell_order_id = order_id
item.sell_qty, item.sell_price = qty, cost
item.buy_qty, item.buy_cost = 0, 0.0
item.phase = SOLD if qty else READY
else:
total = item.buy_qty + qty
item.buy_cost = (
(item.buy_qty * item.buy_cost + amount) / total if total else 0.0
rows.append(row)
seen.add(deal.sys_order_id)
rows.sort(key=lambda r: (r['insert_date'], r['insert_time']))
modified = False
try:
# Snapshot includes these fills: subtract their net quantity before replay.
net = {}
for row in rows:
net[row['code']] = net.get(row['code'], 0) + (
row['traded_volume'] if row['side'] == 'BUY' else -row['traded_volume']
)
item.buy_qty = total
item.buy_order_id = order_id
item.phase = DONE if total >= item.sell_qty else SOLD
if item.phase == DONE:
item.trade_date = today
# 记录真实成交编号,重启后仍可核对本次状态变更的来源。
self.records.append(
{
**asdict(pending),
"confirmed_date": today,
"filled_qty": qty,
"filled_cost": cost,
"amount": amount,
"deal_ids": [row.id for row in rows],
}
)
del self.pending[order_id]
for position in positions:
code = position.stock_code
if code in self.items or position.volume <= 0:
continue
if not math.isfinite(position.open_price) or position.open_price <= 0:
continue
qty = max(0, position.volume - net.get(code, 0))
self.items[code] = TStateItem(code, qty, position.open_price if qty else 0.0)
modified = True
for position in positions:
code = position.stock_code
if position.volume <= 0 or self.busy(code):
continue
if (
code not in self.items
and math.isfinite(position.open_price)
and position.open_price > 0
):
self.items[code] = TStateItem(
code, position.volume, position.open_price
)
self.records.append(
{
"kind": "import",
"code": code,
"date": today,
"filled_qty": position.volume,
"filled_cost": position.open_price,
}
)
log.warning(
"[ZT 底仓] 首次接管 %s,使用当前均价,无法还原历史成本", code
)
# 全部卖出时快照可能已无该证券,按净卖出数量恢复待买回的底仓数量。
for code, delta in net.items():
if code not in self.items and delta < 0:
self.items[code] = TStateItem(code, -delta)
for item in self.items.values():
# 未买回的轮次跨日继续,不删除零持仓的做 T 债务。
if (
item.trade_date != today
and item.phase == DONE
and not self.busy(item.code)
):
item.phase, item.trade_date = READY, ""
item.sell_qty = item.buy_qty = 0
item.sell_price = item.buy_cost = 0.0
item.sell_order_id = item.buy_order_id = ""
self.save()
for row in rows:
item = self.items.setdefault(row['code'], TStateItem(row['code']))
self._reset(item, row['insert_date'])
qty, amount = row['traded_volume'], row['trade_amount']
if row['local_order_id'].startswith('zt-base-'):
total = item.base_qty + qty
item.base_cost = (item.base_qty * item.base_cost + amount) / total
item.base_qty = total
item.base_order_id = row['local_order_id']
else:
self._apply_t_deal(item, row)
self.deals.append(row)
modified = True
for item in self.items.values():
modified = self._reset(item, today) or modified
if modified:
self.save()
except Exception:
self._load()
raise
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
payload = {
"items": {key: asdict(item) for key, item in self.items.items()},
"pending": {key: asdict(item) for key, item in self.pending.items()},
"records": self.records,
}
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
encoding="utf-8",
)
temporary.replace(self.path)
try:
self._store.save(
{
code: {
'code': item.code,
'base_order_id': item.base_order_id,
'base_qty': item.base_qty,
'base_cost': item.base_cost,
'added_order_id': item.added_order_id,
'added_num': item.added_num,
'added_qty': item.added_qty,
'added_cost': item.added_cost,
'status': item.phase,
}
for code, item in self.items.items()
},
self.deals,
)
except Exception:
self._load()
raise
def _load(self) -> None:
if not self.path.is_file():
return
raw = json.loads(self.path.read_text(encoding="utf-8"))
self.items = {
code: TStateItem(**item) for code, item in raw["items"].items()
}
self.pending = {
key: PendingOrder(**item) for key, item in raw["pending"].items()
}
self.records = raw["records"]
self._store.load()
self.items = {}
for code, position in self._store.positions.items():
position = dict(position)
position['phase'] = position.pop('status')
self.items[code] = TStateItem(**position)
self.deals = [
{key: value for key, value in deal.items() if key != 'id'}
for deal in self._store.deals.values()
]
# 轮次明细不占用持仓表字段,从已保存的逐笔成交重建。
for deal in self.deals:
if not deal['local_order_id'].startswith('zt-base-') and deal['code'] in self.items:
self._apply_t_deal(self.items[deal['code']], deal)
for code, item in self.items.items():
if self._store.positions[code]['status'] == READY:
item.phase, item.trade_date = READY, ''
item.sell_qty = item.buy_qty = 0
item.sell_price = item.buy_cost = 0.0