This commit is contained in:
2026-09-15 20:02:05 +08:00
parent bfe89ba122
commit 04daeff141
37 changed files with 2674 additions and 1426 deletions

View File

@@ -0,0 +1,379 @@
"""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)