This commit is contained in:
2026-09-06 14:49:46 +08:00
parent 2eafbb8303
commit fdcbdc7869
5 changed files with 162 additions and 68 deletions

View File

@@ -1,6 +1,6 @@
from .client import Client from .client import Client
from .errors import APIError, BusinessError from .errors import APIError, BusinessError
from .models import Assets, OrderItem, Portfolio, PositionItem, Tick from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem, Tick
from .trade import ( from .trade import (
OP_BUY, OP_BUY,
OP_SELL, OP_SELL,
@@ -9,11 +9,13 @@ from .trade import (
PR_TYPE_LATEST, PR_TYPE_LATEST,
QUICK_TRADE_NOW, QUICK_TRADE_NOW,
) )
__all__ = [ __all__ = [
"APIError", "APIError",
"Assets", "Assets",
"BusinessError", "BusinessError",
"Client", "Client",
"DealItem",
"OP_BUY", "OP_BUY",
"OP_SELL", "OP_SELL",
"ORDER_SIDE_BY_OFFSET", "ORDER_SIDE_BY_OFFSET",

View File

@@ -14,6 +14,7 @@ def _number(value: Any, kind: type = float) -> Any:
@dataclass(slots=True) @dataclass(slots=True)
class OrderItem: class OrderItem:
"""由 QMT 委托明细解析得到的标准订单记录。""" """由 QMT 委托明细解析得到的标准订单记录。"""
id: str id: str
code: str code: str
side: str side: str
@@ -64,11 +65,74 @@ class OrderItem:
) )
@dataclass(slots=True)
class DealItem:
"""由 QMT Deal 成交对象解析得到的标准成交记录。"""
id: str
order_id: str
code: str
side: str
remark: str
traded_at: datetime | None
volume: int
price: float
amount: float
local_order_id: str = ""
order_ref: str = ""
exchange_id: str = ""
name: str = ""
account_id: str = ""
commission: float = 0.0
trade_date: str = ""
trade_time: str = ""
@classmethod
def from_trade_detail(cls, data: dict[str, Any]) -> "DealItem":
"""从 TradeDetailData 的 QMT Deal 原始字段创建成交记录。"""
instrument_id = str(data.get("m_strInstrumentID") or "")
exchange_id = str(data.get("m_strExchangeID") or "")
code = (
f"{instrument_id}.{exchange_id}"
if instrument_id and exchange_id
else instrument_id
)
remark = str(data.get("m_strRemark") or "")
trade_date = str(data.get("m_strTradeDate") or "")
trade_time = str(data.get("m_strTradeTime") or "")
return cls(
id=str(data.get("m_strTradeID") or ""),
order_id=str(data.get("m_strOrderSysID") or ""),
code=code,
side={"23": "BUY", "24": "SELL", "48": "BUY", "49": "SELL"}.get(
str(data.get("m_nOffsetFlag")), ""
),
remark=remark,
traded_at=_parse_datetime(trade_date, trade_time),
volume=_number(data.get("m_nVolume"), int),
price=_number(data.get("m_dPrice")),
amount=_number(data.get("m_dTradeAmount")),
local_order_id=remark.split("|", 1)[0] if remark else "",
order_ref=str(data.get("m_strOrderRef") or ""),
exchange_id=exchange_id,
name=str(data.get("m_strInstrumentName") or ""),
account_id=str(data.get("m_strAccountID") or ""),
commission=_number(
data.get(
"m_dCommission",
data.get("m_dComission", data.get("m_dComssion")),
)
),
trade_date=trade_date,
trade_time=trade_time,
)
@dataclass(slots=True) @dataclass(slots=True)
class PositionItem: class PositionItem:
stock_code: str = "" stock_code: str = ""
stock_name: str = "" stock_name: str = ""
trade_id:str = "" trade_id: str = ""
direction: Any = None direction: Any = None
volume: int = 0 volume: int = 0
open_cost: float = 0.0 open_cost: float = 0.0
@@ -88,15 +152,24 @@ class PositionItem:
@classmethod @classmethod
def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem": def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem":
return cls( return cls(
stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""), stock_code=str(data.get("StockCode") or code),
trade_id=str(data.get("TradeID") or ""),open_cost=_number(data.get("OpenCost")), stock_name=str(data.get("StockName") or ""),
direction=data.get("Direction"), volume=_number(data.get("Volume"), int), trade_id=str(data.get("TradeID") or ""),
open_price=_number(data.get("OpenPrice")), float_profit=_number(data.get("FloatProfit")), open_cost=_number(data.get("OpenCost")),
market_value=_number(data.get("MarketValue")), stock_holder=str(data.get("StockHolder") or ""), direction=data.get("Direction"),
frozen_volume=_number(data.get("FrozenVolume"), int), can_use_volume=_number(data.get("CanUseVolume"), int), volume=_number(data.get("Volume"), int),
on_road_volume=_number(data.get("OnRoadVolume"), int), yesterday_volume=_number(data.get("YesterdayVolume"), int), open_price=_number(data.get("OpenPrice")),
last_price=_number(data.get("LastPrice")), profit_rate=_number(data.get("ProfitRate")), float_profit=_number(data.get("FloatProfit")),
future_trade_type=data.get("FutureTradeType"), expire_date=str(data.get("ExpireDate") or ""), market_value=_number(data.get("MarketValue")),
stock_holder=str(data.get("StockHolder") or ""),
frozen_volume=_number(data.get("FrozenVolume"), int),
can_use_volume=_number(data.get("CanUseVolume"), int),
on_road_volume=_number(data.get("OnRoadVolume"), int),
yesterday_volume=_number(data.get("YesterdayVolume"), int),
last_price=_number(data.get("LastPrice")),
profit_rate=_number(data.get("ProfitRate")),
future_trade_type=data.get("FutureTradeType"),
expire_date=str(data.get("ExpireDate") or ""),
) )
@classmethod @classmethod
@@ -146,8 +219,14 @@ class Portfolio:
def _trade_datetime(data: dict[str, Any]) -> datetime | None: def _trade_datetime(data: dict[str, Any]) -> datetime | None:
date = str(data.get("m_strInsertDate") or "") return _parse_datetime(
clock = str(data.get("m_strInsertTime") or "").replace(":", "").zfill(6) str(data.get("m_strInsertDate") or ""),
str(data.get("m_strInsertTime") or ""),
)
def _parse_datetime(date: str, clock: str) -> datetime | None:
clock = clock.replace(":", "").zfill(6)
try: try:
return datetime.strptime(date + clock, "%Y%m%d%H%M%S") return datetime.strptime(date + clock, "%Y%m%d%H%M%S")
except ValueError: except ValueError:
@@ -165,8 +244,12 @@ class Tick:
if not isinstance(data, dict): if not isinstance(data, dict):
return cls() return cls()
return cls( return cls(
last_price=_number(data.get("lastPrice", data.get("last_price", data.get("LastPrice")))), last_price=_number(
last_close=_number(data.get("lastClose", data.get("last_close", data.get("LastClose")))), data.get("lastPrice", data.get("last_price", data.get("LastPrice")))
),
last_close=_number(
data.get("lastClose", data.get("last_close", data.get("LastClose")))
),
raw=data, raw=data,
) )
@@ -193,28 +276,54 @@ class MarketDataRequest:
@dataclass(slots=True) @dataclass(slots=True)
class FinancialDataRequest: class FinancialDataRequest:
tabname: str = ""; colname: str = ""; market: str = ""; code: str = "" tabname: str = ""
report_type: str = ""; barpos: int = 0 colname: str = ""
field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list) market: str = ""
start_date: str = ""; end_date: str = "" code: str = ""
report_type: str = ""
barpos: int = 0
field_list: list[str] = field(default_factory=list)
stock_list: list[str] = field(default_factory=list)
start_date: str = ""
end_date: str = ""
@dataclass(slots=True) @dataclass(slots=True)
class FactorDataRequest: class FactorDataRequest:
field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list) field_list: list[str] = field(default_factory=list)
stock_code: str = ""; start_date: str = ""; end_date: str = "" stock_list: list[str] = field(default_factory=list)
stock_code: str = ""
start_date: str = ""
end_date: str = ""
@dataclass(slots=True) @dataclass(slots=True)
class BSMPriceRequest: class BSMPriceRequest:
option_type: str; object_prices: Any; strike_price: float; risk_free: float; sigma: float; days: int; dividend: float option_type: str
object_prices: Any
strike_price: float
risk_free: float
sigma: float
days: int
dividend: float
@dataclass(slots=True) @dataclass(slots=True)
class BSMIVRequest: class BSMIVRequest:
option_type: str; object_prices: float; strike_price: float; option_price: float; risk_free: float; days: int; dividend: float option_type: str
object_prices: float
strike_price: float
option_price: float
risk_free: float
days: int
dividend: float
@dataclass(slots=True) @dataclass(slots=True)
class LocalDataRequest: class LocalDataRequest:
stock_code: str; start_time: str = ""; end_time: str = ""; period: str = ""; divid_type: str = ""; count: int = 0 stock_code: str
start_time: str = ""
end_time: str = ""
period: str = ""
divid_type: str = ""
count: int = 0

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from .models import Assets, OrderItem, Portfolio, PositionItem from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem
class PortfolioMixin: class PortfolioMixin:
@@ -41,9 +41,9 @@ class PortfolioMixin:
data = self._get_json("/api/portfolio/order") or [] data = self._get_json("/api/portfolio/order") or []
return [OrderItem.from_trade_detail(row) for row in data] return [OrderItem.from_trade_detail(row) for row in data]
def deals(self) -> list[dict[str, Any]]: def deals(self) -> list[DealItem]:
data = self._get_json("/api/portfolio/deal") or {} """查询原始 Deal 成交对象并转换为标准成交记录。"""
return data.get("deals", []) return [DealItem.from_trade_detail(row) for row in self.org("deal")]
def trade_detail_data(self, datatype: str) -> Any: def trade_detail_data(self, datatype: str) -> Any:
datatype = str(datatype).strip().lower() datatype = str(datatype).strip().lower()

View File

@@ -54,9 +54,10 @@ def StartZT() -> None:
# 收盘前最后一次只读对账,不发新单;未完成买回继续持久保存。 # 收盘前最后一次只读对账,不发新单;未完成买回继续持久保存。
try: try:
portfolio = client.portfolio() portfolio = client.portfolio()
deals = client.deals()
state.reconcile( state.reconcile(
list(portfolio.positions.values()), list(portfolio.positions.values()),
portfolio.orders, deals,
now.date().isoformat(), now.date().isoformat(),
) )
except Exception: except Exception:
@@ -84,10 +85,11 @@ def RunOnce(run: Runtime, state: TState) -> None:
# 1. 一次获取资产、持仓和订单,并清理过期订单。 # 1. 一次获取资产、持仓和订单,并清理过期订单。
portfolio = run.client.portfolio() portfolio = run.client.portfolio()
deals = run.client.deals()
positions = list(portfolio.positions.values()) positions = list(portfolio.positions.values())
run.orders.refresh(run.client, portfolio.orders) run.orders.refresh(run.client, portfolio.orders)
# 对账使用完整原始订单列表,不能丢弃撤单和废单的部分成交 # 状态只按真实成交记账,不使用委托状态推算数量和成本
state.reconcile(positions, portfolio.orders, today) state.reconcile(positions, deals, today)
# 2. 获取本策略的信号开仓数据;信号失败不阻断已有做 T 买回。 # 2. 获取本策略的信号开仓数据;信号失败不阻断已有做 T 买回。
try: try:

View File

@@ -9,10 +9,9 @@ from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from time import time from time import time
from sdk import OrderItem, PositionItem from sdk import DealItem, PositionItem
READY, SOLD, DONE = "READY", "SOLD", "DONE" READY, SOLD, DONE = "READY", "SOLD", "DONE"
TERMINAL_STATUSES = {"53", "54", "56", "57"}
@dataclass(slots=True) @dataclass(slots=True)
@@ -72,14 +71,14 @@ class TState:
raise raise
def reconcile( def reconcile(
self, positions: list[PositionItem], orders: list[OrderItem], today: str self, positions: list[PositionItem], deals: list[DealItem], today: str
) -> None: ) -> None:
"""先按实际成交记账,再接管未知持仓;不覆盖已记录的底仓成本。""" """先按实际成交记账,再接管未知持仓;不覆盖已记录的底仓成本。"""
# 同一本地委托可能拆单;按券商订单号去重,数量齐全且全部结束才记账 # 同一本地委托可能有多笔成交;按成交编号去重后合并数量和金额
by_id: dict[str, dict[str, OrderItem]] = {} by_id: dict[str, dict[str, DealItem]] = {}
for order in orders: for deal in deals:
if order.local_order_id: if deal.local_order_id and deal.id:
by_id.setdefault(order.local_order_id, {})[order.id] = order by_id.setdefault(deal.local_order_id, {})[deal.id] = deal
for order_id, pending in list(self.pending.items()): for order_id, pending in list(self.pending.items()):
side = "SELL" if pending.kind == "sell" else "BUY" side = "SELL" if pending.kind == "sell" else "BUY"
rows = [ rows = [
@@ -88,25 +87,16 @@ class TState:
if row.code == pending.code and row.side == side if row.code == pending.code and row.side == side
] ]
if not rows: if not rows:
log.warning("[ZT 状态] 委托暂未查到,保留待确认:%s", order_id) log.warning("[ZT 状态] 成交暂未查到,保留待确认:%s", order_id)
continue continue
if sum(row.volume for row in rows) != pending.qty: qty = sum(row.volume for row in rows)
# 成交未达到计划数量时继续等待,防止后续成交到达后重复记账。
if qty != pending.qty:
continue continue
if any(row.status not in TERMINAL_STATUSES for row in rows):
continue
if any(
row.status == "56" and row.traded_volume != row.volume for row in rows
):
continue
qty = sum(row.traded_volume for row in rows)
amounts = [ amounts = [
( row.amount if row.amount > 0 else row.price * row.volume
row.trade_amount
if row.trade_amount > 0
else row.trade_price * row.traded_volume
)
for row in rows for row in rows
if row.traded_volume > 0 if row.volume > 0
] ]
if any(not math.isfinite(amount) or amount <= 0 for amount in amounts): if any(not math.isfinite(amount) or amount <= 0 for amount in amounts):
continue continue
@@ -132,7 +122,7 @@ class TState:
item.phase = DONE if total >= item.sell_qty else SOLD item.phase = DONE if total >= item.sell_qty else SOLD
if item.phase == DONE: if item.phase == DONE:
item.trade_date = today item.trade_date = today
# 零成交撤单也记录,保留计划、实际数量、均价和柜台终态 # 记录真实成交编号,重启后仍可核对本次状态变更的来源
self.records.append( self.records.append(
{ {
**asdict(pending), **asdict(pending),
@@ -140,7 +130,7 @@ class TState:
"filled_qty": qty, "filled_qty": qty,
"filled_cost": cost, "filled_cost": cost,
"amount": amount, "amount": amount,
"statuses": [row.status for row in rows], "deal_ids": [row.id for row in rows],
} }
) )
del self.pending[order_id] del self.pending[order_id]
@@ -201,19 +191,10 @@ class TState:
if not self.path.is_file(): if not self.path.is_file():
return return
raw = json.loads(self.path.read_text(encoding="utf-8")) raw = json.loads(self.path.read_text(encoding="utf-8"))
# 兼容原 ZT 文件,保留原底仓成本;没有额外版本字段。
self.items = { self.items = {
code: TStateItem(**item) for code, item in raw.get("items", raw).items() code: TStateItem(**item) for code, item in raw["items"].items()
} }
self.pending = { self.pending = {
key: PendingOrder(**item) for key, item in raw.get("pending", {}).items() key: PendingOrder(**item) for key, item in raw["pending"].items()
} }
self.records = raw.get("records", []) self.records = raw["records"]
if "items" not in raw:
# 旧记录只有提交行情价,不把它伪装成真实成交历史。
self.records.append({"kind": "legacy_import", "items": raw})
for item in self.items.values():
if item.phase in {"SELLING", "BUYING", SOLD}:
raise ValueError(
f"[ZT 状态] {item.code} 旧做 T 轮次未结束,需先核对成交再迁移"
)