380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""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)
|