Files
big-qmt/py-client/strategy/trend/state.py

208 lines
8.5 KiB
Python
Raw Normal View History

2026-09-05 13:53:26 +08:00
"""简单成交账本订单明确结束后一次记账JSON 原子保存。"""
2026-08-28 18:52:27 +08:00
from __future__ import annotations
import json
2026-09-01 14:28:49 +08:00
import logging as log
2026-09-05 13:53:26 +08:00
import math
import shutil
import time
from dataclasses import asdict, dataclass, field, replace
2026-08-28 18:52:27 +08:00
from pathlib import Path
2026-09-05 13:53:26 +08:00
from threading import RLock
2026-08-28 18:52:27 +08:00
from typing import Iterable
2026-08-30 00:34:27 +08:00
from sdk import OrderItem, PositionItem
2026-08-28 18:52:27 +08:00
2026-09-05 13:53:26 +08:00
TERMINAL_STATUSES = {"53", "54", "56", "57"}
2026-08-28 18:52:27 +08:00
@dataclass(slots=True)
class StateItem:
code: str
base_order_id: str = ""
2026-09-05 13:53:26 +08:00
base_pre_qty: int =0
2026-08-28 18:52:27 +08:00
base_qty: int = 0
base_cost: float = 0.0
added_order_id: str = ""
2026-09-05 13:53:26 +08:00
added_pre_qty: int =0
2026-08-28 18:52:27 +08:00
added_num: int = 0
added_qty: int = 0
2026-09-05 13:53:26 +08:00
added_amount: float = 0.0
2026-08-28 18:52:27 +08:00
2026-09-05 13:53:26 +08:00
@property
def added_cost(self) -> float:
return self.added_amount / self.added_qty if self.added_qty else 0.0
2026-08-28 18:52:27 +08:00
2026-09-05 13:53:26 +08:00
@dataclass(slots=True)
class PendingOrder:
order_id: str
kind: str
expected_qty: int
submitted_at: float = field(default_factory=time.time)
2026-08-28 18:52:27 +08:00
2026-09-05 13:53:26 +08:00
class State:
2026-08-28 18:52:27 +08:00
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
2026-09-05 13:53:26 +08:00
self.lock = RLock()
self.items: dict[str, StateItem] = {}
self.pending: dict[str, PendingOrder] = {} # 每只证券最多一个待确认买单
self._dirty = False
self._next_warning = 0.0
self._load()
2026-08-28 18:52:27 +08:00
@classmethod
2026-09-05 13:53:26 +08:00
def for_strategy(cls, data_dir: str | Path, strategy: str, account_id: str) -> State:
return cls(Path(data_dir) / f"{strategy}_{account_id}_state.json")
2026-08-28 18:52:27 +08:00
@property
def codes(self) -> list[str]:
with self.lock:
return list(self.items)
def get(self, code: str) -> StateItem:
with self.lock:
2026-09-05 13:53:26 +08:00
return replace(self.items[code])
2026-08-28 18:52:27 +08:00
2026-09-05 13:53:26 +08:00
def busy(self, code: str) -> bool:
2026-08-28 18:52:27 +08:00
with self.lock:
2026-09-05 13:53:26 +08:00
return code in self.pending
2026-08-28 18:52:27 +08:00
2026-09-05 13:53:26 +08:00
def begin(self, order_id: str, code: str, kind: str, expected_qty: int) -> bool:
if kind not in {"base", "add"} or type(expected_qty) is not int or expected_qty <= 0:
raise ValueError("订单类型或预开仓数量无效")
2026-08-30 00:34:27 +08:00
with self.lock:
2026-09-05 13:53:26 +08:00
if code in self.pending or (kind == "base" and code in self.items):
return False
if kind == "add" and code not in self.items:
raise ValueError("缺少底仓记录")
dirty = self._dirty
self.pending[code] = PendingOrder(order_id, kind, expected_qty)
self._dirty = True
try:
self.save() # 必须成功落盘后才允许发送请求
except Exception:
self.pending.pop(code)
self._dirty = dirty
raise
return True
def reject(self, order_id: str) -> None:
"""仅用于已明确未受理的请求。"""
with self.lock:
for code, pending in self.pending.items():
if pending.order_id == order_id:
del self.pending[code]
self._dirty = True
self.save()
return
def reconcile(self, positions: Iterable[PositionItem], orders: list[OrderItem]) -> None:
by_id: dict[str, dict[str, OrderItem]] = {}
2026-08-30 15:29:21 +08:00
for order in orders:
2026-09-05 13:53:26 +08:00
if order.local_order_id and order.id:
by_id.setdefault(order.local_order_id, {})[order.id] = order
with self.lock:
for position in positions:
code = position.stock_code
if (code and code not in self.items and code not in self.pending
and position.volume > 0 and math.isfinite(position.open_price)
and position.open_price > 0):
self.items[code] = StateItem(code, position.volume, position.open_price)
self._dirty = True
now = time.time()
for code, pending in list(self.pending.items()):
rows = [o for o in by_id.get(pending.order_id, {}).values()
if o.code == code and o.side == "BUY"]
filled = _finished_fill(rows, pending.expected_qty)
if filled is None:
if now >= self._next_warning and now - pending.submitted_at >= 180:
log.warning("[状态] 订单待核查,代码=%s,订单=%s,计划=%d;保留防重",
code, pending.order_id, pending.expected_qty)
2026-08-30 15:29:21 +08:00
continue
2026-09-05 13:53:26 +08:00
qty, amount = filled
if qty:
item = self.items.setdefault(code, StateItem(code))
if pending.kind == "base":
item.base_qty, item.base_cost = qty, amount / qty
else:
item.added_num += 1
2026-09-05 13:53:26 +08:00
item.added_qty += qty
item.added_amount += amount
del self.pending[code]
self._dirty = True
log.info("[状态] 对账结束,代码=%s,订单=%s,计划=%d,成交=%d",
code, pending.order_id, pending.expected_qty, qty)
if now >= self._next_warning:
self._next_warning = now + 180
self.save() # 成交记账与移除 pending 在同一文件中原子提交
2026-08-28 22:46:04 +08:00
2026-08-28 18:52:27 +08:00
def save(self) -> None:
with self.lock:
2026-09-05 13:53:26 +08:00
if not self._dirty:
return
2026-08-28 18:52:27 +08:00
self.path.parent.mkdir(parents=True, exist_ok=True)
2026-09-05 13:53:26 +08:00
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
payload = dict(version=3, items={k: asdict(v) for k, v in self.items.items()},
pending={k: asdict(v) for k, v in self.pending.items()})
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
temporary.replace(self.path)
self._dirty = False
def _load(self) -> None:
2026-08-28 18:52:27 +08:00
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
2026-09-05 13:53:26 +08:00
return
if raw.get("version") not in {2, 3}:
raise ValueError("仅支持 v2/v3 状态文件;旧格式请先核实转换,原文件未修改")
self.items = {k: StateItem(**v) for k, v in raw["items"].items()}
if raw["version"] == 3:
self.pending = {k: PendingOrder(**v) for k, v in raw["pending"].items()}
return
# v2 的未决订单可能已经增量记账,先撤回这部分,结束时再完整记一次。
for old in raw["pending"].values():
code = old["code"]
if code in self.pending:
raise ValueError(f"{code} 存在多个旧未决订单,请核查;原文件未修改")
item = self.items[code]
qty, amount = old.get("applied_qty", 0), old.get("applied_amount", 0.0)
if old["kind"] == "add":
item.added_qty -= qty
item.added_amount -= amount
item.added_num -= int(old.get("counted", False))
else:
base_amount = item.base_qty * item.base_cost - amount
item.base_qty -= qty
item.base_cost = base_amount / item.base_qty if item.base_qty else 0.0
if not item.base_qty and not item.added_qty:
del self.items[code]
self.pending[code] = PendingOrder(old["order_id"], old["kind"], old["expected_qty"],
old.get("submitted_at", time.time()))
backup = self.path.with_suffix(self.path.suffix + ".v2.bak")
if not backup.exists():
shutil.copy2(self.path, backup)
self._dirty = True
self.save()
2026-08-28 18:52:27 +08:00
2026-09-05 13:53:26 +08:00
def _finished_fill(orders: list[OrderItem], expected_qty: int) -> tuple[int, float] | None:
"""仅完整终态快照可记账;缺项、未成交完或金额未知均继续等待。"""
if not orders or expected_qty <= 0 or sum(o.volume for o in orders) != expected_qty:
return None
qty, amount = 0, 0.0
for order in orders:
if (order.status not in TERMINAL_STATUSES or not 0 <= order.traded_volume <= order.volume
or (order.status == "56" and order.traded_volume != order.volume)):
return None
if order.traded_volume:
value = order.trade_amount if order.trade_amount > 0 else order.trade_price * order.traded_volume
if not math.isfinite(value) or value <= 0:
return None
qty += order.traded_volume
amount += value
return qty, amount