"""ZT 做 T 轮次状态:一只股票同时最多一轮,允许跨日持有。 正T(``LONG_T``)与反T(``SHORT_T``)共用同一组字段,区别只是两条腿的方向: 正T:entry=BUY exit=SELL 低吸 → 高抛 反T:entry=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)