From fdcbdc7869eeac60fe5f91f1ed6150717b9b4c67 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Sun, 6 Sep 2026 14:49:46 +0800 Subject: [PATCH] fix bug --- py-client/sdk/__init__.py | 4 +- py-client/sdk/models.py | 155 ++++++++++++++++++++++++++++----- py-client/sdk/portfolio.py | 8 +- py-client/strategy/zt/boot.py | 8 +- py-client/strategy/zt/state.py | 55 ++++-------- 5 files changed, 162 insertions(+), 68 deletions(-) diff --git a/py-client/sdk/__init__.py b/py-client/sdk/__init__.py index 7ead348..85a0e41 100644 --- a/py-client/sdk/__init__.py +++ b/py-client/sdk/__init__.py @@ -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", diff --git a/py-client/sdk/models.py b/py-client/sdk/models.py index 5b1c335..c957650 100644 --- a/py-client/sdk/models.py +++ b/py-client/sdk/models.py @@ -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 diff --git a/py-client/sdk/portfolio.py b/py-client/sdk/portfolio.py index e4e3df2..6a91168 100644 --- a/py-client/sdk/portfolio.py +++ b/py-client/sdk/portfolio.py @@ -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() diff --git a/py-client/strategy/zt/boot.py b/py-client/strategy/zt/boot.py index fff2674..abdf46e 100644 --- a/py-client/strategy/zt/boot.py +++ b/py-client/strategy/zt/boot.py @@ -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: diff --git a/py-client/strategy/zt/state.py b/py-client/strategy/zt/state.py index 558f781..b0d1d4c 100644 --- a/py-client/strategy/zt/state.py +++ b/py-client/strategy/zt/state.py @@ -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"]