This commit is contained in:
2026-09-06 11:53:28 +08:00
parent ac9e9193ad
commit bcc6f02398
7 changed files with 612 additions and 218 deletions

View File

@@ -1,20 +1,18 @@
"""做 T 策略的底仓与日内轮次状态"""
"""做 T 策略的底仓、待确认委托和实际成交记录"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
import logging as log
import math
from dataclasses import asdict, dataclass, field
from pathlib import Path
from threading import Lock
from typing import Iterable
from time import time
from sdk import OrderItem, PositionItem
READY = "READY"
SELLING = "SELLING"
SOLD = "SOLD"
BUYING = "BUYING"
DONE = "DONE"
READY, SOLD, DONE = "READY", "SOLD", "DONE"
TERMINAL_STATUSES = {"53", "54", "56", "57"}
@dataclass(slots=True)
@@ -28,76 +26,139 @@ class TStateItem:
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)
class TState:
"""持久化 dcm 底仓和每只证券每日一次的做 T 进度"""
"""交易逻辑串行更新JSON 保存底仓、待确认委托及成交历史"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.lock = Lock()
self.items = self._load()
self.items: dict[str, TStateItem] = {}
self.pending: dict[str, PendingOrder] = {}
self.records: list[dict] = []
self._load()
@classmethod
def for_strategy(cls, data_dir: str | Path, strategy: str, account_id: str) -> "TState":
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 get(self, code: str) -> TStateItem:
with self.lock:
return self.items[code]
def busy(self, code: str) -> bool:
return any(order.code == code for order in self.pending.values())
def set(self, item: TStateItem) -> None:
with self.lock:
self.items[item.code] = item
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 reconcile(self, positions: Iterable[PositionItem], orders: list[OrderItem], today: str) -> None:
position_list = [item for item in positions if item.stock_code and item.volume > 0]
position_codes = {item.stock_code for item in position_list}
by_local_id: dict[str, list[OrderItem]] = {}
def reconcile(self, positions: list[PositionItem], orders: list[OrderItem], today: str) -> None:
"""先按实际成交记账,再接管未知持仓;不覆盖已记录的底仓成本。"""
# 同一本地委托可能拆单;按券商订单号去重,数量齐全且全部结束才记账。
by_id: dict[str, dict[str, OrderItem]] = {}
for order in orders:
if order.local_order_id:
by_local_id.setdefault(order.local_order_id, []).append(order)
for position in position_list:
if position.stock_code not in self.items and position.open_price > 0:
self.set(TStateItem(position.stock_code, position.volume, position.open_price))
for code in list(self.items):
item = self.get(code)
if code not in position_codes:
with self.lock:
self.items.pop(code, None)
by_id.setdefault(order.local_order_id, {})[order.id] = order
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)
continue
if item.trade_date and item.trade_date != today and item.phase in {DONE, READY}:
item.trade_date, item.phase = "", READY
if sum(row.volume for row in rows) != pending.qty:
continue
if any(row.status not in TERMINAL_STATUSES for row in rows):
continue
if any(row.status == "56" and row.traded_volume != row.volume for row in rows):
continue
qty = sum(row.traded_volume for row in rows)
amounts = [row.trade_amount if row.trade_amount > 0 else row.trade_price * row.traded_volume
for row in rows if row.traded_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
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, "statuses": [row.status for row in rows]})
del self.pending[order_id]
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 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 = ""
item.sell_qty = 0
item.sell_price = 0.0
if item.phase == SELLING and _completed(by_local_id.get(item.sell_order_id)):
item.phase = SOLD
elif item.phase == BUYING and _completed(by_local_id.get(item.buy_order_id)):
item.phase = DONE
self.set(item)
self.save()
def save(self) -> None:
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(json.dumps({key: asdict(value) for key, value in self.items.items()}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
temporary.replace(self.path)
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)
def _load(self) -> dict[str, TStateItem]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
return {}
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"[ZT 状态] 读取失败: {exc}") from exc
if not isinstance(raw, dict):
raise ValueError("[ZT 状态] 根节点必须是对象")
return {code: TStateItem(**value) for code, value in raw.items()}
def _completed(orders: list[OrderItem] | None) -> bool:
return bool(orders) and all(order.status == "56" for order in orders)
def _load(self) -> None:
if not self.path.is_file():
return
raw = json.loads(self.path.read_text(encoding="utf-8"))
# 兼容原 ZT 文件,保留原底仓成本;没有额外版本字段。
self.items = {code: TStateItem(**item) for code, item in raw.get("items", raw).items()}
self.pending = {key: PendingOrder(**item) for key, item in raw.get("pending", {}).items()}
self.records = raw.get("records", [])
if "items" not in raw:
# 旧记录只有提交行情价,不把它伪装成真实成交历史。
self.records.append({"kind": "legacy_import", "items": raw})
for item in self.items.values():
if item.phase in {"SELLING", "BUYING", SOLD}:
raise ValueError(f"[ZT 状态] {item.code} 旧做 T 轮次未结束,需先核对成交再迁移")