Files
big-qmt/py-client/strategy/zt/rounds.py
2026-09-15 20:02:05 +08:00

380 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ZT 做 T 轮次状态:一只股票同时最多一轮,允许跨日持有。
正T``LONG_T``与反T``SHORT_T``)共用同一组字段,区别只是两条腿的方向:
正Tentry=BUY exit=SELL 低吸 → 高抛
反Tentry=SELL exit=BUY 高抛 → 低吸
轮次只记录"我打算做什么、做到哪一步",不重算持仓数量:持仓数量永远以
券商 ``positions`` 为准。因此这里没有数量等式,也就没有"数量对不上就冻结"
这条路径;部分成交、分批成交、部分可卖都由 ``entry_filled_qty`` /
``exit_filled_qty`` 自然表达。
成交累计是幂等的:只统计 ``seen_deal_ids`` 里没有的成交编号。QMT 只返回
当日成交,跨日轮次必须靠这份记录才能记住之前已成交多少,所以它必须落盘。
基准只来自本策略自己的建仓成交(``base_source=opened``):程序不接管账户里
已有的持仓,别人的持仓不进轮次、也不参与做 T。
"""
import json
import os
from dataclasses import asdict, dataclass, field, fields
from datetime import date, datetime
from pathlib import Path
from libs.order import BUSY_STATUSES
PHASE_IDLE = "IDLE" # 无活动轮次
PHASE_OPENING = "OPENING" # 开仓腿已提交,等待成交或终态
PHASE_OPEN = "OPEN" # 开仓腿已定局且有余量,等待平仓条件
PHASE_CLOSING = "CLOSING" # 平仓腿已提交
PHASE_CLOSED = "CLOSED" # 本轮结束normal / aborted / expired
KIND_LONG_T = "LONG_T" # 正T先买后卖
KIND_SHORT_T = "SHORT_T" # 反T先卖后买
KIND_BASE = "BASE" # 建底仓:只有买入腿,成交均价即基准成本
ACTIVE_PHASES = (PHASE_OPENING, PHASE_OPEN, PHASE_CLOSING)
_ENTRY_SIDE = {KIND_LONG_T: "BUY", KIND_SHORT_T: "SELL", KIND_BASE: "BUY"}
_EXIT_SIDE = {KIND_LONG_T: "SELL", KIND_SHORT_T: "BUY", KIND_BASE: ""}
OUTCOME_NORMAL = "normal"
OUTCOME_ABORTED = "aborted"
OUTCOME_EXPIRED = "expired"
OUTCOME_BASE = "base"
BASE_SOURCE_OPENED = "opened" # 本策略建仓,成本取实际成交均价
class RoundStoreError(ValueError):
"""轮次状态文件无法解析;调用方据此从券商持仓重建。"""
@dataclass(slots=True)
class Round:
"""单只证券的做 T 轮次记录。"""
code: str = ""
kind: str = ""
phase: str = PHASE_IDLE
open_date: str = "" # 开仓腿提交日;非空且等于今天即视为已用掉当日轮次
close_date: str = ""
outcome: str = ""
# 建仓基准:用户指定用建仓价,不随做 T 买卖摊薄。
base_qty: int = 0
base_cost: float = 0.0
base_date: str = ""
base_source: str = "" # opened / adopted
# 两条腿对称记录便于正T/反T 共用同一套推进逻辑。
entry_order_id: str = ""
entry_plan_qty: int = 0
entry_filled_qty: int = 0
entry_amount: float = 0.0
exit_order_id: str = ""
exit_plan_qty: int = 0
exit_filled_qty: int = 0
exit_amount: float = 0.0
# 已计入的成交编号,保证跨轮重复同步不会重复累加。
seen_deal_ids: list[str] = field(default_factory=list)
# 本股最后一次有腿成交的日期;当天已有成交就不再开新轮。
last_trade_date: str = ""
updated_at: str = ""
note: str = ""
@property
def entry_side(self) -> str:
return _ENTRY_SIDE.get(self.kind, "")
@property
def exit_side(self) -> str:
return _EXIT_SIDE.get(self.kind, "")
@property
def residual_qty(self) -> int:
"""尚未平掉的轮次敞口正T 为待卖反T 为待买回。"""
return self.entry_filled_qty - self.exit_filled_qty
@property
def entry_avg_price(self) -> float:
return self.entry_amount / self.entry_filled_qty if self.entry_filled_qty else 0.0
@property
def exit_avg_price(self) -> float:
return self.exit_amount / self.exit_filled_qty if self.exit_filled_qty else 0.0
@property
def realized_amount(self) -> float:
"""已平部分的价差收益(不含费用),仅用于日志与审计。"""
qty = min(self.entry_filled_qty, self.exit_filled_qty)
if qty <= 0 or self.entry_avg_price <= 0 or self.exit_avg_price <= 0:
return 0.0
if self.kind == KIND_LONG_T:
return (self.exit_avg_price - self.entry_avg_price) * qty
if self.kind == KIND_SHORT_T:
return (self.entry_avg_price - self.exit_avg_price) * qty
return 0.0
@property
def is_active(self) -> bool:
return self.phase in ACTIVE_PHASES
def can_open(self, today: str) -> bool:
"""当日是否还能开新轮。
三个条件缺一不可:没有未平轮次、今天没开过、今天没有腿成交。
最后一条保证"一只股票每天只做一轮"是真正的往返上限:跨日未平的
轮次今天平掉之后,今天也不再开新轮,避免同一天里平旧仓又开新仓。
"""
return (not self.is_active
and self.open_date != today
and self.last_trade_date != today)
_ROUND_FIELDS = {item.name for item in fields(Round)}
def entry_side(kind: str) -> str:
"""该轮次方向的开仓腿买卖方向。"""
return _ENTRY_SIDE.get(kind, "")
def exit_side(kind: str) -> str:
"""该轮次方向的平仓腿买卖方向;建底仓没有平仓腿。"""
return _EXIT_SIDE.get(kind, "")
def in_flight_order_ids(orders: list, *, busy_statuses: set[str] | None = None) -> set[str]:
"""仍可能继续成交的本地订单号集合。
已完成56、已撤54、部撤53、废单57都不在集合内
因此它们一出现就代表对应腿已经定局。
"""
statuses = BUSY_STATUSES if busy_statuses is None else busy_statuses
return {
order.local_order_id
for order in orders
if order.local_order_id and str(order.order_status) in statuses
}
def apply_deals(round: Round, deals: list, today: str) -> list[tuple[str, object]]:
"""把属于本轮两条腿的成交累计进来;同一笔成交只计一次。
去重键是成交编号,不是本地订单号:一个委托拆成多笔成交是常态,
同一本地订单号下可以有多笔成交,各自都要计入。
Returns:
本轮新计入的 ``(腿名, 成交)`` 列表,腿名为 ``entry`` / ``exit``
供调用方逐笔打日志。
"""
applied: list[tuple[str, object]] = []
seen = set(round.seen_deal_ids)
for deal in deals:
local_id = deal.get_local_order_id
if local_id != round.entry_order_id and local_id != round.exit_order_id:
continue
key = deal.order_sys_id or f'{local_id}|{deal.trade_date}|{deal.trade_time}|{deal.volume}'
if key in seen:
continue
if local_id == round.entry_order_id:
round.entry_filled_qty += deal.volume
round.entry_amount += deal.trade_amount
applied.append(("entry", deal))
else:
round.exit_filled_qty += deal.volume
round.exit_amount += deal.trade_amount
applied.append(("exit", deal))
round.seen_deal_ids.append(key)
seen.add(key)
round.last_trade_date = today
return applied
def advance(round: Round, in_flight: set[str], today: str) -> None:
"""按委托是否仍在途推进阶段;只改变本记录,不下单。"""
if round.phase == PHASE_OPENING and round.entry_order_id not in in_flight:
if round.kind == KIND_BASE:
_settle_base(round, today)
elif round.residual_qty > 0:
round.phase = PHASE_OPEN
elif round.residual_qty == 0:
_finish(round, today, OUTCOME_ABORTED, "开仓腿未成交即终态")
else:
_finish(round, today, OUTCOME_ABORTED,
"成交累计异常:平仓量超过开仓量,本轮作废")
elif round.phase == PHASE_CLOSING and round.exit_order_id not in in_flight:
if round.residual_qty > 0:
round.phase = PHASE_OPEN # 平仓腿部分成交或有撤单,余量继续处理
elif round.residual_qty == 0:
_finish(round, today, OUTCOME_NORMAL, "")
else:
_finish(round, today, OUTCOME_NORMAL, "成交累计异常:平仓量超过开仓量")
def _settle_base(round: Round, today: str) -> None:
"""建仓腿定局:以实际成交均价确定基准成本(用户要求用建仓价)。"""
if round.entry_filled_qty <= 0:
_finish(round, today, OUTCOME_ABORTED, "建仓腿未成交即终态")
return
round.base_qty = round.entry_filled_qty
round.base_cost = round.entry_avg_price
round.base_date = round.open_date or today
round.base_source = BASE_SOURCE_OPENED
_finish(round, today, OUTCOME_BASE, "底仓已建立")
def expire(round: Round, today: str, max_hold_days: int) -> bool:
"""轮次持有超过上限则放弃;不强平,残量留作隔夜持仓。"""
if round.phase not in (PHASE_OPEN, PHASE_CLOSING) or not round.open_date:
return False
if _days_between(round.open_date, today) <= max_hold_days:
return False
_finish(round, today, OUTCOME_EXPIRED,
f"持有超过 {max_hold_days} 天,放弃继续平仓")
return True
def _finish(round: Round, today: str, outcome: str, note: str) -> None:
_absorb_residual(round)
round.phase = PHASE_CLOSED
round.close_date = today
round.outcome = outcome
round.exit_plan_qty = 0
if outcome == OUTCOME_ABORTED:
# 没有产生任何持仓的作废轮次不占用当日配额,允许重新判断一次。
round.open_date = ""
if note:
round.note = note
def _absorb_residual(round: Round) -> None:
"""把未平掉的轮次敞口并入底仓数量,成本基准保持建仓价不变。
没有这一步超期放弃的反T 会在"卖出未买回"的敞口上再开一轮,把仓位
越做越偏;并入底仓后基准数量与券商持仓重新对齐,下一轮的下单量才准。
"""
if round.kind == KIND_LONG_T:
round.base_qty = max(0, round.base_qty + round.residual_qty)
elif round.kind == KIND_SHORT_T:
round.base_qty = max(0, round.base_qty - round.residual_qty)
def _days_between(start: str, today: str) -> int:
try:
return (date.fromisoformat(today) - date.fromisoformat(start)).days
except ValueError:
return 0
def start_round(round: Round, kind: str, today: str) -> None:
"""在已有基准上开新一轮,清空上一轮的两条腿与审计字段。
必须走这个入口而不是直接改字段:上一轮的 ``exit_filled_qty`` 若是残留,
``residual_qty`` 会变成负数,``advance`` 会把它当成"作废"并立刻重开一轮。
"""
round.kind = kind
round.phase = PHASE_OPENING
round.open_date = today
round.close_date = ""
round.outcome = ""
round.note = ""
round.entry_order_id = ""
round.entry_plan_qty = 0
round.entry_filled_qty = 0
round.entry_amount = 0.0
round.exit_order_id = ""
round.exit_plan_qty = 0
round.exit_filled_qty = 0
round.exit_amount = 0.0
round.seen_deal_ids = []
def new_round(code: str, kind: str, today: str, base_qty: int, base_cost: float,
base_date: str = "", base_source: str = "") -> Round:
"""构造一条带基准的新轮次记录。"""
record = Round(code=code, base_qty=base_qty, base_cost=base_cost,
base_date=base_date or today, base_source=base_source)
start_round(record, kind, today)
return record
def new_base_round(code: str, today: str, plan_qty: int) -> Round:
"""建底仓:只有买入腿,成交均价随后写入 base_cost。"""
record = Round(code=code)
start_round(record, KIND_BASE, today)
record.entry_plan_qty = plan_qty
return record
def is_owned_base(round: Round) -> bool:
"""基准是否由本策略自己建立。
只有 ``base_source=opened``(建仓腿成交后写入)算自有基准;账户里已有的
持仓不会被接管,因此不会出现别的来源。
"""
return round.base_qty > 0 and round.base_source == BASE_SOURCE_OPENED
def touch(round: Round, now: datetime | None = None) -> None:
round.updated_at = (now or datetime.now()).isoformat(sep=" ", timespec="seconds")
class RoundStore:
"""每账户一个 JSON 文件,整文件原子替换。"""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.rounds: dict[str, Round] = {}
self.load()
def load(self) -> None:
try:
raw = self.path.read_text(encoding="utf-8")
except FileNotFoundError:
self.rounds = {}
return
except OSError as exc:
raise RoundStoreError(f"读取轮次状态失败: {exc}") from exc
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
raise RoundStoreError(f"解析轮次状态失败: {exc}") from exc
if not isinstance(payload, dict):
raise RoundStoreError("轮次状态根节点必须是对象")
rounds: dict[str, Round] = {}
for code, value in payload.items():
if not isinstance(value, dict):
raise RoundStoreError(f"轮次状态 {code} 必须是对象")
unknown = set(value) - _ROUND_FIELDS
if unknown:
raise RoundStoreError(f"轮次状态 {code} 含未知字段: {sorted(unknown)}")
value["code"] = code
rounds[code] = Round(**value)
self.rounds = rounds
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_name(self.path.name + ".tmp")
temporary.write_text(
json.dumps({code: asdict(item) for code, item in self.rounds.items()},
ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary, self.path)
def get(self, code: str) -> Round:
return self.rounds.get(code) or Round(code=code)
def put(self, round: Round) -> None:
self.rounds[round.code] = round
def drop(self, code: str) -> None:
self.rounds.pop(code, None)