fix bug
This commit is contained in:
379
py-client/strategy/zt/rounds.py
Normal file
379
py-client/strategy/zt/rounds.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user