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 .errors import APIError, BusinessError
from .models import Assets, OrderItem, Portfolio, PositionItem, Tick
from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem, Tick
from .trade import (
OP_BUY,
OP_SELL,
@@ -9,11 +9,13 @@ from .trade import (
PR_TYPE_LATEST,
QUICK_TRADE_NOW,
)
__all__ = [
"APIError",
"Assets",
"BusinessError",
"Client",
"DealItem",
"OP_BUY",
"OP_SELL",
"ORDER_SIDE_BY_OFFSET",

View File

@@ -14,6 +14,7 @@ def _number(value: Any, kind: type = float) -> Any:
@dataclass(slots=True)
class OrderItem:
"""由 QMT 委托明细解析得到的标准订单记录。"""
id: str
code: 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)
class PositionItem:
stock_code: str = ""
stock_name: str = ""
trade_id:str = ""
trade_id: str = ""
direction: Any = None
volume: int = 0
open_cost: float = 0.0
@@ -88,15 +152,24 @@ class PositionItem:
@classmethod
def from_dict(cls, data: dict[str, Any], code: str = "") -> "PositionItem":
return cls(
stock_code=str(data.get("StockCode") or code), stock_name=str(data.get("StockName") or ""),
trade_id=str(data.get("TradeID") or ""),open_cost=_number(data.get("OpenCost")),
direction=data.get("Direction"), volume=_number(data.get("Volume"), int),
open_price=_number(data.get("OpenPrice")), float_profit=_number(data.get("FloatProfit")),
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 ""),
stock_code=str(data.get("StockCode") or code),
stock_name=str(data.get("StockName") or ""),
trade_id=str(data.get("TradeID") or ""),
open_cost=_number(data.get("OpenCost")),
direction=data.get("Direction"),
volume=_number(data.get("Volume"), int),
open_price=_number(data.get("OpenPrice")),
float_profit=_number(data.get("FloatProfit")),
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
@@ -146,8 +219,14 @@ class Portfolio:
def _trade_datetime(data: dict[str, Any]) -> datetime | None:
date = str(data.get("m_strInsertDate") or "")
clock = str(data.get("m_strInsertTime") or "").replace(":", "").zfill(6)
return _parse_datetime(
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:
return datetime.strptime(date + clock, "%Y%m%d%H%M%S")
except ValueError:
@@ -165,8 +244,12 @@ class Tick:
if not isinstance(data, dict):
return cls()
return cls(
last_price=_number(data.get("lastPrice", data.get("last_price", data.get("LastPrice")))),
last_close=_number(data.get("lastClose", data.get("last_close", data.get("LastClose")))),
last_price=_number(
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,
)
@@ -193,28 +276,54 @@ class MarketDataRequest:
@dataclass(slots=True)
class FinancialDataRequest:
tabname: str = ""; colname: str = ""; market: 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 = ""
tabname: str = ""
colname: str = ""
market: 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)
class FactorDataRequest:
field_list: list[str] = field(default_factory=list); stock_list: list[str] = field(default_factory=list)
stock_code: str = ""; start_date: str = ""; end_date: str = ""
field_list: list[str] = field(default_factory=list)
stock_list: list[str] = field(default_factory=list)
stock_code: str = ""
start_date: str = ""
end_date: str = ""
@dataclass(slots=True)
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)
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)
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 .models import Assets, OrderItem, Portfolio, PositionItem
from .models import Assets, DealItem, OrderItem, Portfolio, PositionItem
class PortfolioMixin:
@@ -41,9 +41,9 @@ class PortfolioMixin:
data = self._get_json("/api/portfolio/order") or []
return [OrderItem.from_trade_detail(row) for row in data]
def deals(self) -> list[dict[str, Any]]:
data = self._get_json("/api/portfolio/deal") or {}
return data.get("deals", [])
def deals(self) -> list[DealItem]:
"""查询原始 Deal 成交对象并转换为标准成交记录。"""
return [DealItem.from_trade_detail(row) for row in self.org("deal")]
def trade_detail_data(self, datatype: str) -> Any:
datatype = str(datatype).strip().lower()

View File

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

View File

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