fix bug
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user